← Back to ArticlesIntegration

Azure Integration Services — Design Patterns in Practice

A comprehensive guide to the core design patterns used when building enterprise integrations with Azure Logic Apps, Service Bus, API Management, Azure Functions, Event Grid, and Event Hubs — with real code samples and component diagrams.

Azure Integration Services — Design Patterns in Practice

Enterprise integration is hard. Systems speak different languages, fail at different times, and scale at different rates. Azure Integration Services gives you a rich palette — Logic Apps, Service Bus, API Management, Azure Functions, Event Grid, and Event Hubs — but knowing which pattern to apply, and how to wire the components together, is what separates reliable systems from fragile ones.

This article walks through the core design patterns you will encounter in real projects, with architecture diagrams, working code, and the trade-offs you need to understand before committing to an approach.


Table of Contents

  1. Asynchronous Messaging with Service Bus
  2. Competing Consumers Pattern
  3. Publish-Subscribe with Topics and Filters
  4. Saga / Process Manager Pattern
  5. API Gateway + Backend-for-Frontend (BFF)
  6. Event-Driven Choreography with Event Grid
  7. Fan-Out / Fan-In with Durable Functions
  8. Dead Letter + Poison Message Handling
  9. Claim Check Pattern for Large Payloads
  10. Idempotent Consumer Pattern
  11. Outbox Pattern with Azure SQL + Service Bus
  12. Choosing the Right Pattern

1. Asynchronous Messaging with Service Bus

The Problem

Synchronous HTTP calls between services create tight coupling. If the downstream service is slow, the caller blocks. If it crashes, you get cascading failures. You need a way to decouple producers from consumers in time.

The Pattern

┌──────────────┐    HTTP POST     ┌──────────────────────┐
│   Client /   │ ─────────────►  │    API Management    │
│   Frontend   │                 │   (entry point)      │
└──────────────┘                 └──────────┬───────────┘
                                            │ enqueue message
                                            ▼
                                 ┌──────────────────────┐
                                 │   Service Bus Queue  │
                                 │  (orders-queue)      │
                                 └──────────┬───────────┘
                                            │ trigger
                              ┌─────────────┴──────────────┐
                              ▼                            ▼
                   ┌─────────────────┐         ┌─────────────────┐
                   │ Azure Function  │         │  Azure Function │
                   │  (consumer 1)   │         │  (consumer 2)   │
                   └─────────────────┘         └─────────────────┘

APIM Policy — Enqueue to Service Bus

<!-- APIM inbound policy: forward request body to Service Bus -->
<policies>
  <inbound>
    <base />
    <set-variable name="correlationId" value="@(Guid.NewGuid().ToString())" />
    <send-request mode="new" response-variable-name="sbResponse" timeout="10" ignore-error="false">
      <set-url>https://{{sb-namespace}}.servicebus.windows.net/orders-queue/messages</set-url>
      <set-method>POST</set-method>
      <set-header name="Authorization" exists-action="override">
        <value>@("SharedAccessSignature " + context.Variables["sbSasToken"])</value>
      </set-header>
      <set-header name="BrokerProperties" exists-action="override">
        <value>@("{\"CorrelationId\":\"" + (string)context.Variables["correlationId"] + "\"}")</value>
      </set-header>
      <set-body>@(context.Request.Body.As<string>(preserveContent: true))</set-body>
    </send-request>
    <return-response>
      <set-status code="202" reason="Accepted" />
      <set-header name="x-correlation-id" exists-action="override">
        <value>@((string)context.Variables["correlationId"])</value>
      </set-header>
    </return-response>
  </inbound>
</policies>

Azure Function — Service Bus Consumer

using Azure.Messaging.ServiceBus;
using Microsoft.Azure.Functions.Worker;
using Microsoft.Extensions.Logging;
using System.Text.Json;

public class OrderConsumer
{
    private readonly IOrderService _orderService;
    private readonly ILogger<OrderConsumer> _logger;

    public OrderConsumer(IOrderService orderService, ILogger<OrderConsumer> logger)
    {
        _orderService = orderService;
        _logger = logger;
    }

    [Function("ProcessOrder")]
    public async Task Run(
        [ServiceBusTrigger("orders-queue", Connection = "ServiceBusConnection")] ServiceBusReceivedMessage message,
        ServiceBusMessageActions messageActions)
    {
        var correlationId = message.CorrelationId ?? message.MessageId;
        using var scope = _logger.BeginScope(new Dictionary<string, object>
        {
            ["CorrelationId"] = correlationId,
            ["MessageId"] = message.MessageId,
            ["DeliveryCount"] = message.DeliveryCount
        });

        try
        {
            var order = JsonSerializer.Deserialize<OrderRequest>(message.Body.ToString());

            _logger.LogInformation("Processing order {OrderId}", order?.OrderId);

            await _orderService.ProcessAsync(order!);

            // Explicit complete — only after successful processing
            await messageActions.CompleteMessageAsync(message);

            _logger.LogInformation("Order {OrderId} processed successfully", order?.OrderId);
        }
        catch (TransientException ex)
        {
            // Let Service Bus retry via delivery count — abandon so it reappears
            _logger.LogWarning(ex, "Transient failure processing message {MessageId}, abandoning", message.MessageId);
            await messageActions.AbandonMessageAsync(message);
        }
        catch (PermanentException ex)
        {
            // Non-retriable — dead-letter immediately
            _logger.LogError(ex, "Permanent failure on message {MessageId}, dead-lettering", message.MessageId);
            await messageActions.DeadLetterMessageAsync(message,
                deadLetterReason: "PermanentProcessingFailure",
                deadLetterErrorDescription: ex.Message);
        }
    }
}

host.json — Configure Retry Behaviour

{
  "version": "2.0",
  "extensions": {
    "serviceBus": {
      "prefetchCount": 10,
      "messageHandlerOptions": {
        "autoComplete": false,
        "maxConcurrentCalls": 5,
        "maxAutoRenewDuration": "00:05:00"
      }
    }
  },
  "retry": {
    "strategy": "exponentialBackoff",
    "maxRetryCount": 3,
    "minimumInterval": "00:00:02",
    "maximumInterval": "00:02:00"
  }
}

