Chapter 7 of 12

Azure for .NET Developers: Production-Grade Cloud Architecture and Operations

Messaging and Event-Driven Architecture on Azure

1. Foundations: Choosing the Right Messaging Primitive

The Three Pillars of Azure Messaging

Azure's messaging portfolio is not a single service with performance tiers — it is three services with fundamentally different guarantees. Azure Service Bus is an enterprise broker for reliable, at-least-once (or exactly-once with sessions) delivery of business-critical commands. Azure Event Grid is a reactive routing fabric that pushes lightweight notifications to dozens of subscribers with sub-second latency. Azure Event Hubs is a distributed commit log that retains events for 1–90 days, enabling independent consumers to replay history at their own pace.

The architectural decision is almost always mechanical: if losing a message is unacceptable, use Service Bus; if you need high-fan-out reactivity, use Event Grid; if you need telemetry ingestion or event replay at millions of events per second, use Event Hubs.

Selecting the Right Service: Decision Matrix

DimensionAzure Service BusAzure Event GridAzure Event Hubs
Primary patternCommand / RPC replacementEvent notification / fan-outEvent streaming / log
Message size256 KB (Standard) / 100 MB (Premium)Up to 1 MB per eventUp to 1 MB per event
Delivery guaranteeAt-least-once; exactly-once with sessionsAt-least-once (with retry)At-least-once via checkpointing
OrderingFIFO per sessionNot guaranteedOrdered within partition
Replay / retentionDLQ for failed messages onlyNo replay1–90 days (configurable)
Consumer modelCompeting consumers (pull)Push (webhook / EventGrid trigger)Pull with offset checkpointing
ProtocolAMQP 1.0 / HTTPHTTP (CloudEvents)AMQP / Kafka protocol
Best forOrder processing, workflows, sagasReactive routing, fan-out triggersTelemetry, analytics, audit logs

Note

Service Bus Premium and Event Hubs Premium/Dedicated tiers provide network isolation via Private Link and VNet injection, which is mandatory for most enterprise compliance frameworks.

Three-layer architecture diagram showing .NET producer services at the top feeding Azure Service Bus with topics, subscriptions, sessions, and dead-letter queue; Azure Event Grid with custom topics, event filters, and push delivery; and Azure Event Hubs with Kafka protocol endpoint and partitioned consumer groups. A processing layer below shows MassTransit saga orchestration, idempotency middleware with outbox relay, and a .NET Kafka consumer. An annotation panel explains the transactional outbox pattern, session-ordered delivery, idempotency guarantees, and the Kafka-on-Event-Hubs protocol bridge.
Figure 7.1 — Azure Messaging Architecture: Service Bus, Event Grid, Event Hubs, and MassTransit Saga

2. Azure Service Bus: Topics, Subscriptions, Sessions, and Dead-Letter Queues

Namespace Architecture and SKU Selection

A Service Bus namespace is the top-level billing unit. The Standard tier uses shared multi-tenant infrastructure and is appropriate for dev/test workloads; it does not support Private Link, geo-DR, or messages larger than 256 KB. The Premium tier provides single-tenant reserved messaging units (MUs), messages up to 100 MB, geo-disaster recovery, Private Link, and customer-managed keys — the minimum acceptable tier for production workloads handling PII or financial data.

bash
SB_NS="messaging-event-prod-eastus2-001"
az servicebus namespace create \
  --name "$SB_NS" --resource-group "$RG_PROD" \
  --sku Premium --capacity 2 \
  --zone-redundant true --minimum-tls-version 1.2
az servicebus topic create \
  --namespace-name "$SB_NS" --name "orders" \
  --enable-duplicate-detection true \
  --duplicate-detection-history-time-window PT10M \
  --enable-ordering true # truncated — see CE-13 lab for full script

Important

Always enable --enable-duplicate-detection on topics that handle financial or inventory commands. The rolling window (10 min default) suppresses re-delivered duplicates at the broker level with no application logic required.

Topics and Subscription Filters

Each subscription maintains an independent copy of messages matching its filter rules. Correlation filters match on up to 50 message properties using an optimized hash index — negligible overhead at any subscription count. SQL filters accept arbitrary SQL-92 WHERE clauses but are more expensive to evaluate; benchmark before deploying many SQL-filtered subscriptions on a single topic.

Model your domain events to carry enough metadata as application properties that downstream services can filter without parsing the body. Use the Subject property as a discriminated union for your event type name — all consumers inspect it before deserializing, keeping contracts explicit and filterable at the broker layer.

Tip

Use Subject (SDK v7.x) as the event type discriminator. A message with Subject = "OrderPlaced" and application properties for Region and Channel can be routed by correlation filters to multiple subscriptions at zero SQL evaluation cost.

