# Event Subscriptions

This guide shows you how to add Azure Service Bus event subscription capabilities to your existing .NET application.

!!! tip "Aspire Publish Alternative"
    Starting in Forge 3.3, Azure Functions subscription projects are auto-detected by Aspire publish when using the `SAIF.Platform.Aspire.Hosting` package. Add them with `AddSubscriptionService<TProject>()` in your AppHost and pipeline YAML is generated automatically. See [Aspire Publish](../aspire-publish.md) for details.

[TOC]

## 📋 Prerequisites

- ✅ Existing SAIF API project
- ✅ .NET 10.0 SDK
- ✅ Docker Desktop
- ✅ Azure DevOps access

## 🚀 Quick Start

### 1. Add the Feature

**Using SAIF CLI:**

```bash
saif new saif-feature-event-subscription
```

**Using .NET Template:**

```bash
dotnet new saif-feature-event-subscription -n MyApp -o . \
  --application_name "MyApp" \
  --owner "Platform" \
  --project_id "it-api-exp-myapp" \
  --tenant "corporate"
```

This adds:

- `YourApp.Subscriptions` - Functions project
- `YourApp.ServiceBus.Seed` - Test data seeder
- Infrastructure and pipeline files

### 2. Wire Up AppHost

⚠️ **Manual step required**: Add to `src/YourApp.AppHost/Program.cs`:

```csharp
var subscription = builder.AddSubscription();
```

**Complete example:**

```csharp
var builder = DistributedApplication.CreateBuilder(args);

var backend = builder.AddApi();
var subscription = builder.AddSubscription();  // 👈 Add this

builder.Build().Run();
```

## ⚙️ Configure Subscriptions

Edit `infra/sub/vars.yml` with the topics and subscriptions you want to create:

```yaml
application_name: YourApp
owner: Platform
project_id: it-api-exp-yourapp
function_app_project_id: it-func-yourapp
events:
  - topic: newuser # Topic name from the event service
    subscription: it-func-yourapp-newuser-subscription
  - topic: policy # Add as many topics as you need
    subscription: it-func-yourapp-policy-subscription
  - topic: accountupdated
    subscription: it-func-yourapp-accountupdated-subscription
```

💡 **Note**: Subscription names must be unique within each topic. It is recommended to prefix subscription names with your function app project ID (e.g., `it-func-yourapp-*`), as that is guaranteed to be unique, to avoid conflicts with other applications subscribing to the same topics.

## 🎯 Create Event Handlers

You need to create a **model** and **trigger function** for each event type in your `vars.yml`.

### Option A: Manual POCOs (Plain Old CLR Objects)

#### 1. Define Event Models

Create one model per event type:

**`src/YourApp.Subscriptions/Models/NewUserEvent.cs`:**

```csharp
using System.Text.Json.Serialization;

namespace YourApp.Subscriptions.Models;

public class NewUserEvent
{
  [JsonPropertyName("eventId")]
  public string EventId { get; set; } = string.Empty;

  [JsonPropertyName("userId")]
  public string UserId { get; set; } = string.Empty;

  [JsonPropertyName("email")]
  public string Email { get; set; } = string.Empty;
}
```

💡 **Tip**: Use `[JsonPropertyName]` to match the exact property names from the event publisher.

#### 2. Create Trigger Functions

Create one trigger per event type:

**`src/YourApp.Subscriptions/Triggers/NewUserTrigger.cs`:**

```csharp
using Azure.Messaging.ServiceBus;
using Microsoft.Azure.Functions.Worker;
using Microsoft.Extensions.Logging;
using System.Text.Json;
using YourApp.Subscriptions.Models;

namespace YourApp.Subscriptions.Triggers;

public class NewUserTrigger(ILogger<NewUserTrigger> logger)
{
  [Function("NewUserTrigger")]
  public void Run(
      [ServiceBusTrigger("newuser", "it-func-yourapp-newuser-subscription", Connection = "sbnamespace")]
      ServiceBusReceivedMessage message)
  {
    logger.LogInformation("Processing user event: {MessageId}", message.MessageId);

    try
    {
      var userEvent = JsonSerializer.Deserialize<NewUserEvent>(message.Body.ToString());

      if (userEvent == null)
        throw new InvalidOperationException("Invalid message format");

      // YOUR BUSINESS LOGIC HERE
      logger.LogInformation("User: {UserId}, Email: {Email}",
        userEvent.UserId, userEvent.Email);
    }
    catch (Exception ex)
    {
      logger.LogError(ex, "Error processing message");
      throw; // Re-throw for retry
    }
  }
}
```