2. Competing Consumers Pattern

The Problem

A single consumer cannot keep up with message volume. You need horizontal scale — multiple instances processing messages from the same queue in parallel without duplicating work.

The Pattern

                    ┌──────────────────────┐
                    │   Service Bus Queue  │
                    │  (work-queue)        │
                    │  maxDelivery: 5      │
                    └──────┬───────────────┘
                           │  peek-lock
           ┌───────────────┼────────────────┐
           ▼               ▼                ▼
  ┌─────────────┐  ┌─────────────┐  ┌─────────────┐
  │ Consumer 1  │  │ Consumer 2  │  │ Consumer 3  │
  │ (Function   │  │ (Function   │  │ (Function   │
  │  instance)  │  │  instance)  │  │  instance)  │
  └─────────────┘  └─────────────┘  └─────────────┘

  Each message is delivered to exactly ONE consumer.
  Peek-lock prevents other consumers from seeing it
  until the lock expires or is abandoned.

Sender — Batch Enqueue

public class OrderBatchSender
{
    private readonly ServiceBusSender _sender;

    public OrderBatchSender(ServiceBusClient client)
    {
        _sender = client.CreateSender("work-queue");
    }

    public async Task SendBatchAsync(IEnumerable<OrderRequest> orders)
    {
        // ServiceBus SDK handles batching automatically
        using ServiceBusMessageBatch batch = await _sender.CreateMessageBatchAsync();

        foreach (var order in orders)
        {
            var json = JsonSerializer.Serialize(order);
            var message = new ServiceBusMessage(json)
            {
                MessageId = order.OrderId.ToString(),        // Dedup key
                CorrelationId = order.CorrelationId,
                ContentType = "application/json",
                Subject = "OrderCreated"
            };

            if (!batch.TryAddMessage(message))
            {
                // Batch full — send current batch and start a new one
                await _sender.SendMessagesAsync(batch);
                using var newBatch = await _sender.CreateMessageBatchAsync();
                newBatch.TryAddMessage(message);
            }
        }

        await _sender.SendMessagesAsync(batch);
    }
}

Scaling Configuration (Bicep)

resource functionApp 'Microsoft.Web/sites@2022-09-01' = {
  name: functionAppName
  location: location
  kind: 'functionapp'
  properties: {
    siteConfig: {
      appSettings: [
        {
          name: 'ServiceBusConnection__fullyQualifiedNamespace'
          value: '${serviceBusNamespace}.servicebus.windows.net'
        }
        {
          name: 'FUNCTIONS_WORKER_RUNTIME'
          value: 'dotnet-isolated'
        }
        // Scale out: allow up to 10 concurrent instances
        {
          name: 'WEBSITE_MAX_DYNAMIC_APPLICATION_SCALE_OUT'
          value: '10'
        }
      ]
    }
  }
}

// Auto-scale rule based on queue depth
resource autoScaleSettings 'Microsoft.Insights/autoscalesettings@2022-10-01' = {
  name: '${functionAppName}-autoscale'
  location: location
  properties: {
    profiles: [
      {
        name: 'QueueDepthProfile'
        capacity: { minimum: '1', maximum: '10', default: '1' }
        rules: [
          {
            metricTrigger: {
              metricName: 'ActiveMessages'
              metricResourceUri: serviceBusQueue.id
              operator: 'GreaterThan'
              threshold: 100
              statistic: 'Average'
              timeAggregation: 'Average'
              timeGrain: 'PT1M'
              timeWindow: 'PT5M'
            }
            scaleAction: {
              direction: 'Increase'
              type: 'ChangeCount'
              value: '2'
              cooldown: 'PT3M'
            }
          }
        ]
      }
    ]
    enabled: true
    targetResourceUri: functionApp.id
  }
}

3. Publish-Subscribe with Topics and Filters

The Problem

Multiple downstream services need to react to the same event, but each cares about different subsets. You do not want each service to poll the same queue, and you do not want to write routing logic in your producer.

The Pattern

┌──────────────────┐
│  Order Service   │
│  (publisher)     │
└───────┬──────────┘
        │  publish to topic
        ▼
┌───────────────────────────────────────────────────┐
│              Service Bus Topic                    │
│           (order-events)                          │
└────┬──────────────┬───────────────┬───────────────┘
     │              │               │
     ▼              ▼               ▼
┌─────────┐  ┌───────────┐  ┌────────────┐
│  Sub:   │  │   Sub:    │  │   Sub:     │
│ billing │  │ inventory │  │  shipping  │
│         │  │           │  │            │
│ Filter: │  │  Filter:  │  │  Filter:   │
│ region  │  │  type =   │  │  status =  │
│ = 'EU'  │  │ 'physical'│  │ 'paid'     │
└────┬────┘  └─────┬─────┘  └─────┬──────┘
     │             │              │
     ▼             ▼              ▼
  Billing       Inventory      Shipping
  Function      Function       Function

Publishing with Correlation Properties

public class OrderEventPublisher
{
    private readonly ServiceBusSender _sender;

    public OrderEventPublisher(ServiceBusClient client)
    {
        _sender = client.CreateSender("order-events");
    }

    public async Task PublishOrderCreatedAsync(Order order)
    {
        var eventPayload = new OrderCreatedEvent
        {
            OrderId = order.Id,
            CustomerId = order.CustomerId,
            TotalAmount = order.TotalAmount,
            Region = order.ShippingRegion,
            OrderType = order.HasPhysicalItems ? "physical" : "digital",
            PaymentStatus = order.PaymentStatus,
            CreatedAt = DateTimeOffset.UtcNow
        };

        var message = new ServiceBusMessage(JsonSerializer.Serialize(eventPayload))
        {
            Subject = "OrderCreated",
            ContentType = "application/json",
            // Application properties used for SQL filter evaluation
            ApplicationProperties =
            {
                ["region"] = order.ShippingRegion,
                ["orderType"] = order.HasPhysicalItems ? "physical" : "digital",
                ["paymentStatus"] = order.PaymentStatus,
                ["totalAmount"] = (double)order.TotalAmount
            }
        };

        await _sender.SendMessageAsync(message);
    }
}

