---
title: Service Bus Queue
description: Provision an application-scoped queue for internal producer and consumer messaging.
moved_from:
  - guides/development/eventing/servicebus-queue.md
---

# Service Bus Queue

!!! warning "Experimental"
    This feature is experimental and still evolving — module inputs, outputs, and behavior may
    change without a deprecation period, and you may encounter rough edges. Review the
    [module README](https://github.com/saif-corp/forge/blob/main/src/terraform/saif-resources/modules/servicebus-queue/README.md)
    and pin your module version carefully before relying on it in production.

This guide explains how to enable an app-scoped Azure Service Bus queue for your own internal producer/consumer messaging, without going through the Event Service.

## 🎯 Overview

Some workloads need a simple point-to-point queue purely internal to one app (e.g. a document-intake pipeline handing work between its own API and a background processor) rather than a pub/sub event that other apps subscribe to. The Forge platform provides:

- **RBAC Authentication**: Sender/Receiver access scoped to each queue itself, granted to your app's Entra identity (no connection strings, no SAS tokens)
- **DLQ / poison-message configuration**: configurable delivery-count threshold, message TTL, and dead-lettering, per queue
- **Multiple queues**: provision as many independent, named queues as your app needs (e.g. an intake queue and a separate retry/aux-workflow queue) — not limited to one queue per app
- **Feature Flag**: opt-in; a planned, equivalent Storage Account queue option is tracked separately (see [Service Bus queue vs. Storage queue](#service-bus-queue-vs-storage-queue) below)

## When to choose this over the Event Service

- Use the **Event Service** (topics + subscriptions, see [Event Service](event-service.md) and [Event Subscriptions](event-subscription.md)) when other apps need to react to something your app did.
- Use this **Service Bus queue** feature when only your own app produces and consumes the messages — internal work handoff, not a public event contract.

## Service Bus queue vs. Storage queue

Both are app-internal, single-producer/single-consumer options; the Service Bus queue is available today behind its own feature flag, while the equivalent Storage Account queue option is **planned** (tracked in #1165, not yet implemented). Pick per team based on throughput and ops needs once both are available:

| | Service Bus queue | Storage Account queue (planned) |
| --- | --- | --- |
| Message size | Up to 256 KB (Standard) | Up to 64 KB |
| Ordering / sessions | FIFO via sessions (not exposed by this module) | No ordering guarantee |
| DLQ | Built-in dead-letter sub-queue | Requires your own poison-queue pattern |
| Throughput | Higher, brokered messaging | Simpler, higher scale for basic work queues |
| Cost | Namespace-based (shared namespace, no extra cost per queue) | Per-storage-account transaction cost |

If you're unsure, prefer the Service Bus queue — the built-in DLQ and poison-message handling need less custom code, and it's available now. See #1138 for the full spike write-up.

## 🔧 Infrastructure Setup (Terraform)

### Prerequisites

`enable_servicebus_queue` provisions queues through the `saif-resources` `modules/servicebus-queue` submodule. Enabling the flag requires a `saif-api-service` release that references a published `saif-resources` version containing this module; older versions cannot resolve the submodule. See the [module's generated Terraform reference](https://github.com/saif-corp/forge/blob/main/src/terraform/saif-resources/modules/servicebus-queue/README.md) for the complete input/output contract.

### Enable the Service Bus Queue Feature Flag

Service Bus queue support is a feature flag on the `saif-apiservice` module, configured in the module call the templates generate for you — `infra/api/app.generated.tf`.

Add a `feature_flags` block to the existing `module "saif-appservices"` call:

```hcl
feature_flags = {
  enable_servicebus_queue = true
  # Keyed by a stable queue identity name (e.g. "intake", "retries") — provision as many
  # independent queues as your app needs. Omit `servicebus_queues` entirely to get a single
  # "default" queue with all sub-settings at their defaults.
  servicebus_queues = {
    intake = {
      max_delivery_count                   = 10
      lock_duration                        = "PT30S"
      default_message_ttl                  = "P14D"
      dead_lettering_on_message_expiration = true
      requires_duplicate_detection         = false
    }
    retries = {
      max_delivery_count                      = 20
      dead_lettering_on_message_expiration    = true
      requires_duplicate_detection            = true
      duplicate_detection_history_time_window = "PT15M"
      max_size_in_megabytes                   = 2048
    }
  }
}
```

> ⚠️ `enable_servicebus_queue` and `servicebus_queues` are members of the `feature_flags` object, not top-level module arguments. If the module call already has a `feature_flags` object, [merge these members into it](../../reference/terraform/saif-api-service/index.md#feature-flags).

**Configuration Options (each entry in `servicebus_queues`, keyed by queue name):**

| Property                               | Type         | Description                                                        | Default |
| --------------------------------------- | ------------ | ------------------------------------------------------------------- | ------- |
| `max_delivery_count`                    | number       | Delivery attempts before a message is dead-lettered as poison        | `10`    |
| `lock_duration`                         | string (ISO 8601 duration) | How long a message is locked for a receiving consumer before it becomes available again | `PT30S` |
| `default_message_ttl`                   | string (ISO 8601 duration) | Default time-to-live for messages on the queue; expired messages are dead-lettered when `dead_lettering_on_message_expiration` is true, otherwise discarded | `P14D`  |
| `dead_lettering_on_message_expiration`  | bool         | Move expired messages to the dead-letter queue instead of discarding | `true`  |
| `requires_duplicate_detection`          | bool         | Detect and drop duplicate messages sent within `duplicate_detection_history_time_window` | `false` |
| `duplicate_detection_history_time_window` | string (ISO 8601 duration) | Duplicate-detection window; only applies when `requires_duplicate_detection` is true | `PT10M` |
| `max_size_in_megabytes`                 | number       | Maximum queue size in megabytes                                      | `1024`  |
| `dlq_reader_identities`                 | map(string)  | Identity key → Entra ID object ID granted read access to this queue's DLQ | `{}`    |
| `dlq_manager_identities`                | map(string)  | Identity key → Entra ID object ID granted manage access to this queue's DLQ | `{}`    |

> ⚠️ **Hardcode object IDs — don't resolve them with a `data` source.** Same convention as blob
> storage's `contributor_group_id`: a `data "azuread_group"` lookup executes at plan time and fails
> the Organization Policy Check. Look the object ID up once and paste the literal GUID.

> ⚠️ **`requires_duplicate_detection` cannot be changed after the queue is created.** Azure Service
> Bus does not support toggling duplicate detection on an existing queue, so Terraform must destroy
> and recreate the queue to apply a change — discarding any live and dead-lettered messages still on
> it. Decide this setting up front. See
> [Changing `requires_duplicate_detection`](#changing-requires_duplicate_detection) below if you do
> need to change it on a queue already in use.

> ⚠️ **Queue keys (the `servicebus_queues` map keys) must be lowercase alphanumerics separated by
> single hyphens**, such as `orders` or `order-retries`. Each key determines both the Azure queue
> name (`{project_id}-{key}-queue`) and a `ServiceBusQueueName__{key}` app setting, so it has to be
> safe in both namespaces at once. Azure queue names and .NET's `IConfiguration` lookups are both
> case-insensitive, so keys differing only by case (e.g. `Orders` and `orders`) would resolve to the
> same remote queue and the same configuration key even though Terraform treats them as distinct map
> entries. Underscores are rejected as well: App Service surfaces app settings as environment
> variables and .NET translates `__` into the `:` configuration-path separator, so a key of `a__b`
> would emit `ServiceBusQueueName__a__b` and bind to `ServiceBusQueueName:a:b`, which the documented
> `ServiceBusQueueName:{key}` lookup could never resolve. The module rejects invalid keys with a
> variable validation error before planning.

### Namespace

Queues are provisioned on the shared, per-environment/per-domain Service Bus namespace resolved
from platform context. You don't configure a namespace at all — each environment has its own
namespace, and the module reads it from `context.org_services`.

The namespace's fully-qualified host is published centrally to App Configuration as
`ConnectionStrings:messaging` (see [Generated Resources](#generated-resources) below), which
`AddAzureServiceBusClient(connectionName: "messaging")` reads. `messaging` is the universal
connection name used across all consumers of this module, so it isn't configurable.

### Changing `requires_duplicate_detection`

Because `requires_duplicate_detection` forces queue recreation, do not just flip the value on an
existing, in-use queue — that destroys the live queue (and its dead-letter sub-queue) along with
any unprocessed messages. Instead, migrate onto a new queue:

1. Add a **new** entry to `servicebus_queues` under a new key (e.g. `intake-v2`) with the desired
   `requires_duplicate_detection` value — this provisions a second, independent queue alongside the
   existing one; it does not touch the original.
2. Update producers to send to the new queue's `ServiceBusQueueName__{new_key}` app setting.
3. Drain the old queue: let existing consumers finish processing whatever is already on it (and
   triage anything already in its dead-letter sub-queue) before retiring them.
4. Once the old queue is empty and no longer receiving traffic, remove its entry from
   `servicebus_queues` to delete it.

This avoids any window where in-flight or dead-lettered messages are silently discarded by a
destroy/recreate of the original queue.

### Generated Resources

When `enable_servicebus_queue = true`, the following resources are created for each key in `servicebus_queues`:

- 📬 Azure Service Bus Queue — named `{project_id}-{key}-queue` on the shared Service Bus namespace
- 🔐 RBAC Role Assignments, scoped to that queue only (not the namespace):
  - **Azure Service Bus Data Sender** + **Azure Service Bus Data Receiver** → the app's Entra App Registration service principal (the app authenticates via `EnvironmentCredential`; the UAMI is not granted access)
  - Optional **Azure Service Bus Data Receiver** / **Azure Service Bus Data Owner** → that queue's `dlq_reader_identities` / `dlq_manager_identities`
- ⚙️ App Setting: `ServiceBusQueueName__{key}` — the queue name your app code should send/receive on for that queue

> ℹ️ The namespace's fully-qualified host is **not** emitted per-app. It's published centrally to
> shared App Configuration as `ConnectionStrings:messaging`
> ([cloud-foundations#134](https://github.com/saif-corp/cloud-foundations/pull/134)) — bind with
> `AddAzureServiceBusClient(connectionName: "messaging")`.
>
> This requires `SAIF.Platform.Azure`'s `AddAzureDefaults()` to select the `ConnectionStrings:*`
> prefix from Azure App Configuration. Upgrade `SAIF.Platform.Azure` to the release containing this
> default, or explicitly include `ConnectionStrings:*` in `AzureConfigurationOptions.Prefixes`, and
> call `builder.AddAzureDefaults()` before `AddAzureServiceBusClient()`.

## 🖥️ Using the Queue in Your Service

Add the Aspire Service Bus client package to your app project (it's not part of the golden-path template's default package set):

```bash
dotnet add package Aspire.Azure.Messaging.ServiceBus
```

```csharp
// Program.cs
builder.AddAzureServiceBusClient(connectionName: "messaging");
```

```csharp
using Azure.Messaging.ServiceBus;
using System.Text.Json;

// App settings are named ServiceBusQueueName__{key} for each queue key in servicebus_queues
// (e.g. "ServiceBusQueueName__intake", "ServiceBusQueueName__retries"). .NET configuration
// normalizes the "__" App Service setting separator to ":", so look values up with IConfiguration
// using ":" — the "__" form only exists at the App Service / environment-variable layer.
var intakeQueueName = builder.Configuration["ServiceBusQueueName:intake"];

// ServiceBusSender is a thread-safe, long-lived client — register one per queue instead of
// creating a new sender on every request, and let it be disposed on application shutdown.
builder.Services.AddSingleton(sp =>
{
    var client = sp.GetRequiredService<ServiceBusClient>();
    var queueName = sp.GetRequiredService<IConfiguration>()["ServiceBusQueueName:intake"];
    return client.CreateSender(queueName);
});

app.MapPost("/api/documents", async (DocumentRequest request, ServiceBusSender sender) =>
{
    var message = new ServiceBusMessage(JsonSerializer.Serialize(request));
    await sender.SendMessageAsync(message);
    return Results.Accepted();
});
```

A background worker (hosted service or a second app in the same solution) reads from the same queue with `client.CreateReceiver(queueName)` or `ServiceBusProcessor`.

## 📊 Monitoring & Alerting

- **Queue depth** (`ActiveMessages` metric) — alert when messages back up, indicating a stalled or under-scaled consumer.
- **Dead-letter message count** (`DeadletteredMessages` metric) — alert on any non-zero value; poison messages need triage, not silent accumulation.
- Both metrics are available on the Service Bus namespace in Azure Monitor, scoped to the entity name (`EntityName` dimension = your queue name).

## 📋 Configuration Checklist

- [ ] Add `enable_servicebus_queue = true` to the `feature_flags` block in `infra/api/app.generated.tf` ([merge into an existing object](../../reference/terraform/saif-api-service/index.md#feature-flags) if one is already generated)
- [ ] Define one entry in `servicebus_queues` per queue your app needs, keyed by a stable name
- [ ] Decide `max_delivery_count` and whether duplicate detection is needed, per queue
- [ ] Apply Terraform configuration
- [ ] Add the `Aspire.Azure.Messaging.ServiceBus` package reference to your app project
- [ ] Add `builder.AddAzureServiceBusClient("messaging")` to `Program.cs`
- [ ] Read each queue name from its `ServiceBusQueueName__{key}` app setting — don't hardcode it
- [ ] Set up queue-depth and DLQ-count alerts, per queue

## 🔗 Related Resources

- [Blob Storage](../data/storage-account.md) — analogous feature-flag pattern for Blob Storage
- [Event Service](event-service.md) / [Event Subscriptions](event-subscription.md) — pub/sub for cross-app events
- [Azure Service Bus Documentation](https://learn.microsoft.com/en-us/azure/service-bus-messaging/)
- [Aspire Service Bus Integration](https://learn.microsoft.com/en-us/dotnet/aspire/messaging/azure-service-bus-integration)
