Intermediate AZ-204

AZ-204: Building Connected and Distributed Systems

API Management, Event Grid, Event Hubs, Service Bus and messaging & resilience patterns.

Course: Azure Developer Associate (AZ-204) – Building Connected and Distributed Systems

Module 1 – Azure API Management (APIM)

Why Use API Management?

  • Centralized gateway: security, access control, monitoring.
  • API lifecycle management: creation, deployment, versioning, retirement.
  • Cloud-native scalability: high availability during traffic spikes.
  • Collaboration: developer portal, documentation, onboarding for partners/clients.

Architecture and Pricing Tiers

TierUsageSLAFeatures
ConsumptionServerless, lightweight, internal APIs. Pay-per-use.LimitedReduced features
DeveloperDev/Test only. Low fixed price.❌ No SLAAlmost all features
PremiumProduction. HA, multi-region.✅ GuaranteedAll features + VNet, scale-out

APIM Components

  • Gateway: receives API requests, applies policies, forwards to backends.
  • Management API: configuration management via portal, CLI, ARM.
  • Developer Portal: auto-generated portal for developers/partners. Customizable (WYSIWYG).

Importing and Publishing an API

  • Supports OpenAPI (Swagger), WSDL, WADL, and other formats.
  • Each operation has a configurable suffix URL.
  • Can test directly in the portal with detailed tracing.

Products and Subscriptions

  • A Product groups one or more APIs.
  • Each Subscription is associated with a Product and generates 2 subscription keys (for rotation).
  • Clients include the key in requests (header Ocp-Apim-Subscription-Key).
  • Reason for 2 keys: allows rotation without service interruption.

Securing APIs

Subscription Keys

  • Simple, lightweight. Per-client control. Quotas and rate limiting per key.
  • Limitations: no granular role/identity control.