Subscription Filter — Bicep

// Billing subscription: EU orders over £100
resource billingSubscription 'Microsoft.ServiceBus/namespaces/topics/subscriptions@2021-11-01' = {
  name: 'billing'
  parent: orderEventsTopic
  properties: {
    lockDuration: 'PT1M'
    maxDeliveryCount: 5
    deadLetteringOnMessageExpiration: true
  }
}

resource billingFilter 'Microsoft.ServiceBus/namespaces/topics/subscriptions/rules@2021-11-01' = {
  name: 'EuropeanHighValueOrders'
  parent: billingSubscription
  properties: {
    filterType: 'SqlFilter'
    sqlFilter: {
      sqlExpression: "region = 'EU' AND totalAmount > 100"
    }
  }
}

// Inventory subscription: physical items only
resource inventorySubscription 'Microsoft.ServiceBus/namespaces/topics/subscriptions@2021-11-01' = {
  name: 'inventory'
  parent: orderEventsTopic
  properties: {
    lockDuration: 'PT1M'
    maxDeliveryCount: 5
  }
}

resource inventoryFilter 'Microsoft.ServiceBus/namespaces/topics/subscriptions/rules@2021-11-01' = {
  name: 'PhysicalItemsOnly'
  parent: inventorySubscription
  properties: {
    filterType: 'SqlFilter'
    sqlFilter: {
      sqlExpression: "orderType = 'physical'"
    }
  }
}

4. Saga / Process Manager Pattern

The Problem

A business transaction spans multiple services — order creation, payment, inventory reservation, and shipping. If payment succeeds but inventory fails, you need compensating transactions to roll back cleanly. Distributed transactions (2PC) are impractical in cloud systems.

The Pattern

                        ┌────────────────────────────┐
                        │     Saga Orchestrator       │
                        │  (Durable Function)         │
                        └──────┬─────────────────┬───┘
                               │                 │
               ┌───────────────┼────────────────┐│
               │               │                ││
               ▼               ▼                ▼▼
       ┌─────────────┐  ┌────────────┐  ┌────────────────┐
       │   Payment   │  │ Inventory  │  │    Shipping    │
       │   Service   │  │  Service   │  │    Service     │
       └──────┬──────┘  └─────┬──────┘  └───────┬────────┘
              │               │                 │
         Success/Fail    Success/Fail       Success/Fail
              │               │                 │
              └───────────────┴─────────────────┘
                              │
                   ┌──────────▼───────────┐
                   │  Compensating Steps  │
                   │  (on partial fail)   │
                   │  - Refund payment    │
                   │  - Release inventory │
                   └──────────────────────┘

Durable Function Orchestrator

using Microsoft.Azure.Functions.Worker;
using Microsoft.DurableTask;
using Microsoft.Extensions.Logging;

public class OrderSagaOrchestrator
{
    [Function(nameof(OrderSagaOrchestrator))]
    public async Task<SagaResult> RunOrchestrator(
        [OrchestrationTrigger] TaskOrchestrationContext context)
    {
        var logger = context.CreateReplaySafeLogger(nameof(OrderSagaOrchestrator));
        var input = context.GetInput<OrderSagaInput>()!;
        var compensations = new Stack<string>();

        try
        {
            // Step 1: Reserve inventory
            var inventoryResult = await context.CallActivityAsync<InventoryResult>(
                nameof(ReserveInventoryActivity), input);

            if (!inventoryResult.Success)
                return SagaResult.Failed("Inventory reservation failed", compensations);

            compensations.Push("ReleaseInventory");
            logger.LogInformation("Inventory reserved for order {OrderId}", input.OrderId);

            // Step 2: Process payment
            var paymentResult = await context.CallActivityAsync<PaymentResult>(
                nameof(ProcessPaymentActivity), new PaymentInput
                {
                    OrderId = input.OrderId,
                    Amount = input.TotalAmount,
                    CustomerId = input.CustomerId,
                    ReservationId = inventoryResult.ReservationId
                });

            if (!paymentResult.Success)
            {
                // Run compensations in reverse order
                await RunCompensationsAsync(context, compensations, input);
                return SagaResult.Failed("Payment failed", compensations);
            }

            compensations.Push("RefundPayment");
            logger.LogInformation("Payment processed for order {OrderId}", input.OrderId);

            // Step 3: Arrange shipping
            var shippingResult = await context.CallActivityAsync<ShippingResult>(
                nameof(ArrangeShippingActivity), new ShippingInput
                {
                    OrderId = input.OrderId,
                    PaymentReference = paymentResult.PaymentReference,
                    Address = input.DeliveryAddress
                });

            if (!shippingResult.Success)
            {
                await RunCompensationsAsync(context, compensations, input);
                return SagaResult.Failed("Shipping arrangement failed", compensations);
            }

            logger.LogInformation("Order {OrderId} saga completed successfully", input.OrderId);
            return SagaResult.Succeeded(shippingResult.TrackingNumber);
        }
        catch (Exception ex)
        {
            logger.LogError(ex, "Unexpected error in saga for order {OrderId}", input.OrderId);
            await RunCompensationsAsync(context, compensations, input);
            throw;
        }
    }

    private static async Task RunCompensationsAsync(
        TaskOrchestrationContext context,
        Stack<string> compensations,
        OrderSagaInput input)
    {
        while (compensations.Count > 0)
        {
            var step = compensations.Pop();
            await context.CallActivityAsync(step + "Activity", input);
        }
    }
}

// Individual activity — idempotent by design
public class ReserveInventoryActivity
{
    private readonly IInventoryClient _inventoryClient;