Sessions for Ordered Processing

Without sessions, a subscription delivers messages to the first available consumer regardless of enqueue order. Sessions tie messages with the same SessionId to a single locked consumer — guaranteeing FIFO within a session while preserving horizontal scalability across different sessions. In .NET, use ServiceBusSessionProcessor with ProcessSessionMessageAsync and store per-session state via sessionArgs.GetSessionStateAsync().

Warning

Session support is set at entity creation time and cannot be added to an existing queue or topic without recreating it. Always design for sessions if ordered processing may be needed.

Dead-Letter Queues and Poison Message Handling

Every queue and subscription has an auto-provisioned DLQ at the /$DeadLetterQueue suffix. Messages land there when MaxDeliveryCount is exceeded, TTL expires with dead-lettering enabled, or a filter evaluation throws. A growing DLQ is a leading indicator of systemic failure — deploy a dedicated DLQ consumer per subscription with alerting and a replay mechanism.

Important

MaxDeliveryCount interacts with lock duration. Set lock duration to at least 2× your P99 processing time, and renew locks programmatically via processor.RenewMessageLockAsync() for long-running operations.

Architecture diagram illustrating the transactional outbox pattern in a .NET application: a command handler writes domain events to a SQL outbox table within the same database transaction, a background outbox publisher forwards them to an Azure Service Bus topic with sessions enabled, the topic fans out to a saga-orders subscription consumed by a MassTransit saga state machine tracking Pending-Processing-Completed states correlated by OrderId session key, and a notify subscription consumed by a notification handler that enforces idempotency via Redis before publishing CloudEvents to an Event Grid custom topic for push delivery; a dead-letter queue captures poison messages, and Event Hubs with Kafka protocol is shown as an alternative high-throughput streaming path.
Figure 7.2 — Transactional Outbox Pattern with MassTransit Saga Orchestration and idempotency on Azure Service Bus

3. Azure Event Grid: Custom Topics, Event Filtering, and Push Delivery

Event Grid Architecture and System Topics

Event Grid's programming model has three components: an event source (Azure service or custom topic), an event subscription (routing rule), and an event handler (webhook, Function, Service Bus, Storage Queue, or Event Hubs). System topics are created automatically for supported Azure services; custom topics are first-class resources you create for domain events, authenticated via managed identity.

bash
az eventgrid topic create \
  --name "evgt-orders-prod-eastus2-001" \
  --resource-group "$RG_PROD" --location eastus2 \
  --input-schema cloudeventschemav1_0
az eventgrid event-subscription create \
  --name "order-webhook-subscription" \
  --endpoint "https://api.contoso.com/webhooks/order-events" \
  --included-event-types "com.contoso.orders.placed" \
  --advanced-filter data.orderTotal NumberGreaterThan 100 \
  --max-delivery-attempts 30 --event-ttl 1440

Event Filtering: Basic and Advanced

Event type inclusion is the first filter layer — always declare a specific includedEventTypes list rather than using the wildcard default. The second layer is advanced filters using JSON path expressions (up to 25 clauses per subscription, implicit AND). They operate on data.*, subject, eventType, and other top-level properties.

Tip

Model your CloudEvents subject as a hierarchical path (e.g., /orders/EU/online/12345). Event Grid prefix/suffix matching on subject lets you filter by region or channel without any advanced filter overhead.

Webhook Delivery, Retries, and Dead-Lettering

Event Grid retries failed deliveries with exponential backoff for up to 24 hours or until maxDeliveryAttempts is exhausted. Your webhook must handle the subscription validation handshake (echo validationResponse) before receiving events. Dead-lettering for Event Grid requires a Blob Storage container — undeliverable events are written as JSON envelopes with last HTTP response code included.

Warning

Event Grid's 24-hour retry window may not be sufficient for extended outages. For business-critical delivery, route to an Azure Service Bus queue (natively supported as an Event Grid sink) and process from the durable queue when the downstream recovers.

4. Azure Event Hubs: High-Throughput Streaming with Kafka Protocol

Partitions, Consumer Groups, and the Data Model

Event Hubs is a distributed commit log. Each event hub has a fixed number of immutable partitions — ordered, append-only sequences. A consumer group maintains independent offsets per partition, enabling ten different consumers (analytics, search, cold storage) to read the same stream independently. Partition count equals maximum parallelism within a consumer group and cannot be changed after creation.

bash
az eventhubs namespace create \
  --name "evhns-telemetry-prod-eastus2-001" \
  --sku Premium --enable-kafka true \
  --zone-redundant true
az eventhubs eventhub create \
  --namespace-name "evhns-telemetry-prod-eastus2-001" \
  --name "telemetry-events" \
  --partition-count 32 --message-retention 7
