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
- Asynchronous Messaging with Service Bus
- Competing Consumers Pattern
- Publish-Subscribe with Topics and Filters
- Saga / Process Manager Pattern
- API Gateway + Backend-for-Frontend (BFF)
- Event-Driven Choreography with Event Grid
- Fan-Out / Fan-In with Durable Functions
- Dead Letter + Poison Message Handling
- Claim Check Pattern for Large Payloads
- Idempotent Consumer Pattern
- Outbox Pattern with Azure SQL + Service Bus
- 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:
- Producers and consumers are decoupled in time and scale
- Failures are contained at each boundary, not propagated
- Retries are safe because consumers are idempotent
- Large payloads never stress the messaging infrastructure
- Business transactions across services commit atomically via Saga + Outbox
Summary
| Pattern | Azure Service(s) | Key Benefit |
|---|---|---|
| Async Messaging | Service Bus | Decoupling + durability |
| Competing Consumers | Service Bus + Functions | Horizontal scale |
| Pub-Sub | Service Bus Topics | Fan-out with filtering |
| Saga | Durable Functions | Distributed transaction safety |
| API Gateway / BFF | API Management | Cross-cutting concerns at the edge |
| Choreography | Event Grid | Loose coupling without orchestrator |
| Fan-Out / Fan-In | Durable Functions | Parallelism with aggregation |
| Dead Letter Handling | Service Bus DLQ + Blob | No silent data loss |
| Claim Check | Blob Storage + Service Bus | Large payload support |
| Idempotent Consumer | Table Storage | Safe at-least-once delivery |
| Outbox | Azure SQL + Functions | Atomic DB + event consistency |
Mastering these patterns — and knowing when not to apply one — is what defines a mature Azure integration architect.