    public ReserveInventoryActivity(IInventoryClient inventoryClient)
    {
        _inventoryClient = inventoryClient;
    }

    [Function(nameof(ReserveInventoryActivity))]
    public async Task<InventoryResult> Run(
        [ActivityTrigger] OrderSagaInput input,
        FunctionContext context)
    {
        // Idempotency key prevents double reservation on replay
        return await _inventoryClient.ReserveAsync(input.OrderId, input.Items);
    }
}

5. API Gateway + Backend-for-Frontend (BFF)

The Problem

Mobile, web, and partner consumers all need different shapes of data, different authentication, and different rate limits. Putting all that logic into microservices pollutes them with client concerns.

The Pattern

┌───────────┐  ┌───────────┐  ┌──────────────┐
│  Mobile   │  │   Web     │  │   Partner    │
│   App     │  │   SPA     │  │   API Key    │
└─────┬─────┘  └─────┬─────┘  └──────┬───────┘
      │               │               │
      ▼               ▼               ▼
┌─────────────────────────────────────────────┐
│            Azure API Management             │
│                                             │
│  /mobile/*  /web/*  /partner/*              │
│  Products define rate limits per consumer   │
│  Policies handle auth, transform, cache     │
└──────┬──────────────┬────────────┬──────────┘
       │              │            │
       ▼              ▼            ▼
┌────────────┐ ┌───────────┐ ┌──────────────┐
│  Orders    │ │ Catalogue │ │  Inventory   │
│  Service   │ │  Service  │ │   Service    │
└────────────┘ └───────────┘ └──────────────┘

APIM Policy — Mobile BFF (response shaping + caching)

<policies>
  <inbound>
    <base />
    <!-- Validate JWT from Entra ID -->
    <validate-jwt header-name="Authorization" failed-validation-httpcode="401">
      <openid-config url="https://login.microsoftonline.com/{{tenant-id}}/v2.0/.well-known/openid-configuration" />
      <audiences>
        <audience>{{mobile-app-client-id}}</audience>
      </audiences>
    </validate-jwt>

    <!-- Rate limit: 200 calls/minute per subscription -->
    <rate-limit-by-key calls="200" renewal-period="60"
      counter-key="@(context.Subscription.Id)" />

    <!-- Cache GET requests for 30 seconds -->
    <cache-lookup vary-by-developer="false" vary-by-developer-groups="false"
      downstream-caching-type="none">
      <vary-by-query-parameter>page</vary-by-query-parameter>
      <vary-by-query-parameter>size</vary-by-query-parameter>
    </cache-lookup>
  </inbound>
  <outbound>
    <base />
    <!-- Strip internal fields the mobile client should not see -->
    <set-body>@{
      var body = context.Response.Body.As<JObject>();
      body.Remove("internalCostCode");
      body.Remove("supplierId");
      body.Remove("warehouseLocation");
      return body.ToString();
    }</set-body>
    <cache-store duration="30" />
  </outbound>
</policies>

APIM Named Values + Key Vault Reference

<!-- Reference Key Vault secret without ever exposing it in policy -->
<authentication-managed-identity resource="https://vault.azure.net" />
<!-- Then use Named Value linked to KV secret -->
<set-header name="x-internal-api-key" exists-action="override">
  <value>{{kv-internal-api-key}}</value>
</set-header>

6. Event-Driven Choreography with Event Grid

The Problem

Orchestration (one central component driving all steps) works well for complex transactions but creates a single point of coupling. Choreography lets each service react to events independently — no central coordinator knows about everyone else.

The Pattern

  Order Service                        Inventory Service
      │                                      │
      │  publishes                           │  subscribes to
      │  OrderCreated                        │  OrderCreated
      ▼                                      ▼
┌──────────────────────────────────────────────────────┐
│               Azure Event Grid Topic                 │
│               (order-domain-events)                  │
└──────┬──────────────┬────────────────────┬───────────┘
       │              │                    │
       ▼              ▼                    ▼
  Inventory       Notification         Analytics
  Service         Service              Pipeline
  (reserves       (emails customer)    (updates BI)
   stock)

No service knows about any other. Each reacts to the same event.

Publishing an Event from a Logic App

{
  "definition": {
    "$schema": "https://schema.management.azure.com/providers/Microsoft.Logic/schemas/2016-06-01/workflowdefinition.json#",
    "actions": {
      "Publish_Order_Event": {
        "type": "Http",
        "inputs": {
          "method": "POST",
          "uri": "https://order-domain-events.australiaeast-1.eventgrid.azure.net/api/events",
          "headers": {
            "aeg-sas-key": "@parameters('eventGridKey')",
            "Content-Type": "application/json"
          },
          "body": [
            {
              "id": "@{guid()}",
              "eventType": "Order.Created",
              "subject": "@{concat('orders/', triggerBody()?['orderId'])}",
              "eventTime": "@{utcNow()}",
              "dataVersion": "1.0",
              "data": {
                "orderId": "@{triggerBody()?['orderId']}",
                "customerId": "@{triggerBody()?['customerId']}",
                "totalAmount": "@{triggerBody()?['totalAmount']}",
                "items": "@{triggerBody()?['items']}"
              }
            }
          ]
        }
      }
    }
  }
}

Subscribing with an Azure Function (Push Delivery)

[Function("HandleOrderCreated")]
public async Task<HttpResponseData> Run(
    [HttpTrigger(AuthorizationLevel.Anonymous, "post")] HttpRequestData req,
    FunctionContext context)
{
    var logger = context.GetLogger(nameof(HandleOrderCreated));
    var body = await req.ReadAsStringAsync();

    // Event Grid sends a validation challenge on subscription creation
    var events = JsonSerializer.Deserialize<JsonElement[]>(body!);
    if (events?[0].TryGetProperty("validationCode", out var validationCode) == true)
    {
        var response = req.CreateResponse(HttpStatusCode.OK);
        await response.WriteAsJsonAsync(new { validationResponse = validationCode.GetString() });
        return response;
    }

    foreach (var @event in events ?? [])
    {
        var eventType = @event.GetProperty("eventType").GetString();
        if (eventType == "Order.Created")
        {
            var data = @event.GetProperty("data");
            var orderId = data.GetProperty("orderId").GetString();

            logger.LogInformation("Inventory reserving stock for order {OrderId}", orderId);
            await _inventoryService.ReserveStockAsync(orderId!, data);
        }
    }

    return req.CreateResponse(HttpStatusCode.OK);
}

Dead Letter + Retry Policy on Subscription (Bicep)

resource inventorySubscription 'Microsoft.EventGrid/eventSubscriptions@2022-06-15' = {
  name: 'inventory-service-sub'
  scope: orderEventsTopic
  properties: {
    destination: {
      endpointType: 'AzureFunction'
      properties: {
        resourceId: inventoryFunction.id
        maxEventsPerBatch: 1
      }
    }
    retryPolicy: {
      maxDeliveryAttempts: 10
      eventTimeToLiveInMinutes: 1440  // 24 hours
    }
    deadLetterDestination: {
      endpointType: 'StorageBlob'
      properties: {
        resourceId: deadLetterStorageAccount.id
        blobContainerName: 'eventgrid-deadletter'
      }
    }
    filter: {
      includedEventTypes: [ 'Order.Created' ]
      subjectBeginsWith: 'orders/'
    }
  }
}

7. Fan-Out / Fan-In with Durable Functions

The Problem

You need to process a large batch in parallel — e.g., enrich 500 products from an external API — and then aggregate the results once everything completes. Sequential processing is too slow; you need controlled concurrency with a final aggregation step.

The Pattern

HTTP Trigger
     │
     ▼
┌────────────────────────────────────┐
│  Orchestrator Function             │
│  - receives list of 500 item IDs   │
│  - fans out to N activity funcs    │
│  - waits for ALL to complete       │
│  - aggregates results (fan-in)     │
└────────────────────────────────────┘
     │           │           │
     ▼           ▼           ▼
 Activity 1  Activity 2  Activity N
 (enrich     (enrich     (enrich
  item 1-50)  item 51-100) item 451-500)
     │           │           │
     └─────┬─────┘           │
           └────────┬────────┘
                    ▼
             Aggregation step
             (write to Blob)

Orchestrator + Activities

[Function(nameof(ProductEnrichmentOrchestrator))]
public async Task<EnrichmentSummary> RunOrchestrator(
    [OrchestrationTrigger] TaskOrchestrationContext context)
{
    var logger = context.CreateReplaySafeLogger(nameof(ProductEnrichmentOrchestrator));
    var productIds = context.GetInput<List<string>>()!;

    logger.LogInformation("Starting fan-out for {Count} products", productIds.Count);

    // Chunk into batches of 50 to avoid overwhelming downstream APIs
    var chunks = productIds
        .Select((id, i) => (id, i))
        .GroupBy(x => x.i / 50)
        .Select(g => g.Select(x => x.id).ToList())
        .ToList();

    // Fan-out: all chunks run in parallel
    var enrichmentTasks = chunks
        .Select(chunk => context.CallActivityAsync<List<EnrichedProduct>>(
            nameof(EnrichProductBatchActivity), chunk))
        .ToList();

    // Fan-in: wait for all results
    var results = await Task.WhenAll(enrichmentTasks);

    var allProducts = results.SelectMany(r => r).ToList();

    // Aggregate step
    var summary = await context.CallActivityAsync<EnrichmentSummary>(
        nameof(SaveEnrichedProductsActivity), allProducts);

    logger.LogInformation("Enrichment complete: {Success} succeeded, {Failed} failed",
        summary.SuccessCount, summary.FailureCount);

    return summary;
}

[Function(nameof(EnrichProductBatchActivity))]
public async Task<List<EnrichedProduct>> EnrichBatch(
    [ActivityTrigger] List<string> productIds,
    FunctionContext context)
{
    var results = new List<EnrichedProduct>();

    foreach (var id in productIds)
    {
        try
        {
            var enriched = await _enrichmentClient.EnrichAsync(id);
            results.Add(enriched);
        }
        catch (Exception ex)
        {
            // Don't fail the whole batch — capture partial failures
            results.Add(new EnrichedProduct { Id = id, Error = ex.Message });
        }
    }

    return results;
}

[Function(nameof(SaveEnrichedProductsActivity))]
public async Task<EnrichmentSummary> SaveEnrichedProducts(
    [ActivityTrigger] List<EnrichedProduct> products,
    FunctionContext context)
{
    var json = JsonSerializer.Serialize(products);
    var blobName = $"enriched/{DateTimeOffset.UtcNow:yyyyMMdd-HHmmss}.json";

    await _blobContainer.UploadBlobAsync(blobName, BinaryData.FromString(json));

    return new EnrichmentSummary
    {
        SuccessCount = products.Count(p => p.Error == null),
        FailureCount = products.Count(p => p.Error != null),
        BlobPath = blobName
    };
}

8. Dead Letter + Poison Message Handling

The Problem

A message cannot be processed — bad format, failed schema validation, business rule violation. If you abandon it repeatedly it hits maxDeliveryCount and lands in the Dead Letter Queue (DLQ). Without a strategy, the DLQ fills silently and data is lost.

The Pattern

┌──────────────────┐
│  Main Queue /    │
│  Subscription    │
│  maxDelivery: 5  │
└────────┬─────────┘
         │ after 5 failed deliveries
         ▼
┌──────────────────┐
│   Dead Letter    │
│   Queue (DLQ)    │
└────────┬─────────┘
         │ trigger
         ▼
┌──────────────────────────────────────┐
│    DLQ Processor Function            │
│  1. Log + alert                      │
│  2. Try to repair message            │
│  3. If repairable: re-enqueue        │
│  4. If not: archive to Blob Storage  │
└──────────────────────────────────────┘

DLQ Processor Function

[Function("ProcessDeadLetterMessages")]
public async Task Run(
    [ServiceBusTrigger(
        "orders-queue/$deadletterqueue",
        Connection = "ServiceBusConnection")] ServiceBusReceivedMessage message,
    ServiceBusMessageActions messageActions,
    FunctionContext context)
{
    var logger = context.GetLogger(nameof(ProcessDeadLetterMessages));

    var deadLetterReason = message.DeadLetterReason ?? "Unknown";
    var originalEnqueuedAt = message.EnqueuedTime;

    logger.LogWarning(
        "DLQ message received. Reason: {Reason}, OriginalMessageId: {MessageId}, " +
        "EnqueuedAt: {EnqueuedAt}, DeliveryCount: {DeliveryCount}",
        deadLetterReason, message.MessageId, originalEnqueuedAt, message.DeliveryCount);

    // Archive to blob regardless of what happens next
    await ArchiveToStorageAsync(message);

    // Attempt repair based on failure reason
    var repaired = deadLetterReason switch
    {
        "MaxDeliveryCountExceeded" => await TryRepairTransientFailure(message),
        "MessageLockLost" => await TryResubmit(message),
        "PermanentProcessingFailure" => false,  // Don't retry permanent failures
        _ => false
    };

    if (repaired)
    {
        logger.LogInformation("DLQ message {MessageId} repaired and requeued", message.MessageId);
    }
    else
    {
        // Send alert to operations team
        await _alertService.SendDlqAlertAsync(new DlqAlert
        {
            MessageId = message.MessageId,
            Reason = deadLetterReason,
            QueueName = "orders-queue",
            ArchivedAt = DateTimeOffset.UtcNow
        });
    }

    // Always complete — we've handled it one way or another
    await messageActions.CompleteMessageAsync(message);
}

private async Task ArchiveToStorageAsync(ServiceBusReceivedMessage message)
{
    var archiveEntry = new
    {
        MessageId = message.MessageId,
        DeadLetterReason = message.DeadLetterReason,
        DeadLetterErrorDescription = message.DeadLetterErrorDescription,
        Body = message.Body.ToString(),
        ApplicationProperties = message.ApplicationProperties,
        ArchivedAt = DateTimeOffset.UtcNow
    };

    var json = JsonSerializer.Serialize(archiveEntry);
    var blobName = $"dlq-archive/{DateTime.UtcNow:yyyy/MM/dd}/{message.MessageId}.json";
    await _blobContainer.UploadBlobAsync(blobName, BinaryData.FromString(json));
}

private async Task<bool> TryResubmit(ServiceBusReceivedMessage original)
{
    try
    {
        // Clone into a new sendable message — reset delivery count
        var resubmit = new ServiceBusMessage(original.Body)
        {
            MessageId = original.MessageId,
            CorrelationId = original.CorrelationId,
            ContentType = original.ContentType,
            Subject = original.Subject
        };

        foreach (var prop in original.ApplicationProperties)
            resubmit.ApplicationProperties[prop.Key] = prop.Value;

        await _mainQueueSender.SendMessageAsync(resubmit);
        return true;
    }
    catch
    {
        return false;
    }
}

9. Claim Check Pattern for Large Payloads

The Problem

Service Bus has a message size limit of 1 MB (Standard) or 100 MB (Premium). Event Grid events are limited to 1 MB. When your payloads — images, PDFs, large JSON datasets — exceed these limits you need a different approach.

The Pattern

Producer                    Blob Storage              Consumer
   │                             │                       │
   │  1. Upload large payload    │                       │
   │ ─────────────────────────►  │                       │
   │                             │                       │
   │  2. Get blob URL/reference  │                       │
   │ ◄─────────────────────────  │                       │
   │                             │                       │
   │  3. Send lightweight        │                       │
   │     "claim check" message   │                       │
   │     (just the reference)    │                       │
   │ ───────────────────────────────────────────────►   │
   │                             │                       │
   │                             │  4. Retrieve payload  │
   │                             │ ◄─────────────────────│
   │                             │                       │
   │                             │  5. Process           │
   │                             │ ──────────────────►   │

Producer — Upload and Send Claim Check

public class ClaimCheckProducer
{
    private readonly BlobContainerClient _blobContainer;
    private readonly ServiceBusSender _sender;

    public ClaimCheckProducer(BlobContainerClient blobContainer, ServiceBusClient sbClient)
    {
        _blobContainer = blobContainer;
        _sender = sbClient.CreateSender("processing-queue");
    }

    public async Task SendLargePayloadAsync(string orderId, Stream largePayload)
    {
        // 1. Upload payload to Blob Storage
        var blobName = $"payloads/{orderId}/{Guid.NewGuid()}.json";
        var blobClient = _blobContainer.GetBlobClient(blobName);

        await blobClient.UploadAsync(largePayload, new BlobUploadOptions
        {
            HttpHeaders = new BlobHttpHeaders { ContentType = "application/json" }
        });

        // 2. Generate a time-limited SAS URI (1 hour)
        var sasUri = blobClient.GenerateSasUri(BlobSasPermissions.Read,
            DateTimeOffset.UtcNow.AddHours(1));

        // 3. Send lightweight claim check message
        var claimCheck = new ClaimCheckMessage
        {
            OrderId = orderId,
            BlobUri = sasUri.ToString(),
            BlobName = blobName,
            PayloadSizeBytes = largePayload.Length,
            ExpiresAt = DateTimeOffset.UtcNow.AddHours(1)
        };

        var message = new ServiceBusMessage(JsonSerializer.Serialize(claimCheck))
        {
            MessageId = orderId,
            ContentType = "application/json",
            Subject = "LargePayloadAvailable"
        };

        await _sender.SendMessageAsync(message);
    }
}

Consumer — Retrieve and Process

[Function("ProcessLargePayload")]
public async Task Run(
    [ServiceBusTrigger("processing-queue", Connection = "ServiceBusConnection")]
    ServiceBusReceivedMessage message,
    ServiceBusMessageActions messageActions)
{
    var claimCheck = JsonSerializer.Deserialize<ClaimCheckMessage>(message.Body.ToString())!;

    if (claimCheck.ExpiresAt < DateTimeOffset.UtcNow)
    {
        _logger.LogError("Claim check expired for order {OrderId}", claimCheck.OrderId);
        await messageActions.DeadLetterMessageAsync(message,
            "ClaimCheckExpired", "SAS URI has expired");
        return;
    }

    // Retrieve the actual payload via the SAS URI
    using var httpClient = _httpClientFactory.CreateClient();
    using var response = await httpClient.GetAsync(claimCheck.BlobUri);
    response.EnsureSuccessStatusCode();

    var payload = await response.Content.ReadAsStringAsync();
    var orderData = JsonSerializer.Deserialize<LargeOrderPayload>(payload)!;

    await _processingService.ProcessAsync(orderData);

    // Optionally delete the blob after processing to save costs
    var blobClient = _blobContainerClient.GetBlobClient(claimCheck.BlobName);
    await blobClient.DeleteIfExistsAsync();

    await messageActions.CompleteMessageAsync(message);
}

10. Idempotent Consumer Pattern

The Problem

Messages can be delivered more than once — Service Bus at-least-once delivery, Event Grid retries, network blips causing duplicate submissions. If your consumer is not idempotent, a duplicate payment charge or double inventory deduction will cause data corruption.

The Pattern

Message arrives
      │
      ▼
┌─────────────────────────────────────┐
│  Check idempotency store            │
│  (Azure Table Storage / Redis)      │
│  Key = MessageId + OperationType    │
└────────────┬──────────────┬─────────┘
             │ NOT seen      │ SEEN before
             ▼              ▼
      Process it       Return 200 OK
      Store key        (skip processing)
      Complete msg

Idempotency Middleware for Azure Functions

public class IdempotencyService
{
    private readonly TableClient _tableClient;

    public IdempotencyService(TableServiceClient tableServiceClient)
    {
        _tableClient = tableServiceClient.GetTableClient("IdempotencyKeys");
    }

    /// <summary>
    /// Returns true if this message has NOT been processed before.
    /// Atomically marks it as in-progress using optimistic concurrency.
    /// </summary>
    public async Task<bool> TryAcquireAsync(string messageId, string operationType)
    {
        var partitionKey = operationType;
        var rowKey = messageId;

        try
        {
            var entity = new TableEntity(partitionKey, rowKey)
            {
                ["ProcessedAt"] = DateTimeOffset.UtcNow,
                ["Status"] = "Processing"
            };

            // IfNoneMatch = "*" means: only insert if this row does NOT exist
            await _tableClient.AddEntityAsync(entity);
            return true;  // First time seeing this message
        }
        catch (RequestFailedException ex) when (ex.Status == 409)
        {
            return false;  // Row already exists — duplicate
        }
    }

    public async Task MarkCompleteAsync(string messageId, string operationType)
    {
        var entity = new TableEntity(operationType, messageId)
        {
            ["Status"] = "Completed",
            ["CompletedAt"] = DateTimeOffset.UtcNow
        };

        await _tableClient.UpsertEntityAsync(entity);
    }
}

// Usage in the Function consumer
[Function("IdempotentOrderProcessor")]
public async Task Run(
    [ServiceBusTrigger("orders-queue", Connection = "ServiceBusConnection")]
    ServiceBusReceivedMessage message,
    ServiceBusMessageActions messageActions)
{
    var acquired = await _idempotencyService.TryAcquireAsync(
        message.MessageId, "OrderProcessing");

    if (!acquired)
    {
        _logger.LogWarning("Duplicate message {MessageId} — skipping", message.MessageId);
        await messageActions.CompleteMessageAsync(message);  // Ack and discard
        return;
    }

    try
    {
        var order = JsonSerializer.Deserialize<OrderRequest>(message.Body.ToString())!;
        await _orderService.ProcessAsync(order);
        await _idempotencyService.MarkCompleteAsync(message.MessageId, "OrderProcessing");
        await messageActions.CompleteMessageAsync(message);
    }
    catch
    {
        // On failure, the idempotency key stays as "Processing"
        // A separate cleanup job can reset stuck keys after TTL
        await messageActions.AbandonMessageAsync(message);
        throw;
    }
}

Cleanup — TTL on Idempotency Keys

// Periodic Function to clean up stale Processing keys older than 24 hours
[Function("CleanupStaleIdempotencyKeys")]
public async Task RunCleanup(
    [TimerTrigger("0 0 * * * *")] TimerInfo timer)  // Every hour
{
    var cutoff = DateTimeOffset.UtcNow.AddHours(-24);
    var staleKeys = _tableClient.QueryAsync<TableEntity>(
        filter: $"Status eq 'Processing' and ProcessedAt lt datetime'{cutoff:O}'");

    await foreach (var entity in staleKeys)
    {
        _logger.LogWarning("Removing stale idempotency key {PartitionKey}/{RowKey}",
            entity.PartitionKey, entity.RowKey);
        await _tableClient.DeleteEntityAsync(entity.PartitionKey, entity.RowKey);
    }
}

11. Outbox Pattern with Azure SQL + Service Bus

The Problem

You write to your database AND publish a message. What if the DB write succeeds but the message send fails? Or vice versa? You end up with inconsistent state. The Outbox pattern solves this by making message publishing part of the same database transaction.

The Pattern

Application
    │
    │  1. Within ONE database transaction:
    │     - Write business entity (e.g., Order)
    │     - Write message to OutboxMessages table
    ▼
┌────────────────────────┐
│     Azure SQL DB       │
│  ┌──────────────────┐  │
│  │  Orders table    │  │
│  └──────────────────┘  │
│  ┌──────────────────┐  │
│  │ OutboxMessages   │  │
│  │  (pending rows)  │  │
│  └──────────────────┘  │
└────────────┬───────────┘
             │  2. Background processor polls
             │     unprocessed outbox rows
             ▼
┌────────────────────────┐
│  Outbox Publisher      │
│  (Azure Function /     │
│   Timer Trigger)       │
└────────────┬───────────┘
             │  3. Publish to Service Bus
             ▼
┌────────────────────────┐
│   Service Bus Topic    │
└────────────────────────┘
             │  4. Mark outbox row as published
             ▼
      (atomic update in DB)

EF Core Implementation

// OutboxMessage entity
public class OutboxMessage
{
    public Guid Id { get; set; } = Guid.NewGuid();
    public string EventType { get; set; } = default!;
    public string Payload { get; set; } = default!;
    public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow;
    public DateTimeOffset? PublishedAt { get; set; }
    public string? Error { get; set; }
    public int RetryCount { get; set; }
}

// Application service — writes order AND outbox in one transaction
public class OrderService
{
    private readonly AppDbContext _db;

    public OrderService(AppDbContext db) => _db = db;

    public async Task CreateOrderAsync(CreateOrderRequest request)
    {
        var order = new Order
        {
            Id = Guid.NewGuid(),
            CustomerId = request.CustomerId,
            TotalAmount = request.TotalAmount,
            Status = "Pending",
            CreatedAt = DateTimeOffset.UtcNow
        };

        var outboxMessage = new OutboxMessage
        {
            EventType = "Order.Created",
            Payload = JsonSerializer.Serialize(new OrderCreatedEvent
            {
                OrderId = order.Id,
                CustomerId = order.CustomerId,
                TotalAmount = order.TotalAmount
            })
        };

        _db.Orders.Add(order);
        _db.OutboxMessages.Add(outboxMessage);

        // Single transaction — both writes succeed or both fail
        await _db.SaveChangesAsync();
    }
}

// Outbox publisher — Timer-triggered Azure Function
[Function("OutboxPublisher")]
public async Task RunOutboxPublisher(
    [TimerTrigger("*/10 * * * * *")] TimerInfo timer)  // Every 10 seconds
{
    // Fetch batch of unpublished messages, with advisory lock
    var messages = await _db.OutboxMessages
        .Where(m => m.PublishedAt == null && m.RetryCount < 5)
        .OrderBy(m => m.CreatedAt)
        .Take(50)
        .ToListAsync();

    foreach (var msg in messages)
    {
        try
        {
            var sbMessage = new ServiceBusMessage(msg.Payload)
            {
                MessageId = msg.Id.ToString(),
                Subject = msg.EventType,
                ContentType = "application/json"
            };

            await _sender.SendMessageAsync(sbMessage);

            msg.PublishedAt = DateTimeOffset.UtcNow;
            _logger.LogInformation("Published outbox message {Id} ({EventType})",
                msg.Id, msg.EventType);
        }
        catch (Exception ex)
        {
            msg.RetryCount++;
            msg.Error = ex.Message;
            _logger.LogError(ex, "Failed to publish outbox message {Id}", msg.Id);
        }
    }

    await _db.SaveChangesAsync();
}