OAuth 2.0 with Entra ID

  1. Register an app in Entra ID → note the clientId and clientSecret.
  2. Configure a redirectUri (e.g. https://localhost for testing).
  3. Expose the API in Entra ID: define the Application ID URI.
  4. Configure APIM: add JWT validation in policies.
  5. Client Credentials Flow: obtain a token via Postman with tenant ID, client ID, secret.
  6. Include the Bearer token in API requests.

Combination: Subscription Key + OAuth

  • Multi-layer security: the key identifies the consumer, the token authenticates the identity.

RBAC

  • Controls who can create/modify/delete APIs in APIM.
  • Integrated with Entra ID for centralized access management.

Policies

  • Allow modifying API behavior without touching the backend code.
  • Scopes (application levels):
    • All APIs: applies to all APIs in the instance.
    • Product: applies to all APIs in a Product.
    • API: applies to a specific API.
    • Operation: applies to a specific operation.

Policy Types

  • Inbound: processing before the backend call (auth, transformation, validation).
  • Backend: managing the connection to the backend.
  • Outbound: response processing (transformation, logging).

Common Policies

PolicyDescription
rate-limitLimits the number of calls (e.g.: 5 calls/minute per subscription key)
corsEnables cross-origin requests for browser apps
rewrite-uriRewrites the request URL
set-headerAdds/modifies headers
validate-jwtValidates an OAuth JWT token
cache-lookup / cache-storeResponse caching

XML Policy Format

<policies>
    <inbound>
        <rate-limit-by-key calls="5" renewal-period="60" counter-key="@(context.Subscription?.Key ?? "anonymous")" />
        <cors>
            <allowed-origins><origin>*</origin></allowed-origins>
            <allowed-methods><method>GET</method></allowed-methods>
        </cors>
        <base />
    </inbound>
    <backend><base /></backend>
    <outbound><base /></outbound>
    <on-error><base /></on-error>
</policies>

Developer Portal

  • Auto-generated web portal, fully customizable (CSS, fonts, menus).
  • WYSIWYG editing with widgets/sections.
  • Developers see published APIs, Products, methods, and can test them.
  • Publish from admin mode → available to anonymous or registered users.

Module 2 – Azure Event Grid and Event Hubs (Event-driven Solutions)

Event-Driven Architecture (EDA)

  • Event producersBrokersEvent consumers.
  • Asynchronous communication: the producer continues without waiting for a response.
  • Benefits: natural scalability, loose coupling, real-time reactivity.

Azure Event Grid

  • Discrete event routing (state change notifications).
  • Architecture: Topics → Subscriptions → Event Handlers.

Topics: System vs Custom

TypeDescription
System TopicsAutomatically created by Azure for its resources (Storage, Resource Groups, etc.)
Custom TopicsManually created for your application events

Supported Event Handlers

  • Azure Functions, Logic Apps, Event Hubs, Service Bus, Webhooks, Storage Queues.

Creating and Sending an Event (CLI)

# Retrieve the topic endpoint and key
endpoint=$(az eventgrid topic show --name <topic> -g <rg> --query "endpoint" -o tsv)
key=$(az eventgrid topic key list --name <topic> -g <rg> --query "key1" -o tsv)

# Send an event
curl -X POST -H "aeg-sas-key: $key" -H "Content-Type: application/json" \
  -d '[{"id":"1","subject":"test","eventType":"testEvent","dataVersion":"1.0","data":{"message":"Hello"}}]' \
  $endpoint

Triggering an Azure Function from Event Grid (local with ngrok)

  1. Develop the function locally (debug mode, port 7172).
  2. Use ngrok to create a public tunnel: ngrok http 7172.
  3. Copy the ngrok URL + the function path as the Event Handler endpoint.
  4. Event Grid validates the endpoint via a validation handshake.
  • Limitation: the ngrok URL is only valid while the ngrok window remains open.

Azure Event Hubs

  • High-performance stream ingestion (streaming).
  • Use cases: IoT telemetry, centralized logs, real-time analytics.

Event Hubs Architecture

Producers → Event Hubs Namespace → Event Hub → Partitions → Consumer Groups
  • Namespace: secure container for one or more Event Hubs (create them separately).
  • Partitions: divide the stream into parallel lanes → scalability.
  • Consumer Groups: independent stream views for multiple consumers in parallel.
  • Retention: events kept for a configurable duration → consumers can replay.

Event Grid vs Event Hubs

CharacteristicEvent GridEvent Hubs
Use caseDiscrete events/notificationsContinuous massive data stream
ThroughputModerateVery high (millions of events/s)
PersistenceVery shortConfigurable (days/weeks)
OrderingNot guaranteedOrdered by partition
AnalogyLetter carrier (targeted delivery)Data river (continuous stream)

Sending Events to Event Hubs (C#)

var connectionString = "<connection-string>";
var eventHubName = "<hub-name>";

await using var producer = new EventHubProducerClient(connectionString, eventHubName);
using var eventBatch = await producer.CreateBatchAsync();

for (int i = 1; i <= 10; i++)
{
    var eventData = new EventData(Encoding.UTF8.GetBytes($"Event #{i}"));
    eventBatch.TryAdd(eventData);
}
await producer.SendAsync(eventBatch);

Consuming from Event Hubs (C#)

// EventProcessorClient requires a blob container for checkpoints
var storageClient = new BlobContainerClient(storageConnectionString, containerName);
var processor = new EventProcessorClient(storageClient, EventHubConsumerClient.DefaultConsumerGroupName,
    connectionString, eventHubName);

processor.ProcessEventAsync += async (args) =>
{
    Console.WriteLine(Encoding.UTF8.GetString(args.Data.Body.ToArray()));
    await args.UpdateCheckpointAsync(); // Save the read position
};

processor.ProcessErrorAsync += (args) =>
{
    Console.WriteLine(args.Exception.Message);
    return Task.CompletedTask;
};

await processor.StartProcessingAsync();
Console.ReadKey();
await processor.StopProcessingAsync();

Checkpoints

  • Save the read position in the stream for each partition.
  • Stored in Blob Storage via EventProcessorClient.
  • Allow resuming reading after a stop without reprocessing already-processed messages.
  • Consumer Groups allow multiple consumers to read the same stream independently.

EDA Best Practices

  • Clear event schemas: include source, type, timestamp. Plan for backward compatibility.
  • Idempotency: repeated processing without creating duplicates.
  • Dead-letter: queue for events not processed after multiple attempts.
  • Partitioning: enables parallel processing.
  • Backpressure: avoid overwhelming consumers.
  • Azure Monitor: alerts on metrics (lag, error rate).

Module 3 – Azure Service Bus and Queue Storage (Messaging)

Messaging vs Eventing Difference

  • Eventing: producer does not wait for a response, is not responsible for processing.
  • Messaging: the message contains a command expecting an action. The producer may require a response.

Azure Service Bus vs Azure Storage Queues

CharacteristicService BusStorage Queues
ProtocolsAMQP, HTTPSHTTPS only
Max message size256 KB (Standard), 100 MB (Premium)64 KB
Retention duration14 days max7 days max
OrderingFIFO with sessionsNot guaranteed
Duplicate detection✅ Built-in❌ No
Topics/Subscriptions✅ Publish-subscribe❌ No
Dead-letter queue✅ Automatic❌ No
Sessions
Transactions
PriceHigherLow
Use caseEnterprise, complex workflowsSimple decoupling, high non-critical volume

Service Bus – Queues vs Topics

  • Queue: point-to-point (1 producer → 1 consumer). FIFO. Message consumed once.
  • Topic + Subscriptions: publish-subscribe (1 producer → N consumers). Each subscription receives a copy. Configurable filters per subscription.

Messaging Patterns

PatternService BusDescription
Command (point-to-point)QueueOne message → one recipient
Publish-SubscribeTopic + SubscriptionsOne message → N subscribers
Request-ReplyCorrelation IDsSimulates synchronicity

Sending/Receiving with Service Bus (C#)

// Connection (Do NOT use Master Key in production → prefer Entra ID)
var client = new ServiceBusClient(connectionString);

// Send a message
var sender = client.CreateSender("demoqueue");
await sender.SendMessageAsync(new ServiceBusMessage("Hello World"));

// Receive messages
var receiver = client.CreateReceiver("demoqueue");
var message = await receiver.ReceiveMessageAsync();
Console.WriteLine(message.Body);
await receiver.CompleteMessageAsync(message); // Confirm receipt

Sessions (FIFO Messaging)

  • Group related messages with the same SessionId.
  • Messages in the same group processed in order by a single consumer.
  • Must be enabled when creating the queue/subscription.

Session Use Cases

  • Sequential steps of a workflow (payment → stock allocation → shipping).
  • Financial transactions per user.
  • IoT: data from the same device in order.
  • Multi-step approval processes.
// Send with SessionId
var message = new ServiceBusMessage("Step 1") { SessionId = "order-123" };
await sender.SendMessageAsync(message);

// Receive a specific session
var sessionReceiver = await client.AcceptSessionAsync("sessionqueue", "order-123");
var msg = await sessionReceiver.ReceiveMessageAsync();

Dead-Letter Queue (DLQ)

  • Logical sub-queue automatically attached to each queue/subscription.
  • Receives messages that cannot be processed (after N attempts, expired, or explicitly rejected).
  • Access path: <queue-name>/$deadletterqueue.
  • DLQ messages retain all their properties + error metadata.
  • MaxDeliveryCount: max number of attempts before DLQ (default: 10, configurable).
  • Note: producers CANNOT write directly to the DLQ.
// Explicitly move a message to the DLQ with reason
await receiver.DeadLetterMessageAsync(message,
    deadLetterReason: "ValidationFailed",
    deadLetterErrorDescription: "The 'email' field is invalid");

// Access the DLQ for inspection/processing
var dlqReceiver = client.CreateReceiver("demoqueue",
    new ServiceBusReceiverOptions { SubQueue = SubQueue.DeadLetter });

Publish-Subscribe with Filters

// Create filtering rules on a subscription
await adminClient.CreateRuleAsync("demotopic", "sub1", new CreateRuleOptions
{
    Name = "OddFilter",
    Filter = new SqlRuleFilter("Category = 'Odd'")
});

// Send a message with custom property
var message = new ServiceBusMessage("Data") { ApplicationProperties = { ["Category"] = "Odd" } };

Azure Storage Queues

  • Lightweight REST-based solution.
  • Use cases: simple decoupling (e.g.: offloading background processing to Azure Functions/WebJobs).
  • No topics, sessions, or duplicate detection.
var queueClient = new QueueClient(connectionString, "demoqueue");
await queueClient.CreateIfNotExistsAsync(); // Idempotent

// Send
await queueClient.SendMessageAsync("Hello World");

// Receive and process
var messages = await queueClient.ReceiveMessagesAsync(maxMessages: 10);
foreach (var msg in messages.Value)
{
    Console.WriteLine(msg.MessageText);
    await queueClient.DeleteMessageAsync(msg.MessageId, msg.PopReceipt);
}

Messaging Security

  • SAS (Shared Access Signature): time-limited access (read/write/delete). Use for third-party delegation.
  • RBAC with Entra ID: recommended for Azure applications.
    • Azure Service Bus Data Sender: send messages.
    • Azure Service Bus Data Receiver: receive messages.
  • Encryption: at-rest automatically by Microsoft. Option for customer-managed keys.
  • TLS: all communications encrypted in transit.

Key Points for the Exam

APIM

  • Policies = correct answer for auth headers and transformations (not Products, Named Values, or Backends).
  • 3 policy levels: All APIs → Products → APIs (cumulative inheritance).
  • Subscription Key + OAuth = multi-layer security.

Event Grid vs Event Hubs vs Service Bus

  • Event Grid: discrete events, low volume, reactivity (resource changes, user actions).
  • Event Hubs: IoT telemetry, logs, millions of events/s, high-performance streaming.
  • Service Bus: enterprise workflows, FIFO, sessions, transactions, dead-letter queue.

Common Trick Questions

  • Azure Notification Hubs = push notifications to mobile apps (not for IoT telemetry).
  • For FIFO with subsets (by user, by transaction) → Service Bus sessions.
  • To filter messages by properties → Subscription Filters (not ForwardTo, Sessions, or DLQ).
  • Event Hubs checkpoints → stored in Blob Storage via EventProcessorClient.

Module 5 – Azure Service Bus: Deep Dive

Namespaces and Service Tiers

A namespace is the root container for all Service Bus entities (queues, topics, relays). It has a unique DNS name (<namespace>.servicebus.windows.net).

TierMax message sizePartitioningAvailability zonesUse case
Basic256 KBSimple queues, dev/test
Standard256 KBStandard production
Premium100 MBHigh perf, network isolation, VNET
# Create a Premium namespace with CLI
az servicebus namespace create \
  --resource-group myRG \
  --name myNamespace \
  --sku Premium \
  --location eastus

Queues vs Topics / Subscriptions

flowchart LR
    P[Producer] -->|SendMessageAsync| Q["(Queue)"]
    Q -->|ReceiveMessageAsync| C[Single consumer]

    P2[Producer] -->|SendMessageAsync| T["(Topic)"]
    T --> S1["(Sub 1\nSQL filter)"]
    T --> S2["(Sub 2\nCorrelation filter)"]
    T --> S3["(Sub 3\nNo filter)"]
    S1 --> C1[Consumer A]
    S2 --> C2[Consumer B]
    S3 --> C3[Consumer C]

Subscription Filters

TypeSyntaxPerformanceUsage
SQL FilterCategory = 'Electronics' AND Price > 100ModerateComplex conditions on properties
Correlation Filter{ CorrelationId = "abc", Label = "order" }Very fastExact match (recommended)
True Filter(default)MaximumReceive all messages
// Create a SQL filter on a subscription
var adminClient = new ServiceBusAdministrationClient(connectionString);

// Remove the default filter ($Default = True)
await adminClient.DeleteRuleAsync("demotopic", "sub1", "$Default");

// Add a SQL filter
await adminClient.CreateRuleAsync("demotopic", "sub1", new CreateRuleOptions
{
    Name = "HighPrice",
    Filter = new SqlRuleFilter("Category = 'Electronics' AND Price > 100"),
    Action = new SqlRuleAction("SET Label = 'premium'") // Optional action
});

// Add a correlation filter (more performant)
await adminClient.CreateRuleAsync("demotopic", "sub2", new CreateRuleOptions
{
    Name = "CorrelFilter",
    Filter = new CorrelationRuleFilter
    {
        CorrelationId = "urgent-order",
        ApplicationProperties = { ["Priority"] = "High" }
    }
});

Dead-Letter Queue (DLQ) — Deep Dive

flowchart TD
    M[Incoming message] --> Q["(Active Queue)"]
    Q -->|Attempt 1..N| P[Processor]
    P -->|CompleteAsync| END[✅ Processed]
    P -->|AbandonAsync| Q
    P -->|DeadLetterAsync| DLQ["(Dead-Letter Queue\nqueue/$deadletterqueue)"]
    Q -->|MaxDeliveryCount exceeded| DLQ
    Q -->|TTL expired| DLQ
    DLQ --> DIAG[🔍 Diagnostic / Replay]

Properties automatically added to DLQ:

PropertyDescription
DeadLetterReasonCause of dead-lettering (MaxDeliveryCountExceeded, TTLExpiredException, custom)
DeadLetterErrorDescriptionDetailed description provided by the consumer
EnqueuedTimeUtcOriginal enqueue timestamp
DeliveryCountNumber of delivery attempts made
// Read and reprocess from DLQ
var dlqReceiver = client.CreateReceiver(
    "demoqueue",
    new ServiceBusReceiverOptions { SubQueue = SubQueue.DeadLetter });

ServiceBusReceivedMessage dlqMsg = await dlqReceiver.ReceiveMessageAsync();

Console.WriteLine($"DLQ Reason: {dlqMsg.DeadLetterReason}");
Console.WriteLine($"Description: {dlqMsg.DeadLetterErrorDescription}");
Console.WriteLine($"Body: {dlqMsg.Body}");

// Optional: re-send the corrected message
var sender = client.CreateSender("demoqueue");
await sender.SendMessageAsync(new ServiceBusMessage(dlqMsg.Body));
await dlqReceiver.CompleteMessageAsync(dlqMsg);

Sessions — Guaranteed FIFO Order

Sessions enable strict FIFO processing within a group of messages identified by a SessionId. Only one consumer holds the session lock at a time.

sequenceDiagram
    participant P as Producer
    participant Q as Queue (sessions ON)
    participant C1 as Consumer 1
    participant C2 as Consumer 2

    P->>Q: msg{SessionId="user-A", seq=1}
    P->>Q: msg{SessionId="user-B", seq=1}
    P->>Q: msg{SessionId="user-A", seq=2}
    P->>Q: msg{SessionId="user-A", seq=3}

    Q-->>C1: AcceptSession("user-A") → locked
    C1->>C1: Processes seq=1, seq=2, seq=3 in order
    Q-->>C2: AcceptSession("user-B")
    C2->>C2: Processes seq=1
// Send messages with SessionId
var sender = client.CreateSender("sessionqueue");
for (int i = 1; i <= 5; i++)
{
    await sender.SendMessageAsync(new ServiceBusMessage($"Step {i}")
    {
        SessionId = "order-456",
        Subject = $"step-{i}"
    });
}

// Receive a session (blocks until available)
ServiceBusSessionReceiver sessionReceiver =
    await client.AcceptSessionAsync("sessionqueue", "order-456");

while (true)
{
    ServiceBusReceivedMessage msg = await sessionReceiver.ReceiveMessageAsync(TimeSpan.FromSeconds(5));
    if (msg == null) break;
    Console.WriteLine($"Session {msg.SessionId}: {msg.Body}");
    await sessionReceiver.CompleteMessageAsync(msg);
}

// Session state (contextual storage)
await sessionReceiver.SetSessionStateAsync(BinaryData.FromString("progress=3/5"));
BinaryData state = await sessionReceiver.GetSessionStateAsync();

Duplicate Detection

  • Enabled at queue/topic creation time (not modifiable afterward).
  • Configurable window: 20 seconds to 7 days (default: 10 minutes).
  • Service Bus stores the MessageId within the window → silently rejects duplicates.
// Create a queue with duplicate detection
await adminClient.CreateQueueAsync(new CreateQueueOptions("my-queue")
{
    RequiresDuplicateDetection = true,
    DuplicateDetectionHistoryTimeWindow = TimeSpan.FromMinutes(30)
});

// Send with a stable MessageId (idempotent)
await sender.SendMessageAsync(new ServiceBusMessage("payload")
{
    MessageId = "order-789-retry-2" // Same ID → ignored if already received
});

Message Deferral

A consumer can defer a message it is not ready to process yet, without rejecting it. The message remains in the queue but is no longer delivered automatically — it must be recalled by its SequenceNumber.

// Defer a message
long seqNumber = message.SequenceNumber;
await receiver.DeferMessageAsync(message);

// Later: retrieve the deferred message by sequence number
ServiceBusReceivedMessage deferred = await receiver.ReceiveDeferredMessageAsync(seqNumber);
await receiver.CompleteMessageAsync(deferred);

Scheduled Messages

// Schedule delivery in 2 hours
DateTimeOffset scheduledTime = DateTimeOffset.UtcNow.AddHours(2);
long seqNumber = await sender.ScheduleMessageAsync(
    new ServiceBusMessage("Appointment reminder"),
    scheduledTime);

// Cancel a scheduled message
await sender.CancelScheduledMessageAsync(seqNumber);

Service Bus Transactions

Multiple operations can be grouped in an atomic transaction (all or nothing). This works within the same namespace.

// Example: move a message from queue A to queue B in a transaction
var receiverA = client.CreateReceiver("queueA");
var senderB = client.CreateSender("queueB");

ServiceBusReceivedMessage msgA = await receiverA.ReceiveMessageAsync();

using ServiceBusTransactionContext txContext = await client.CreateTransactionAsync();

await senderB.SendMessageAsync(new ServiceBusMessage(msgA.Body), txContext);
await receiverA.CompleteMessageAsync(msgA, txContext);

await txContext.CommitAsync(); // or RollbackAsync() on error

Module 6 – Azure Event Grid: Deep Dive

System vs Custom Topics

flowchart LR
    subgraph Azure Platform
        SA[Storage Account] -->|BlobCreated\nBlobDeleted| ST[System Topic]
        RG[Resource Group] -->|ResourceWritten| ST2[System Topic]
    end
    subgraph Your application
        APP[.NET App] -->|PublishEventsAsync| CT[Custom Topic]
    end
    ST --> SUB1[Subscription → Azure Function]
    ST2 --> SUB2[Subscription → Logic App]
    CT --> SUB3[Subscription → Event Hub]
    CT --> SUB4[Subscription → Webhook]
CharacteristicSystem TopicCustom Topic
CreationAutomatic by AzureManual
SourceAzure resources (Storage, ACR, IoT Hub…)Your application
ManagementMicrosoft manages lifecycleYou manage
EndpointNot visibleURL + SAS key exposed

Event Schemas

Event Grid Schema (native Azure)

{
  "id": "550e8400-e29b-41d4-a716-446655440000",
  "eventType": "Microsoft.Storage.BlobCreated",
  "subject": "/blobServices/default/containers/images/blobs/photo.jpg",
  "eventTime": "2024-03-15T10:30:00Z",
  "dataVersion": "1.0",
  "data": {
    "api": "PutBlockList",
    "url": "https://storage.blob.core.windows.net/images/photo.jpg",
    "contentType": "image/jpeg"
  }
}
{
  "specversion": "1.0",
  "type": "com.example.order.created",
  "source": "/myapp/orders",
  "id": "A234-1234-1234",
  "time": "2024-03-15T10:30:00Z",
  "datacontenttype": "application/json",
  "data": {
    "orderId": "ORD-001",
    "amount": 149.99
  }
}
CriterionEvent Grid SchemaCloudEvents
StandardAzure proprietaryCNCF (industry)
PortabilityAzure onlyMulti-cloud
RecommendationLegacyNew development

Subscription Filters

flowchart TD
    T[Custom Topic] --> F{Subscription filters}
    F -->|subject beginsWith /orders| S1[Sub Orders]
    F -->|eventType = OrderShipped| S2[Sub Shipments]
    F -->|advanced: data.amount > 1000| S3[Sub Large orders]
    S1 --> FN1[Azure Function]
    S2 --> FN2[Logic App]
    S3 --> FN3[Webhook Alerting]
// Create a subscription with advanced filters
var eventGridClient = new EventGridManagementClient(credential);

await eventGridClient.EventSubscriptions.CreateOrUpdateAsync(
    scope: "/subscriptions/{sub}/resourceGroups/{rg}/providers/Microsoft.EventGrid/topics/myTopic",
    eventSubscriptionName: "order-subscription",
    eventSubscriptionInfo: new EventSubscription
    {
        Destination = new WebHookEventSubscriptionDestination
        {
            EndpointUrl = "https://myapp.azurewebsites.net/api/handler"
        },
        Filter = new EventSubscriptionFilter
        {
            SubjectBeginsWith = "/orders/",
            SubjectEndsWith = ".json",
            IncludedEventTypes = new[] { "OrderCreated", "OrderUpdated" },
            AdvancedFilters = new List<AdvancedFilter>
            {
                new NumberGreaterThanAdvancedFilter
                {
                    Key = "data.amount",
                    Value = 1000
                }
            }
        },
        RetryPolicy = new RetryPolicy
        {
            MaxDeliveryAttempts = 30,
            EventTimeToLiveInMinutes = 1440 // 24h
        }
    });

Publishing Events to a Custom Topic (C#)

// SDK Azure.Messaging.EventGrid
var topicEndpoint = new Uri("https://mytopic.eastus-1.eventgrid.azure.net/api/events");
var credential = new AzureKeyCredential("<topic-key>");
var client = new EventGridPublisherClient(topicEndpoint, credential);

// CloudEvents event
var cloudEvent = new CloudEvent(
    source: "/myapp/orders",
    type: "com.example.order.created",
    jsonSerializableData: new { OrderId = "ORD-001", Amount = 149.99 })
{
    Id = Guid.NewGuid().ToString(),
    Time = DateTimeOffset.UtcNow
};

await client.SendEventAsync(cloudEvent);

// Or send multiple events in a batch
await client.SendEventsAsync(new[] { cloudEvent1, cloudEvent2, cloudEvent3 });

Delivery Guarantees and Retry Policy

ParameterDefault valueRange
Max attempts301 – 30
Event TTL1440 min (24h)1 – 1440 min
Delay between attemptsExponential (10s → 10min)Not configurable
GuaranteeAt-least-once

At-least-once: an event may be delivered more than once in case of timeout. Handlers must be idempotent.

Push vs Pull Delivery (Event Grid)

ModeDescriptionUse case
PushEvent Grid pushes the event to the endpoint (webhook, Function…)Minimal latency, real-time
PullConsumer polls an Event Grid namespace (Event Grid namespace + topic)Batch workloads, throughput control

Module 7 – Azure Event Hubs: Advanced

Creating a Namespace and Event Hub

# Create the namespace
az eventhubs namespace create \
  --resource-group myRG \
  --name myEHNamespace \
  --sku Standard \
  --location eastus \
  --enable-kafka true  # Kafka compatibility

# Create the Event Hub with 4 partitions
az eventhubs eventhub create \
  --resource-group myRG \
  --namespace-name myEHNamespace \
  --name myHub \
  --partition-count 4 \
  --message-retention 7

Kafka Compatibility

Event Hubs (Standard and Premium) exposes a Kafka endpoint compatible with the Kafka 1.0+ API. Existing Kafka applications can point to Event Hubs without modifying code, by only changing the broker configuration.

# Kafka → Event Hubs configuration
bootstrap.servers=myEHNamespace.servicebus.windows.net:9093
security.protocol=SASL_SSL
sasl.mechanism=PLAIN
sasl.jaas.config=org.apache.kafka.common.security.plain.PlainLoginModule required \
  username="$ConnectionString" \
  password="<connection-string>";

Partition Key Selection

flowchart LR
    E1[Event\ndeviceId=D1] -->|"hash(D1) % 4 = 0"| P0[Partition 0]
    E2[Event\ndeviceId=D2] -->|"hash(D2) % 4 = 1"| P1[Partition 1]
    E3[Event\ndeviceId=D1] -->|"hash(D1) % 4 = 0"| P0
    E4[Event\ndeviceId=D3] -->|"hash(D3) % 4 = 3"| P3[Partition 3]
    P0 --> CG1[Consumer Group A]
    P1 --> CG1
    P0 --> CG2[Consumer Group B]
// Send with partition key (guaranteed order by deviceId)
var producer = new EventHubProducerClient(connectionString, eventHubName);

var batchOptions = new CreateBatchOptions { PartitionKey = "device-001" };
using EventDataBatch batch = await producer.CreateBatchAsync(batchOptions);

batch.TryAdd(new EventData(Encoding.UTF8.GetBytes("telemetry-1")));
batch.TryAdd(new EventData(Encoding.UTF8.GetBytes("telemetry-2")));

await producer.SendAsync(batch);

Partition key selection rules:

  • Use the logical entity identifier (deviceId, userId, orderId) to guarantee order.
  • Do NOT use a very high cardinality key (e.g. random GUID per event) → loses grouping.
  • Do NOT use a fixed key → all events in a single partition → hotspot.

Consumer Groups — Consumer Isolation

flowchart TD
    EH[Event Hub\n4 partitions] --> CGA[Consumer Group A\nAnalytics Service]
    EH --> CGB[Consumer Group B\nAlerting Service]
    EH --> CGC[Consumer Group C\nArchival Service]
    CGA -->|independent checkpoint| BLOB_A[Blob Storage\ncheckpoints-analytics]
    CGB -->|independent checkpoint| BLOB_B[Blob Storage\ncheckpoints-alerting]
    CGC -->|independent checkpoint| BLOB_C[Blob Storage\ncheckpoints-archive]
// Dedicated consumer group for the analytics service
var storageClient = new BlobContainerClient(storageConnStr, "checkpoints-analytics");
await storageClient.CreateIfNotExistsAsync();

var processor = new EventProcessorClient(
    storageClient,
    consumerGroup: "analytics-group",  // Specific consumer group
    connectionString,
    eventHubName);

processor.ProcessEventAsync += async args =>
{
    var data = Encoding.UTF8.GetString(args.Data.Body.ToArray());
    var partitionId = args.Partition.PartitionId;
    var offset = args.Data.Offset;

    Console.WriteLine($"[Partition {partitionId}, Offset {offset}] {data}");

    // Checkpoint every 100 messages to reduce Blob calls
    if (args.Data.SequenceNumber % 100 == 0)
        await args.UpdateCheckpointAsync();
};

processor.ProcessErrorAsync += args =>
{
    Console.Error.WriteLine($"Partition {args.PartitionId} error: {args.Exception}");
    return Task.CompletedTask;
};

await processor.StartProcessingAsync();

Capture to Azure Data Lake Storage (ADLS)

The Capture feature automatically writes events to Azure Blob Storage or ADLS Gen2 in Avro format.

# Enable Capture on an Event Hub
az eventhubs eventhub update \
  --resource-group myRG \
  --namespace-name myEHNamespace \
  --name myHub \
  --enable-capture true \
  --capture-interval 300 \
  --capture-size-limit 314572800 \
  --destination-name EventHubArchive.AzureBlockBlob \
  --storage-account /subscriptions/{sub}/resourceGroups/{rg}/providers/Microsoft.Storage/storageAccounts/myStorage \
  --blob-container captures \
  --archive-name-format "{Namespace}/{EventHub}/{PartitionId}/{Year}/{Month}/{Day}/{Hour}/{Minute}/{Second}"

Schema Registry with Avro

The Event Hubs Schema Registry allows versioning and validating Avro/JSON event schemas.

// Serialize with the Schema Registry
var schemaRegistryClient = new SchemaRegistryClient(
    "myEHNamespace.servicebus.windows.net", new DefaultAzureCredential());

var serializer = new SchemaRegistryAvroSerializer(
    schemaRegistryClient,
    groupName: "mySchemaGroup",
    new SchemaRegistryAvroSerializerOptions { AutoRegisterSchemas = true });

var telemetry = new TelemetryData { DeviceId = "D1", Temperature = 23.5f };
EventData eventData = await serializer.SerializeAsync<EventData, TelemetryData>(telemetry);

await producer.SendAsync(new[] { eventData });

Module 8 – Azure Queue Storage: Deep Dive

Service Bus vs Queue Storage Comparison (detailed)

FeatureAzure Queue StorageAzure Service Bus
ProtocolHTTPS RESTAMQP 1.0, HTTPS
Max message size64 KB256 KB (Standard), 100 MB (Premium)
Max retention7 days14 days
Delivery orderingApproximate (best-effort)Guaranteed FIFO with sessions
Duplicate detection
Dead-letter❌ (manual poison messages)✅ automatic
Topics / Pub-sub
Sessions
Transactions
Max messagesUnlimited (Azure storage)Configurable (namespace quota)
Visibility timeout✅ (30s default)✅ (lock duration, 60s default)
CostVery lowModerate to high

TTL (Time-To-Live) and Visibility Timeout

  • TTL: maximum duration a message stays in the queue (default: 7 days, max: 7 days).
  • Visibility Timeout: when a consumer retrieves a message, it becomes invisible to others for this delay. If not deleted within that delay → becomes visible again (automatic redelivery).
sequenceDiagram
    participant P as Producer
    participant Q as Queue Storage
    participant C as Consumer

    P->>Q: SendMessage (TTL = 1h)
    C->>Q: ReceiveMessages (visibilityTimeout=30s)
    Q-->>C: message + PopReceipt
    Note over Q: Message invisible for 30s
    C->>C: Processing
    C->>Q: DeleteMessage(messageId, popReceipt) ✅
    Note over Q: Message permanently deleted

    alt If timeout exceeded without DeleteMessage
        Q->>Q: Becomes visible again → redelivery
    end

Poison Message Handling

Azure Queue Storage does not have a native DLQ. Standard pattern:

  1. The message is redelivered until dequeueCount is high.
  2. Read DequeueCount → if > threshold (e.g. 5), move to a separate poison queue.
var queueClient = new QueueClient(connectionString, "myqueue");
var poisonQueue = new QueueClient(connectionString, "myqueue-poison");
await poisonQueue.CreateIfNotExistsAsync();

QueueMessage[] messages = await queueClient.ReceiveMessagesAsync(
    maxMessages: 10,
    visibilityTimeout: TimeSpan.FromSeconds(30));

foreach (var msg in messages)
{
    if (msg.DequeueCount > 5)
    {
        // Move to poison queue
        await poisonQueue.SendMessageAsync(msg.MessageText);
        await queueClient.DeleteMessageAsync(msg.MessageId, msg.PopReceipt);
        Console.WriteLine($"Poison message moved: {msg.MessageId}");
        continue;
    }

    try
    {
        // Normal processing
        ProcessMessage(msg.MessageText);
        await queueClient.DeleteMessageAsync(msg.MessageId, msg.PopReceipt);
    }
    catch
    {
        // Do not delete → becomes visible again after visibilityTimeout
        Console.WriteLine("Processing failed, message will be redelivered");
    }
}

Peek vs Dequeue

OperationSDK MethodVisibility effectAuto-deletion
PeekPeekMessagesAsyncNone
Receive (Dequeue)ReceiveMessagesAsyncInvisible during timeout❌ (manual)
DeleteDeleteMessageAsyncDeleted
// Peek — read without removing (non-destructive read)
PeekedMessage[] peeked = await queueClient.PeekMessagesAsync(maxMessages: 5);
foreach (var msg in peeked)
    Console.WriteLine($"[Peek] {msg.MessageText}");

// Dequeue — read and make invisible
QueueMessage[] received = await queueClient.ReceiveMessagesAsync(
    maxMessages: 10,
    visibilityTimeout: TimeSpan.FromMinutes(2));

foreach (var msg in received)
{
    Console.WriteLine($"[Dequeue] {msg.MessageText} (Attempt #{msg.DequeueCount})");
    // Processing...
    await queueClient.DeleteMessageAsync(msg.MessageId, msg.PopReceipt);
}

Module 9 – Azure Notification Hubs

Architecture and Push Notification Flow

flowchart LR
    BACKEND[Backend App\n.NET / Node] -->|Send notification| NH[Azure\nNotification Hub]
    NH -->|APNs protocol| IOS[📱 iOS App]
    NH -->|FCM protocol| ANDROID[📱 Android App]
    NH -->|WNS protocol| WIN[🖥️ Windows App]

    IOS -->|Register token| NH
    ANDROID -->|Register token| NH
    WIN -->|Register token| NH

PNS (Platform Notification Services)

PlatformPNSProtocol
iOSAPNs (Apple Push Notification service)HTTP/2
AndroidFCM (Firebase Cloud Messaging)HTTP
WindowsWNS (Windows Push Notification Services)HTTPS

Tags — Precise Targeting

Tags allow segmenting recipients without maintaining a list on the server side.

// Client-side registration with tags (Notification Hubs SDK)
var hub = new NotificationHub("myHub", "myConnectionString");

// iOS registration with user and device tags
string registrationId = await hub.RegisterNativeAsync(
    deviceToken: "<APNs-device-token>",
    tags: new[] { "userId:user-42", "category:sports", "region:FR" });
// Targeted send from the backend — tag expression
var hub = new NotificationHubClient.CreateClientFromConnectionString(
    connectionString, hubName);

// Send to all French users interested in sports
string tagExpression = "category:sports && region:FR";

var notification = new AppleNotification("{\"aps\":{\"alert\":\"New match tonight!\"}}");
await hub.SendNotificationAsync(notification, tagExpression);

Templates — Cross-platform Notifications

Templates allow sending a single backend call for all platforms, letting Notification Hubs adapt the format.

// iOS template registration
string iosTemplate = "{\"aps\":{\"alert\":\"$(message)\"}}";
await hub.RegisterTemplateAsync(deviceToken, iosTemplate, "genericTemplate",
    tags: new[] { "userId:user-42" });

// Android template registration
string androidTemplate = "{\"data\":{\"message\":\"$(message)\"}}";
await hub.RegisterTemplateAsync(fcmToken, androidTemplate, "genericTemplate",
    tags: new[] { "userId:user-42" });

// Send from the backend — a single call for all platforms
var templateParams = new Dictionary<string, string> { ["message"] = "Your order has shipped!" };
await hub.SendTemplateNotificationAsync(templateParams, "userId:user-42");

Backendless Registration

Instead of the backend managing registrations, the client registers directly via a temporary SAS token provided by the backend.

// Backend: generate a limited access token (listen only)
string sasToken = SharedAccessSignatureTokenProvider.GetSharedAccessSignature(
    keyName: "DefaultListenSharedAccessSignature",
    sharedAccessKey: "<listen-key>",
    targetUri: "https://<namespace>.servicebus.windows.net/<hubName>",
    tokenTimeToLive: TimeSpan.FromHours(1));

// Client: use the token to register directly
var client = new NotificationHubClient(sasToken, hubEndpoint);
await client.RegisterNativeAsync(deviceToken);

Debugging Push Notifications

ProblemCheck
Notification not receivedCheck PNS credentials (APNs cert expiration, FCM key)
Incorrectly targeted tagUse Test Send in the Azure portal with tag expression
Expired registrationPNS tokens expire → re-register regularly
Quota exceededCheck tier (Free = 1M push/month, Basic = 10M, Standard = unlimited)

Module 10 – APIM: Advanced Policies

Cache — cache-lookup / cache-store

<policies>
  <inbound>
    <cache-lookup vary-by-developer="false" vary-by-developer-groups="false">
      <vary-by-header>Accept</vary-by-header>
      <vary-by-query-parameter>version</vary-by-query-parameter>
    </cache-lookup>
    <base />
  </inbound>
  <outbound>
    <cache-store duration="3600" /> <!-- Cache 1 hour -->
    <base />
  </outbound>
</policies>

Send-Request — Outbound HTTP Call from a Policy

<inbound>
  <send-request mode="new" response-variable-name="tokenResponse" timeout="20" ignore-error="false">
    <set-url>https://login.microsoftonline.com/{tenantId}/oauth2/token</set-url>
    <set-method>POST</set-method>
    <set-header name="Content-Type" exists-action="override">
      <value>application/x-www-form-urlencoded</value>
    </set-header>
    <set-body>grant_type=client_credentials&client_id={{clientId}}&client_secret={{clientSecret}}&resource=https://graph.microsoft.com/</set-body>
  </send-request>
  <set-header name="Authorization" exists-action="override">
    <value>@("Bearer " + ((IResponse)context.Variables["tokenResponse"]).Body.As<JObject>()["access_token"].ToString())</value>
  </set-header>
  <base />
</inbound>

Choose — Conditional Logic

<inbound>
  <choose>
    <when condition="@(context.Request.Headers.GetValueOrDefault("X-Client-Version", "0").CompareTo("2.0") >= 0)">
      <!-- v2+ clients → new backend -->
      <set-backend-service base-url="https://api-v2.contoso.com" />
    </when>
    <when condition="@(context.User.Groups.Contains("premium"))">
      <set-header name="X-Priority" exists-action="override">
        <value>high</value>
      </set-header>
    </when>
    <otherwise>
      <!-- Legacy clients → old backend -->
      <set-backend-service base-url="https://api-v1.contoso.com" />
    </otherwise>
  </choose>
  <base />
</inbound>

Retry — Automatic Resilience

<backend>
  <retry condition="@(context.Response.StatusCode >= 500)" count="3" interval="2" max-interval="10" delta="1" first-fast-retry="true">
    <forward-request />
  </retry>
</backend>

Mock-Response — Simulating a Backend

<inbound>
  <mock-response status-code="200" content-type="application/json">
    <representations>
      <representation content-type="application/json">
        { "status": "ok", "data": [{"id": 1, "name": "Mock Item"}] }
      </representation>
    </representations>
  </mock-response>
</inbound>

Set-Variable and Trace

<inbound>
  <!-- Store a value in a context variable -->
  <set-variable name="clientRegion" value="@(context.Request.Headers.GetValueOrDefault("X-Region", "unknown"))" />

  <!-- Trace a value in the journal (visible in Test → Trace) -->
  <trace source="CustomPolicy" severity="information">
    <message>@($"Request from region: {(string)context.Variables["clientRegion"]}")</message>
    <metadata name="requestId" value="@(context.RequestId)" />
  </trace>
  <base />
</inbound>

Named Values and Backends

Named Values: key-value store in APIM. Referenced in policies with {{valueName}}. Can be secrets (stored in Key Vault).

<!-- Use a Named Value in a policy -->
<set-header name="X-API-Key" exists-action="override">
  <value>{{BackendApiKey}}</value> <!-- References a Named Value -->
</set-header>

Backends: named entities representing a backend service, with circuit breaker and load balancing.

<backend>
  <set-backend-service backend-id="my-backend-pool" /> <!-- References an APIM Backend -->
</backend>

Policy Expressions — C# Subset

Policy expressions use a subset of C# executed in the context context:

<!-- Common expression examples -->

<!-- Read a header -->
@(context.Request.Headers.GetValueOrDefault("X-Forwarded-For", "0.0.0.0"))

<!-- Read a JWT claim -->
@(context.Request.Headers["Authorization"].First().Split(' ')[1])

<!-- Encode to Base64 -->
@(Convert.ToBase64String(Encoding.UTF8.GetBytes("user:password")))

<!-- Calculate an MD5 hash -->
@{
    var body = context.Request.Body.As<string>(preserveContent: true);
    return BitConverter.ToString(MD5.Create().ComputeHash(Encoding.UTF8.GetBytes(body))).Replace("-","").ToLower();
}

<!-- Check an Entra ID role in the JWT token -->
@(context.Request.Headers.ContainsKey("Authorization") &&
  new Jwt(context.Request.Headers["Authorization"].First().Replace("Bearer ", ""))
    .Claims["roles"].Contains("API.Write"))

Module 11 – Azure Relay

WCF Relay vs Hybrid Connections

Azure Relay allows exposing on-premises services on the Internet without opening inbound ports or configuring a VPN.

FeatureWCF RelayHybrid Connections
ProtocolWCF / SOAPWebSocket (HTTPS)
Language.NET (WCF only)Any language
StatusLegacyRecommended
Use caseMigration of existing WCF servicesNew development, Node.js, Java

Hybrid Connections Architecture

sequenceDiagram
    participant C as Client (Internet)
    participant R as Azure Relay\nHybrid Connection
    participant L as Listener\n(On-Premises)

    L->>R: Outbound WebSocket connection (outbound only)
    Note over L,R: Persistent tunnel established from on-prem
    C->>R: Inbound HTTPS request
    R-->>L: Forward via WebSocket tunnel
    L->>L: Local processing
    L-->>R: Response
    R-->>C: HTTP response

The on-premises server initiates the outbound connection to Azure Relay (port 443 HTTPS outbound only — no inbound ports to open).

SDK Setup — Hybrid Connections

// Listener side on-premises (behind the firewall)
var tokenProvider = TokenProvider.CreateSharedAccessSignatureTokenProvider(
    keyName: "RootManageSharedAccessKey",
    sharedAccessKey: "<key>");

var relayNamespace = "myrelay.servicebus.windows.net";
var connectionName = "myconnection";

var listener = new HybridConnectionListener(
    new Uri($"sb://{relayNamespace}/{connectionName}"),
    tokenProvider);

listener.RequestHandler = async (context) =>
{
    using var reader = new StreamReader(context.Request.InputStream);
    string body = await reader.ReadToEndAsync();
    Console.WriteLine($"Request received: {body}");

    context.Response.StatusCode = HttpStatusCode.OK;
    using var writer = new StreamWriter(context.Response.OutputStream);
    await writer.WriteLineAsync("Response from the on-premises server!");
    await context.Response.CloseAsync();
};

await listener.OpenAsync();
Console.WriteLine("Relay listener started. Press any key to stop.");
Console.ReadKey();
await listener.CloseAsync();
// Client side (Internet)
var sender = new HybridConnectionClient(
    new Uri($"sb://{relayNamespace}/{connectionName}"),
    tokenProvider);

var stream = await sender.CreateConnectionAsync();
using var writer = new StreamWriter(stream) { AutoFlush = true };
using var reader = new StreamReader(stream);

await writer.WriteLineAsync("Hello from the Internet!");
string response = await reader.ReadLineAsync();
Console.WriteLine($"Response: {response}");

Azure Relay Use Cases

  • Exposing an internal API on the Internet for partners without VPN.
  • Debugging a local API from a cloud environment (enterprise alternative to ngrok).
  • Progressively migrating on-premises WCF services to Azure.
  • Connecting SaaS applications to internal databases or services.

Module 12 – Messaging Patterns

Pattern 1: Competing Consumers

Multiple consumer instances read from the same queue → horizontal scalability.

flowchart LR
    P[Producer] -->|messages| Q["(Queue)"]
    Q --> C1[Consumer 1\nInstance A]
    Q --> C2[Consumer 2\nInstance B]
    Q --> C3[Consumer 3\nInstance C]
    C1 --> DB["(Database)"]
    C2 --> DB
    C3 --> DB

Key rules:

  • Each message is processed by only one consumer (automatic locking by Service Bus).
  • Ideal for Azure Functions or Container Apps workers in scale-out.
  • The lock duration must be sufficient for processing (otherwise the message is redelivered).

Pattern 2: Publisher / Subscriber

flowchart LR
    PUB[Publisher] -->|message| T["(Topic)"]
    T --> S1["(Sub: Notifications\ncustomer:* filter)"]
    T --> S2["(Sub: Analytics\nall messages)"]
    T --> S3["(Sub: Audit\nall messages)"]
    S1 --> SVC1[Notifications Service\nEmail / SMS]
    S2 --> SVC2[Analytics Service\nPower BI]
    S3 --> SVC3[Audit Service\nLog Storage]

Pattern 3: Request / Reply

Simulates synchronous communication via asynchronous messaging with ReplyTo and CorrelationId.

sequenceDiagram
    participant REQ as Requesting Service
    participant Q as Request Queue
    participant REP_Q as Reply Queue
    participant SVC as Target Service

    REQ->>Q: Send(msg, ReplyTo="reply-queue", CorrelationId="abc-123")
    SVC->>Q: Receive
    SVC->>SVC: Processing
    SVC->>REP_Q: Send(response, CorrelationId="abc-123")
    REQ->>REP_Q: Receive (filter by CorrelationId="abc-123")
// Requester: send and wait for the response
var requestMsg = new ServiceBusMessage("Calculate price for product 42")
{
    ReplyTo = "reply-queue",
    CorrelationId = Guid.NewGuid().ToString(),
    MessageId = Guid.NewGuid().ToString()
};

await requestSender.SendMessageAsync(requestMsg);

// Wait for response with matching CorrelationId
var replyReceiver = client.CreateReceiver("reply-queue");
ServiceBusReceivedMessage reply;
do
{
    reply = await replyReceiver.ReceiveMessageAsync(TimeSpan.FromSeconds(30));
} while (reply?.CorrelationId != requestMsg.CorrelationId);

Console.WriteLine($"Response received: {reply.Body}");
await replyReceiver.CompleteMessageAsync(reply);

Pattern 4: Dead-Letter Pattern

flowchart TD
    Q["(Active Queue)"] -->|Delivery N| C[Consumer]
    C -->|Business error| DLQ["(Dead-Letter Queue)"]
    C -->|Success| DONE[✅ Message processed]
    DLQ --> ALERT[🔔 Azure Monitor Alert]
    DLQ --> REPLAY[🔁 Replay Service\nafter fix]
    REPLAY -->|Corrected message| Q

Pattern 5: Message Routing

flowchart LR
    SRC[Source Events] --> T["(Service Bus Topic)"]
    T -->|SQL: OrderType='Standard'| STANDARD["(Sub Standard)"]
    T -->|SQL: OrderType='Express'| EXPRESS["(Sub Express)"]
    T -->|SQL: OrderType='International'| INTL["(Sub International)"]
    STANDARD --> WF1[Standard Workflow\n2-3 days]
    EXPRESS --> WF2[Express Workflow\n24h]
    INTL --> WF3[International Workflow\nCustoms + delivery]

Module 13 – Resilience Patterns

Retry with Exponential Backoff and Jitter

flowchart TD
    REQ[Request] --> SVC{Service\navailable?}
    SVC -->|✅ Success| DONE[OK Response]
    SVC -->|❌ 5xx Error| CALC[Calculate delay\nbaseDelay × 2^attempt + jitter]
    CALC --> WAIT[Wait]
    WAIT --> COUNT{Attempts\n< max?}
    COUNT -->|Yes| SVC
    COUNT -->|No| FAIL[💥 Final failure\nDead-letter / Exception]
// Manual retry with exponential backoff + jitter
public async Task<T> RetryWithBackoff<T>(Func<Task<T>> operation,
    int maxAttempts = 5, int baseDelayMs = 100)
{
    var rng = new Random();
    for (int attempt = 1; attempt <= maxAttempts; attempt++)
    {
        try
        {
            return await operation();
        }
        catch (Exception ex) when (attempt < maxAttempts)
        {
            // Exponential backoff: 100ms, 200ms, 400ms, 800ms...
            int delay = (int)(baseDelayMs * Math.Pow(2, attempt - 1));
            // Jitter: add randomness to avoid thundering herds
            int jitter = rng.Next(0, delay / 2);
            await Task.Delay(delay + jitter);
            Console.WriteLine($"Attempt {attempt} failed ({ex.Message}). Retry in {delay + jitter}ms");
        }
    }
    return await operation(); // Last attempt — let the exception propagate
}

Circuit Breaker with Polly

stateDiagram-v2
    [*] --> Closed : Start
    Closed --> Open : N failures in the time window
    Open --> HalfOpen : After pause duration
    HalfOpen --> Closed : Test request succeeds
    HalfOpen --> Open : Test request fails

    state Closed { [*] --> ok }
    state Open { [*] --> fail }
    state HalfOpen { [*] --> test }
// Polly v8 — Circuit Breaker + Retry combined
using Polly;
using Polly.CircuitBreaker;
using Polly.Retry;

var pipeline = new ResiliencePipelineBuilder()
    .AddRetry(new RetryStrategyOptions
    {
        MaxRetryAttempts = 3,
        Delay = TimeSpan.FromMilliseconds(200),
        BackoffType = DelayBackoffType.Exponential,
        UseJitter = true,
        ShouldHandle = new PredicateBuilder().Handle<HttpRequestException>()
    })
    .AddCircuitBreaker(new CircuitBreakerStrategyOptions
    {
        FailureRatio = 0.5,            // Opens if 50% of calls fail
        SamplingDuration = TimeSpan.FromSeconds(30),
        MinimumThroughput = 10,        // Minimum 10 calls before opening
        BreakDuration = TimeSpan.FromSeconds(60), // Stays open for 60s
        ShouldHandle = new PredicateBuilder().Handle<HttpRequestException>()
    })
    .Build();

// Usage
await pipeline.ExecuteAsync(async cancellationToken =>
{
    var response = await httpClient.GetAsync("/api/orders", cancellationToken);
    response.EnsureSuccessStatusCode();
});

Bulkhead Isolation

Limits the number of concurrent requests to a service to prevent overload from contaminating the entire system.

// Polly v8 — Rate Limiter (equivalent to bulkhead)
var bulkhead = new ResiliencePipelineBuilder()
    .AddConcurrencyLimiter(
        permitLimit: 10,   // Max 10 simultaneous calls
        queueLimit: 5)     // Max 5 waiting
    .Build();

Timeout Handling

var pipeline = new ResiliencePipelineBuilder()
    .AddTimeout(new TimeoutStrategyOptions
    {
        Timeout = TimeSpan.FromSeconds(10),
        OnTimeout = args =>
        {
            Console.WriteLine($"Timeout after {args.Timeout.TotalSeconds}s");
            return default;
        }
    })
    .Build();

Saga Pattern — Distributed Transactions

The Saga Pattern manages distributed transactions via a series of compensatable local transactions. On failure, compensatory actions roll back the previous steps.

sequenceDiagram
    participant O as Orchestrator
    participant CMD as Orders Service
    participant INV as Inventory Service
    participant PAY as Payment Service
    participant SHIP as Shipping Service

    O->>CMD: Create order
    CMD-->>O: Order created ✅
    O->>INV: Reserve stock
    INV-->>O: Stock reserved ✅
    O->>PAY: Charge payment
    PAY-->>O: ❌ Payment declined

    Note over O: Triggering compensations
    O->>INV: Release stock (compensation)
    O->>CMD: Cancel order (compensation)
    O-->>O: Saga ended in failure ✅ clean
// Orchestrated Saga via Azure Service Bus
public class OrderSagaOrchestrator
{
    private readonly ServiceBusClient _client;

    public async Task ExecuteAsync(OrderCreatedEvent orderEvent)
    {
        var sagaId = Guid.NewGuid().ToString();
        var compensations = new Stack<Func<Task>>();

        try
        {
            // Step 1: Reserve stock
            await SendCommand("inventory-commands", new ReserveStockCommand
            {
                SagaId = sagaId, OrderId = orderEvent.OrderId, Items = orderEvent.Items
            });
            compensations.Push(() => SendCommand("inventory-commands",
                new ReleaseStockCommand { SagaId = sagaId, OrderId = orderEvent.OrderId }));

            // Step 2: Charge payment
            await SendCommand("payment-commands", new ChargePaymentCommand
            {
                SagaId = sagaId, OrderId = orderEvent.OrderId, Amount = orderEvent.Amount
            });
            compensations.Push(() => SendCommand("payment-commands",
                new RefundPaymentCommand { SagaId = sagaId, OrderId = orderEvent.OrderId }));

            // Step 3: Create shipment
            await SendCommand("shipping-commands", new CreateShipmentCommand
            {
                SagaId = sagaId, OrderId = orderEvent.OrderId
            });
        }
        catch (Exception ex)
        {
            Console.WriteLine($"Saga {sagaId} failed: {ex.Message}. Compensating...");
            // Unwind compensations in reverse order
            while (compensations.Count > 0)
                await compensations.Pop()();
        }
    }

    private async Task SendCommand(string queue, object command)
    {
        var sender = _client.CreateSender(queue);
        var msg = new ServiceBusMessage(JsonSerializer.Serialize(command))
        {
            ContentType = "application/json"
        };
        await sender.SendMessageAsync(msg);
    }
}

Module 14 – AZ-204 Review Questions

Q1 — Service Bus: Sessions

Question: An order processing application must guarantee that all steps of an order (payment, stock allocation, shipping) are processed in order for each individual order. Which Azure Service Bus feature should you use?

  • A. Duplicate Detection
  • B. Dead-Letter Queue
  • C. Sessions
  • D. Message Deferral

Explanation: Sessions allow grouping messages by SessionId (e.g. the order ID) and guarantee that messages in the same session are processed in FIFO order by a single consumer at a time.


Q2 — Event Grid: Schema

Question: You are developing a new multi-cloud event-driven solution. Which Azure Event Grid event schema do you recommend to maximize portability and compatibility with industry standards?

  • A. Event Grid Schema (native)
  • B. CloudEvents Schema (CNCF)
  • C. JSON Schema
  • D. Avro Schema

Explanation: CloudEvents is a CNCF (Cloud Native Computing Foundation) standard supported by multiple cloud providers. For new development, CloudEvents is Microsoft’s recommendation to maximize portability.


Q3 — Event Hubs: Consumer Groups

Question: You have an Event Hub ingesting IoT data. Three different services (analytics, alerting, archiving) must all process all events independently. How do you implement this?

  • A. Create 3 separate Event Hubs and send messages to each
  • B. Create 3 separate Consumer Groups on the same Event Hub
  • C. Partition the Event Hub into 3 partitions and assign one to each service
  • D. Use subscription filters for each service

Explanation: Consumer Groups provide independent views of the stream. Each group maintains its own checkpoint, allowing the 3 services to read all events at their own pace without blocking each other.


Q4 — Queue Storage: Poison Messages

Question: An Azure Function reads messages from an Azure Storage Queue. Some messages consistently cause exceptions. After how many deliveries is a message considered poison in Azure Queue Storage, and where should it be moved?

  • A. After 5 deliveries, to an automatic dead-letter queue
  • B. After 10 deliveries, to an automatic dead-letter queue
  • C. After N deliveries (configurable), manually to a separate queue
  • D. Never automatically, it must be deleted manually

Explanation: Azure Queue Storage does not have a native DLQ. The DequeueCount property increments with each read. The developer must implement poison message detection logic (e.g. DequeueCount > 5) and manually move them to a separate poison queue.


Q5 — APIM: Policy Scope

Question: You want to apply a rate limit to all APIs for a specific group of consumers, without applying it to all APIs in the APIM instance or to each API individually. Which policy scope do you use?

  • A. All APIs
  • B. Product
  • C. API
  • D. Operation

Explanation: The Product scope applies to all APIs grouped in that product. Clients subscribe to a Product, and the Product’s policies apply to all their requests to the Product’s APIs. This is the intermediate level between “All APIs” and a specific API.


Q6 — Notification Hubs: Tags

Question: You are using Azure Notification Hubs to send push notifications. You want to send a notification only to French users interested in the “sports” category. Which feature do you use?

  • A. Templates
  • B. Scheduled notifications
  • C. Tags and tag expressions
  • D. Consumer Groups

Explanation: Tags allow segmenting registered devices. A tag expression like region:FR && category:sports targets only devices with both tags. This avoids maintaining a list of devices on the backend.


Q7 — Azure Relay

Question: A company has a REST API hosted on an on-premises server behind a strict firewall. It needs to expose this API to external partners via the Internet without opening inbound ports or configuring a VPN. Which Azure solution do you choose?

  • A. Azure VPN Gateway
  • B. Azure ExpressRoute
  • C. Azure API Management with direct on-premises backend
  • D. Azure Relay — Hybrid Connections

Explanation: Azure Relay Hybrid Connections allows exposing on-premises services on the Internet via an outbound WebSocket connection established from the internal server. No inbound ports are required. This is the ideal solution for this scenario without VPN.


Q8 — Service Bus: Duplicate Detection

Question: A payment service sends Service Bus messages. In case of network timeout, the service may resend the same message. How do you prevent double processing of a payment?

  • A. Use Service Bus sessions
  • B. Enable Duplicate Detection and use a stable MessageId
  • C. Use a Dead-Letter Queue to filter duplicates
  • D. Implement a distributed lock with Azure Redis Cache

Explanation: Duplicate Detection stores received MessageId values within a configurable time window. If a message with the same MessageId is resent within this window, Service Bus silently rejects it. The service must use a deterministic MessageId (e.g. transaction ID).


Q9 — Event Grid: Delivery Guarantee

Question: An Azure Function handler receives Event Grid events. Due to an update, the Function is unavailable for 2 minutes. What happens to events sent during this period?

  • A. Events are permanently lost
  • B. Events are redirected to another region
  • C. Event Grid retries delivery with exponential backoff until the event TTL
  • D. Events are paused until manual restart

Explanation: Event Grid guarantees at-least-once delivery with a configurable retry policy (up to 30 attempts, 24h TTL by default). During the 2-minute unavailability, Event Grid retries delivery with increasing delays until the Function is available again.


Q10 — Resilience: Circuit Breaker

Question: Your .NET application calls an external microservice that has intermittent failures. You want to prevent cumulative timeouts from degrading the overall application performance. Which pattern and .NET library do you recommend?

  • A. Retry with exponential backoff only (Polly)
  • B. Bulkhead isolation (SemaphoreSlim)
  • C. Circuit Breaker (Polly)
  • D. Message Queue for all requests

Explanation: The Circuit Breaker (implemented with Polly) quickly cuts calls to a failing service (Open state) after an error threshold, avoiding costly timeouts. After a pause period, it moves to Half-Open state to test if the service has recovered. This is the appropriate pattern to protect the application from cascading failures.


Q11 — Event Hubs: Checkpoints

Question: You are using EventProcessorClient to consume an Event Hub. The application restarts after a failure. How do you ensure that events are not reprocessed from the beginning?

  • A. Event Hubs automatically deletes read events
  • B. The consumer group keeps the read position in memory
  • C. Checkpoints stored in Azure Blob Storage preserve the read position per partition
  • D. You must manually configure a start offset in the code

Explanation: EventProcessorClient calls UpdateCheckpointAsync() to save the read position (offset) of each partition in Azure Blob Storage. On restart, the processor resumes from the last saved checkpoint, avoiding reprocessing.


Q12 — Saga Pattern

Question: In a microservices architecture, an order transaction involves 3 services (inventory, payment, shipping). If payment fails after stock has been reserved, how do you guarantee data consistency?

  • A. Use a distributed 2PC (Two-Phase Commit) transaction
  • B. Manually cancel each service on error in the orchestrator code
  • C. Implement the Saga Pattern with compensatory actions
  • D. Use Azure SQL Distributed Transactions

Explanation: The Saga Pattern decomposes a distributed transaction into a sequence of local transactions. If a step fails, compensatory transactions are executed in reverse order to undo the effects of previous steps. This is the recommended solution for microservices architectures as it avoids the performance and availability issues of classic distributed transactions (2PC).


Summary — Service Selection Table

ScenarioRecommended serviceReason
IoT telemetry, millions of events/sEvent HubsHigh performance, partitions, retention
Azure state change notificationEvent Grid (System Topic)Integrated with Azure resources
Workflow with entity-ordered stepsService Bus + SessionsFIFO guaranteed by SessionId
Pub-sub with property-based filteringService Bus Topics + FiltersSQL/Correlation filters
Simple queue, low costAzure Queue StorageSimple REST, minimal price
Mobile push iOS/Android/WindowsNotification HubsMulti-platform PNS abstraction
Expose on-prem API without VPNAzure Relay Hybrid ConnectionsOutbound connection only
Secure and observable API gatewayAPIMPolicies, rate limit, cache, portal
Distributed microservice transactionsSaga Pattern + Service BusCompensation without 2PC
HTTP call resiliencePolly + Circuit BreakerFail-fast, cascade protection

Search Terms

az-204 · connected · distributed · systems · azure · developer · microsoft · event · hubs · service · bus · grid · pattern · storage · messaging · policy · queue · apim · architecture · deep · dive · notification · queues · relay

Interested in this course?

Contact us to book it or get a custom training plan for your team.