# See CE-14 lab for consumer groups, Capture, and SAS policy setup

Kafka Protocol Compatibility in .NET

Event Hubs exposes a Kafka-compatible endpoint at <namespace>.servicebus.windows.net:9093 using SASL_SSL. Existing Kafka applications can migrate by updating only the broker address and credentials — no application code changes. For greenfield .NET projects, the native Azure.Messaging.EventHubs SDK is preferred for its first-class managed identity integration and OpenTelemetry diagnostics.

Note

Enable Event Hubs Capture for all production hubs to archive events to Blob Storage or ADLS Gen2 in Avro format. If consumers fall behind the retention window, Capture provides a durability backstop for backfill.

Event Processor Client: Checkpointing and Load Balancing

EventProcessorClient manages partition ownership across instances via blob storage lease negotiation and raises ProcessEventAsync per message. Checkpoint frequency is a throughput-vs-reprocessing tradeoff: checkpointing every 100 events or 10 seconds is a practical default. Always checkpoint after a successful downstream write to maintain at-least-once semantics.

A three-column comparison matrix diagram contrasting Azure Service Bus (topics, subscriptions, sessions, dead-letter queues, MassTransit sagas), Azure Event Grid (custom topics, event filtering, push delivery, transactional outbox pattern), and Azure Event Hubs (Kafka protocol endpoint, partitions, capture and replay, .NET Confluent Kafka consumer) with a characteristics row and annotated .NET decision guide at the bottom.
Figure 7.3 — Azure Messaging Services comparison matrix with .NET integration patterns for Service Bus, Event Grid, and Event Hubs

5. MassTransit on Azure Service Bus: Saga Orchestration

MassTransit Fundamentals for Azure

MassTransit maps its consumer and saga model onto Service Bus entities automatically — each message type gets a topic/subscription pair, sagas get a dedicated queue. Configure the transport using UsingAzureServiceBus with DefaultAzureCredential for managed identity. Disable AutoStart entity creation in production and pre-create infrastructure via Bicep or the Azure CLI.

csharp
builder.Services.AddMassTransit(x => {
    x.AddSagaStateMachine<OrderFulfillmentStateMachine, OrderFulfillmentState>()
        .EntityFrameworkRepository(r => { r.ExistingDbContext<OrderDbContext>(); r.UseSqlServer(); });
    x.UsingAzureServiceBus((ctx, cfg) => {
        cfg.Host(new Uri("sb://messaging-event-prod-eastus2-001.servicebus.windows.net/"),
            h => h.TokenCredential = new DefaultAzureCredential());
        cfg.UseEntityFrameworkOutbox<OrderDbContext>(ctx);
        cfg.UseMessageRetry(r => r.Exponential(5, TimeSpan.FromSeconds(1),
            TimeSpan.FromSeconds(30), TimeSpan.FromSeconds(2)));
        cfg.ConfigureEndpoints(ctx);
    });
});

State Machine Sagas for Long-Running Workflows

A saga coordinates a multi-step workflow (e.g., OrderPlaced → AwaitingPayment → AwaitingFulfillment → Completed) by persisting state and reacting to domain events. MassTransit's EF Core repository against Azure SQL is the standard production choice, enabling transactional outbox integration. For high-throughput scenarios, the Redis repository via Azure Cache for Redis offers lower latency.

Important

Every saga state machine handler must be idempotent. Service Bus delivers at-least-once, so design state transitions as no-ops for duplicate events by checking the current state before applying the transition.

Consumer Configuration and Error Handling

MassTransit retry (UseMessageRetry) runs in-process without consuming a Service Bus delivery count — only when MassTransit exhausts its retries does it dead-letter the message and increment the Service Bus count. The circuit breaker filter (UseCircuitBreaker) prevents cascade failures by faulting messages immediately when a downstream dependency is unavailable, allowing the broker to redeliver to other instances.

6. Transactional Outbox Pattern and Idempotency Guarantees

The Dual-Write Problem

It is impossible to atomically update a local database and publish a message to an external broker. Crash-after-write-but-before-publish means the message is never sent; crash-after-publish-but-before-write means the event is emitted without persisted state. The outbox pattern closes this gap: write the message into an OutboxMessage table in the same database transaction as the business data, then relay asynchronously.

MassTransit provides a first-class EF Core outbox: cfg.UseEntityFrameworkOutbox<TDbContext>(ctx) intercepts all Publish and Send calls inside consumers and writes them to the outbox table. A background OutboxDeliveryService sweeps the table and delivers pending messages to Service Bus. Combined with Service Bus duplicate detection keyed on the outbox row ID, the relay is safely retriable.