12. Choosing the Right Pattern

The patterns in this article are not mutually exclusive — most production systems combine several. This decision table helps you pick your starting point.

┌───────────────────────────────────────────────────────────────────────────────────┐
│                         Pattern Selection Guide                                    │
├────────────────────────────┬──────────────────────────────────────────────────────┤
│ SITUATION                  │ PATTERN TO REACH FOR                                 │
├────────────────────────────┼──────────────────────────────────────────────────────┤
│ Decouple producer/consumer │ Asynchronous Messaging (Service Bus Queue)           │
│ Scale processing           │ Competing Consumers                                  │
│ Fan-out to multiple svcs   │ Pub-Sub with Topics + Filters                        │
│ Distributed transaction    │ Saga / Process Manager (Durable Functions)           │
│ Multi-channel API surface  │ API Gateway + BFF (APIM)                             │
│ Loose coupling via events  │ Event Grid Choreography                              │
│ Parallel batch processing  │ Fan-Out / Fan-In (Durable Functions)                 │
│ Handle bad messages        │ Dead Letter + Poison Message Handler                 │
│ Payloads > 1 MB            │ Claim Check (Blob Storage reference)                 │
│ At-least-once delivery     │ Idempotent Consumer (Table Storage dedup)            │
│ Atomic DB + message write  │ Outbox Pattern (SQL + background publisher)          │
└────────────────────────────┴──────────────────────────────────────────────────────┘