**Key points:**

- Topic and subscription names must match `vars.yml`
- Connection is always `"sbnamespace"` (unless using a custom namespace and implementation)
- Throw exceptions to trigger retries
- Repeat this pattern for each event type

---

### Option B: Generate Models with Kiota

Instead of manually creating POCOs, you can generate type-safe models from OpenAPI specifications using [Kiota](https://learn.microsoft.com/en-us/openapi/kiota/overview).

#### Benefits

- Type-safe models automatically generated from OpenAPI specs
- Reduced manual maintenance and errors
- Built-in JSON serialization/deserialization
- Keeps models in sync with event schemas
- Automatic regeneration on build when OpenAPI spec changes

#### Configure Kiota Reference

The template includes a commented-out `KiotaReference` in the `.csproj` file. To enable it:

1. **Open `src/YourApp.Subscriptions/YourApp.Subscriptions.csproj`**

2. **Uncomment the KiotaReference section** at the bottom of the file:

```xml
<ItemGroup>
  <KiotaReference Include="KiotaGenerated" OpenApi="https://openapi.saif.com/{event-service-project-id}/{environment}/{openapi-spec-file-name}.yaml">
    <NamespaceName>YourApp.Subscriptions</NamespaceName>
  </KiotaReference>
</ItemGroup>
```

Replace:

- `{event-service-project-id}` with the event service's project ID (e.g., `it-api-sys-testeventing`)
- `{environment}` with `test`, `qa`, `uat` or `prod`
- `{openapi-spec-file-name}` with the spec filename (commonly `openapi` or `openapi.v1`)

💡 **Tip**: You can find the correct OpenAPI URL by checking the event service's documentation or asking the event service team.

3. **Remove or comment out the pre-created Models files**:

   The template creates a `Models` folder by default. Since Kiota will generate models in the `KiotaGenerated/Models` folder, you can either:

   - Delete the `src/YourApp.Subscriptions/Models` folder, or
   - Keep it for any custom models you want to create manually alongside the generated ones

4. **Build the project** to generate models:

```powershell
cd src/YourApp.Subscriptions
dotnet build
```

The models will be automatically generated in the `KiotaGenerated/Models` folder during build.

#### Use Generated Models in Triggers

Create one trigger per event type:

**`src/YourApp.Subscriptions/Triggers/NewUserTrigger.cs`:**

```csharp
using Azure.Messaging.ServiceBus;
using Microsoft.Azure.Functions.Worker;
using Microsoft.Extensions.Logging;
using Microsoft.Kiota.Serialization.Json;
using YourApp.Subscriptions.Models;

namespace YourApp.Subscriptions.Triggers;

public class NewUserTrigger(ILogger<NewUserTrigger> logger)
{
  [Function("NewUserTrigger")]
  public void Run(
      [ServiceBusTrigger("newuser", "it-func-yourapp-newuser-subscription", Connection = "sbnamespace")]
      ServiceBusReceivedMessage message)
  {
    logger.LogInformation("Processing user event: {MessageId}", message.MessageId);

    try
    {
      // Deserialize using Kiota's JsonParseNodeFactory
      using var stream = new MemoryStream(message.Body.ToArray());
      var parseNode = new JsonParseNodeFactory().GetRootParseNode("application/json", stream);
      var userEvent = parseNode.GetObjectValue(NewUserEvent.CreateFromDiscriminatorValue);

      if (userEvent == null)
        throw new InvalidOperationException("Invalid message format");

      // YOUR BUSINESS LOGIC HERE
      logger.LogInformation("User: {UserId}, Email: {Email}",
        userEvent.UserId, userEvent.Email);
    }
    catch (Exception ex)
    {
      logger.LogError(ex, "Error processing message");
      throw; // Re-throw for retry
    }
  }
}
```

### Updating Models

When your OpenAPI spec changes, the models will be **automatically regenerated** on the next build. Simply run `dotnet build` and the `KiotaReference` in your `.csproj` file ensures models stay in sync with the OpenAPI spec without manual intervention.

💡 **Best Practice**: Always use the latest published OpenAPI spec URL (e.g., from `https://openapi.saif.com/...`) to ensure your models match the current event schema.

---

### Comparison: Manual vs. Kiota

| Aspect                | Manual POCOs                         | Kiota Generated (KiotaReference)             |
| --------------------- | ------------------------------------ | -------------------------------------------- |
| **Setup Time**        | Fast (write class)                   | Fast (uncomment config in .csproj)           |
| **Maintenance**       | Manual updates needed for each model | Automatic regeneration on build              |
| **Type Safety**       | Basic                                | Enhanced with factories                      |
| **Schema Validation** | None                                 | OpenAPI validation                           |
| **Multiple Events**   | Write each model separately          | All models generated automatically           |
| **Workflow**          | Manual coding                        | Build triggers generation                    |
| **Best For**          | Simple events, prototyping           | Multiple events, production, complex schemas |

---

## Integrating with Cosmos DB

If you need to interact with the event data in your application, storing the data in the API's Cosmos DB may be a viable solution. Here's a brief example of how to do that.

1. Add Cosmos DB to your project if not already done. Follow [Add Cosmos DB NoSQL](../data/cosmos-nosql.md) guide on how to get it set up before proceeding to step 2.

2. In the AppHost builder, make sure the `subscription` variable is created before the database variable, then add `subscription` as an argument to `.AddCosmosDb()`:

```csharp
...
var subscription = builder.AddSubscription();

var (cosmosdb, database) = builder.AddCosmosDb(backend, subscription);
...
```

3. In the Subscription builder, add the Cosmos DB context for dependency injection:

```csharp
...
builder
  .AddAzureDefaults()
  .AddServiceDefaults()
  .AddCosmosDbServices(); // 👈 Add this
...
```

4. Create mappers to convert event models to the existing Cosmos DB entities. Here is an hand-written mapper example (you can also use libraries like AutoMapper, Mapperly, etc.):

```csharp
public static class NewUserEventMapper
{
  public static Data.NewUserEvent ToEntity(this Models.NewUserEvent userEvent, string partitionKey)
  {
    return new Data.NewUserEvent
    {
      Id = userEvent.EventId,
      EventId = userEvent.EventId,
      UserId = userEvent.UserId,
      Email = userEvent.Email,
      PartitionKey = partitionKey
    };
  }
}
```

1. In your trigger, inject the Cosmos DB context and use it in the function to save the event data:

```csharp
public class NewUserEventTrigger(ILogger<NewUserEventTrigger> logger, EmmjohContext dbContext)
{
  [Function("NewUserTrigger")]
  public void Run(
      [ServiceBusTrigger("newuser", "it-func-yourapp-newuser-subscription", Connection = "sbnamespace")]
      ServiceBusReceivedMessage message, CancellationToken cancellationToken)
  {
    ...
    try {
      using var stream = new MemoryStream(message.Body.ToArray());
      var parseNode = await new JsonParseNodeFactory().GetRootParseNodeAsync("application/json", stream);
      var newUserEvent = parseNode.GetObjectValue(Models.NewUserEvent.CreateFromDiscriminatorValue);

      // Add to DbContext and save to Cosmos DB
      var entity = newUserEvent.ToEntity(partitionKey: newUserEvent.Email);
      dbContext.NewUserEvents.Add(entity);

      logger.LogInformation("Saving changes to Cosmos DB...");
      await dbContext.NewUserEvents.SaveChangesAsync(cancellationToken);

      logger.LogInformation("Successfully stored new user event {EventId} to Cosmos DB", newUserEvent.EventId);

      // Other business logic
      ...
    }
    ...
  }
}
```

## 🧪 Test Locally

1. **Run the AppHost** (F5 in Visual Studio or `dotnet run` in terminal):

2. **Aspire dashboard opens** showing:

   - Service Bus emulator
   - Your subscription function
   - Service Bus seeder (runs automatically)
   - Cosmos DB emulator (if integrating)
   - Cosmos DB seeder (if integrating, may need to manually run depending on setup)

3. **Check logs** for message processing:

```
[servicebus-seed] Seeded 5 messages to 'newuser' topic
[subscriptions] Processing user event: abc-123
[subscriptions] User: user123, Email: john@example.com
```

If integrating Cosmos DB, check logs for writing changes to the database:

```
[subscriptions] Saving changes to Cosmos DB...
[subscriptions] Executed CreateItem (284.6743 ms, 7.43 RU) ActivityId='52a0be67-604e-4a52-9133-173b942c1bb9', Container='NewUserEvents', Id='?', Partition='?'
[subscriptions] Successfully stored new user event abc-123 to Cosmos DB
```

### Optional: Customize Test Data

Edit `src/YourApp.ServiceBus.Seed/DataSeed.cs` to send custom test messages. Add a seeding method for each event type you configured in `vars.yml`.

## 🚀 Deploy

1. **Commit and push:**

```bash
git add .
git commit -m "Add event subscription feature"
git push
```

2. **Run pipeline** in Azure DevOps:
   - Pipeline name: `{project-id}-sub`
   - Example: `it-api-exp-myapp-sub`

The pipeline deploys:

- Azure Function App
- Service Bus subscriptions
- Application Insights

3. **Verify** in Azure Portal:

   - Function App is running
   - Service Bus subscriptions exist
   - Functions appear in Function App

4. **Monitor in Dynatrace:**
   - All logs are automatically sent to Dynatrace
   - View structured logs with your event data
   - Monitor function performance and errors
   - Access at: [Dynatrace Portal](https://kzf06550.live.dynatrace.com)

💡 **Tip**: Use structured logging (as shown in examples) for better Dynatrace integration. Log properties appear as filterable fields in Dynatrace.

## 🔍 Self-Service DLQ Access

You can grant Entra ID groups or user principals access to browse and manage dead-letter queue (DLQ) messages in **Service Bus Explorer** (Azure Portal) without requiring manual portal steps or platform team intervention.

### Two Access Tiers

| Tier         | Role                                | Environments                     | Capability                                                    |
| ------------ | ----------------------------------- | -------------------------------- | ------------------------------------------------------------- |
| **Readers**  | `Azure Service Bus Data Receiver`   | Non-production only (enforced)      | Peek/receive DLQ messages in Service Bus Explorer              |
| **Managers** | `Azure Service Bus Data Owner`      | All environments including prod  | Peek, complete, dead-letter, and requeue messages              |

!!! note "Data Receiver includes receive"
    The `Azure Service Bus Data Receiver` role is not peek-only — it includes receive, which can consume/complete messages. Reader access is **blocked in production by the `saif-event-subscriber-service` module** when `is_production = true` — even if a `prod` key appears in `dlq_readers`, no reader role assignments will be created.

### Configuring DLQ Access

Configure `dlq_readers` and `dlq_managers` in your `feature-event-subscription-dlq-vars.yaml` file as maps keyed by environment short name. Each environment maps to a list of identity entries. Each entry requires a `name` (descriptive label) and either an `object_id` or `group_name`:

```yaml
dlq_readers:
  test:
    - name: "Claims Dev Team"
      object_id: "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
  qa:
    - name: "Claims Dev Team"
      object_id: "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
    - name: "QA Testers"
      group_name: "Claims-QA-Readers"
  uat:
    - name: "QA Testers"
      group_name: "Claims-QA-Readers"
  # Reader access is always blocked in prod by the template.
  # You may omit the prod key here as a documentation convention, but it has no effect.

dlq_managers:
  test:
    - name: "Ops Team"
      group_name: "Claims-Ops"
  qa:
    - name: "Ops Team"
      group_name: "Claims-Ops"
  uat:
    - name: "Ops Team"
      group_name: "Claims-Ops"
  prod:
    - name: "Ops Team"
      group_name: "Claims-Ops"
```

The template automatically filters the list based on `var.environment_short_name` at plan time — only identities for the current environment are passed to the module. For `dlq_readers`, production is **always blocked** regardless of what keys are present in the YAML. For `dlq_managers`, all environment keys including `prod` are applied as configured.

| Field        | Description                                                                                                      | Required     |
| ------------ | ---------------------------------------------------------------------------------------------------------------- | ------------ |
| `name`       | Descriptive label for the identity entry (used as the Terraform state key — must be unique within the environment) | Yes          |
| `object_id`  | Entra ID object ID of a group or user principal                                                                  | One of these |
| `group_name` | Display name of an Entra ID security group (looked up automatically — see constraints below)                     | is required  |

!!! warning "Group Membership"
    You are responsible for managing membership of the Entra ID groups you reference. Forge provisions the role assignment — it does not manage who belongs to the group.

!!! warning "`group_name` Constraints"
    Group lookup uses Entra ID display name with `security_enabled = true`. Two constraints apply:

    - **Display names must be unique** across all security groups in the tenant. If multiple security groups share the same display name, the Terraform plan will fail.
    - **M365 groups are not supported** — only security groups are matched.

    Prefer `object_id` when possible to avoid ambiguous lookups. Find the object ID in **Microsoft Entra ID** → **Groups** → select your group → copy the **Object ID** from the overview page.

!!! info "New projects"
    Projects created with `saif new saif-feature-event-subscription` (or `dotnet new`) get `feature-event-subscription-dlq-vars.yaml` and `feature-event-subscription-dlq.tf` automatically — no manual steps needed.

??? info "Existing projects (created before this feature)"
    If your project was created before DLQ access support was added, add these three files manually:

    **1.** Create `infra/sub/feature-event-subscription-dlq-vars.yaml`:
    ```yaml
    dlq_readers:
      test: []
      qa: []
      uat: []

    dlq_managers:
      test: []
      qa: []
      uat: []
      prod: []
    ```

    **2.** Create `infra/sub/feature-event-subscription-dlq.tf`:
    ```hcl
    locals {
      feature_event_subscription_dlq_vars_yaml = file("${path.module}/feature-event-subscription-dlq-vars.yaml")
      feature_event_subscription_dlq_vars_data = yamldecode(local.feature_event_subscription_dlq_vars_yaml)

      # Production blocking is enforced by the saif-event-subscriber-service module via is_production.
      feature_event_subscription_dlq_readers = {
        for r in try(local.feature_event_subscription_dlq_vars_data["dlq_readers"][var.environment_short_name], []) :
        r["name"] => {
          object_id  = try(r["object_id"], null)
          group_name = try(r["group_name"], null)
        }
      }

      feature_event_subscription_dlq_managers = {
        for r in try(local.feature_event_subscription_dlq_vars_data["dlq_managers"][var.environment_short_name], []) :
        r["name"] => {
          object_id  = try(r["object_id"], null)
          group_name = try(r["group_name"], null)
        }
      }
    }
    ```

    **3.** Add DLQ identity parameters to your `module "subscriber"` call in `sub.generated.tf`:
    ```hcl
    is_production           = var.is_production
    dlq_reader_identities   = local.feature_event_subscription_dlq_readers
    dlq_manager_identities  = local.feature_event_subscription_dlq_managers
    ```

### Browsing DLQ Messages in the Azure Portal

Once your role assignment is deployed, navigate to the subscription in the Azure Portal:

**Azure Portal** → **Service Bus namespace** → **Topics** → select topic → **Subscriptions** → select subscription → **Service Bus Explorer** → **Dead-letter** tab

!!! warning "Switch to Microsoft Entra authentication"
    SAIF Service Bus namespaces have local (SAS key) authentication **disabled**. The Service Bus Explorer defaults to SAS key mode and will show an error banner. You must click **"Switch to Microsoft Entra authentication"** in the top-right of the explorer before you can peek or receive messages.

    This is a one-time click per browser session — it is a portal UI preference and cannot be automated.

After switching, use **Peek from start** or **Peek next messages** to browse DLQ messages without consuming them. **Receive** mode will consume the message from the DLQ — use it only if you intend to remove the message.

## 🔧 Troubleshooting

### Messages not being received

- ✅ Verify subscription exists in Azure Portal
- ✅ Check Function App is running (view in Dynatrace)
- ✅ Ensure topic names match exactly

### Deserialization errors

- ✅ Check `JsonPropertyName` attributes match publisher schema
- ✅ View error logs in Dynatrace with full stack traces
- ✅ Log raw message to see actual JSON:
  ```csharp
  logger.LogInformation("Raw: {Body}", message.Body.ToString());
  ```

### Local testing: Service Bus not starting

- ✅ Ensure Docker Desktop is running
- ✅ Restart Docker Desktop
- ✅ Re-run AppHost

### Deployment fails

- ✅ Check pipeline logs in Azure DevOps
- ✅ Verify `vars.yml` syntax is correct
- ✅ Ensure all required variables are set

### Messages in dead letter queue

- ✅ Check Dynatrace for exception logs and traces
- ✅ Verify event model matches message schema
- ✅ Filter Dynatrace logs by MessageId for detailed investigation
- ✅ [Configure DLQ access](#self-service-dlq-access) to browse dead-letter messages in Service Bus Explorer

### Viewing Logs and Monitoring

All function logs are automatically sent to **Dynatrace**:

- Filter by function name, severity, or custom properties
- View distributed traces across services
- Set up alerts for errors or performance issues
- Access: [Dynatrace Portal](https://kzf06550.live.dynatrace.com)

## 📚 Resources

- [SAIF CLI](../install-saif-cli.md)
- [Creating Event Services](event-service.md)
- [Azure Functions Docs](https://learn.microsoft.com/en-us/azure/azure-functions/)
- [Service Bus Docs](https://learn.microsoft.com/en-us/azure/service-bus-messaging/)
- [Event Orchestration Foundry Example](../../../foundry/event-orchestration.md) - Working example of the saga pattern for distributed event-driven transactions

```

```