Implementing the Outbox in .NET with EF Core

Add outbox entities to your DbContext by calling modelBuilder.AddInboxStateEntity(), modelBuilder.AddOutboxMessageEntity(), and modelBuilder.AddOutboxStateEntity() in OnModelCreating. Generate and apply EF migrations to create the three tables. The InboxState table tracks processed message IDs for exactly-once consumer semantics beyond the Service Bus duplicate detection window.

bash
APP_MI_ID=$(az identity show --name "id-orders-api-prod-eastus2-001" \
  --resource-group "$RG_PROD" --query principalId -o tsv)
SB_NS_ID=$(az servicebus namespace show --name "$SB_NS" \
  --resource-group "$RG_PROD" --query id -o tsv)
az role assignment create --assignee "$APP_MI_ID" \
  --role "Azure Service Bus Data Sender" --scope "$SB_NS_ID"
az role assignment create --assignee "$APP_MI_ID" \
  --role "Azure Service Bus Data Receiver" --scope "$SB_NS_ID"

Idempotency at the Consumer Level

Even with an outbox on the producer side, consumer idempotency must be designed explicitly. Store (MessageId, ConsumerType) in a processed-messages table within the same transaction as your business operation. Before processing, check if the key exists and acknowledge without reprocessing if found. Use EF Core's RowVersion property for optimistic concurrency so that concurrent deliveries of the same message result in a concurrency exception (triggering retry) rather than silent data corruption.

Note

MassTransit's InboxState table extends idempotency coverage beyond Service Bus's 10-minute duplicate detection window to the full retention period of the InboxState row, providing a long-lived deduplication store at no extra infrastructure cost.

7. Lab: Service Bus Topology and Event Hubs Kafka Provisioning

1

CE-13: Provision a Complete Service Bus Messaging Topology

Create a Standard Service Bus namespace with an orders topic (duplicate detection, 14-day TTL) and three subscriptions: fulfillment-service (all events), notification-service (correlation filter for OrderPlaced), and a sessions-enabled payments topic with payment-processor subscription. Enable diagnostic settings to a Log Analytics workspace.

bash
set -euo pipefail
RG_DEV="rg-messaging-event-driven-architecture-dev-001"
SB_NS_DEV="messaging-event-dev-eastus2-001"
az group create --name "$RG_DEV" --location eastus2 \
  --tags Environment=Development Lab=CE-13
az servicebus namespace create --name "$SB_NS_DEV" \
  --resource-group "$RG_DEV" --sku Standard
az servicebus topic create --namespace-name "$SB_NS_DEV" \
  --name orders --enable-duplicate-detection true \
  --default-message-time-to-live P14D
# Add subscriptions and payments topic — see full script in repo
2

CE-14: Deploy Event Hubs for Telemetry Ingestion with Kafka Protocol

Create a Standard Event Hubs namespace with Kafka enabled, a device-telemetry hub (16 partitions, 3-day retention, Capture to Blob Storage), and three consumer groups. Retrieve the Kafka endpoint and connection string for client configuration.

bash
set -euo pipefail
EH_NS_DEV="evhns-telemetry-dev-eastus2-001"
az eventhubs namespace create --name "$EH_NS_DEV" \
  --resource-group "$RG_DEV" --sku Standard \
  --enable-kafka true --capacity 2
az eventhubs eventhub create --namespace-name "$EH_NS_DEV" \
  --name device-telemetry --partition-count 16 \
  --message-retention 3 --enable-capture true
# bootstrap.servers=${EH_NS_DEV}.servicebus.windows.net:9093

8. Summary

ConceptKey Point
Service Bus vs Event Grid vs Event HubsService Bus = reliable commands; Event Grid = reactive notifications; Event Hubs = high-throughput streaming. Match the service to the delivery semantic.
Subscription FiltersUse correlation filters over SQL filters for low-overhead routing. Model event type in Subject and domain attributes as application properties for broker-side routing.
Sessions for OrderingEnable at entity creation — cannot be added later. FIFO per SessionId with horizontal scalability across sessions.
Dead-Letter QueuesA growing DLQ signals systemic failure. Deploy a dedicated DLQ consumer per subscription with alerting and replay capability.
Event Grid FilteringUse event type inclusion lists and advanced filters. For high-durability delivery, use Service Bus as the Event Grid sink.
Event Hubs and KafkaPartition count is immutable. Checkpoint after a successful downstream write. Kafka protocol enables migration without application changes.
Transactional OutboxWrite messages to a database outbox within the same transaction as business data, then relay asynchronously. Use Service Bus duplicate detection keyed on the outbox row ID.

Chapter: 7 of 12  |  Status: v0.1 Draft  |