Combining Patterns — A Real Architecture

A production order processing system typically combines:

Client ──► APIM (BFF + rate limit + JWT)
              │
              ▼
         Service Bus Queue  ◄── Outbox Publisher (SQL outbox)
              │
              ▼
         Order Consumer Function  ──► Idempotent check (Table Storage)
              │
              ▼
         Durable Function Orchestrator  (Saga: payment + inventory + shipping)
              │
              ▼
         Event Grid Topic  ──► Notification / Analytics / BI (Choreography)
              │
           DLQ Function  (archive + alert on failures)

Each layer applies one pattern cleanly. The result is a system where:


Summary

PatternAzure Service(s)Key Benefit
Async MessagingService BusDecoupling + durability
Competing ConsumersService Bus + FunctionsHorizontal scale
Pub-SubService Bus TopicsFan-out with filtering
SagaDurable FunctionsDistributed transaction safety
API Gateway / BFFAPI ManagementCross-cutting concerns at the edge
ChoreographyEvent GridLoose coupling without orchestrator
Fan-Out / Fan-InDurable FunctionsParallelism with aggregation
Dead Letter HandlingService Bus DLQ + BlobNo silent data loss
Claim CheckBlob Storage + Service BusLarge payload support
Idempotent ConsumerTable StorageSafe at-least-once delivery
OutboxAzure SQL + FunctionsAtomic DB + event consistency

Mastering these patterns — and knowing when not to apply one — is what defines a mature Azure integration architect.