Today, when a passenger clicks "Confirm booking" in app-contoso-reservas-pro, six things happen one after another inside that single HTTP request: the payment gateway is charged, availability is updated, the boarding pass issue is enqueued, the miles are credited, the billing system is notified and the confirmation email is sent. All synchronous, all inline. The consequence is brutal and Contoso Airlines has already lived through it: if the miles service is slow, the purchase fails. A peripheral component takes down the company's main revenue stream.

This lesson solves that problem at the root through decoupling: components stop calling each other and start exchanging messages and events through an intermediary infrastructure. You will see the three services Azure offers for this, when to use each, and the design consequences this model brings, which are not free.

Contents

  1. Why decouple
  2. Message versus event
  3. The three services compared
  4. Azure Service Bus
  5. Azure Event Grid
  6. Azure Event Hubs
  7. Contoso's complete flow
  8. At-least-once delivery, ordering and idempotency
  9. The cost of each service
  10. Common Mistakes and Tips
  11. Exercises
  12. Conclusion

  1. Why decouple

Chaining six synchronous calls has three cumulative flaws. Availability multiplies: if each component is at 99.9%, the chain of six drops to 99.4%, more than four hours of downtime a month. Latency adds up: the passenger waits for all of them. And coupling freezes things: adding a seventh step forces you to touch and redeploy the purchase code.

With an intermediary, the purchase does the bare essentials — take the payment and reserve the seat — and publishes a fact. The others react at their own pace. If the miles service is down, its message waits in the queue and is processed when it comes back; the passenger never notices. That is decoupling: in time, in space and in availability.

  1. Message versus event

This is the distinction almost everybody confuses, and choosing the right service depends on it.

Message Event
What it expresses An instruction: do this A fact: this has happened
The sender's intent Expects somebody to process it Neither knows nor cares who reacts
Content The full payload that is needed Lightweight: what happened, when and about what
Coupling The sender knows the recipient None
If nobody consumes it It is an error: it stays in the queue It is normal: it is discarded
Example at Contoso "Issue the boarding pass for locator XR7742" "Booking XR7742 has been confirmed"

The mnemonic: a message has a recipient and an intent; an event has a sender and a past tense. The verb form gives it away — EmitirTarjeta versus ReservaConfirmada — and that is not cosmetic: the message goes to Service Bus because somebody has to process it and it cannot be lost; the event goes to Event Grid because zero, one or seven subscribers may be interested and the sender is unaffected either way.

  1. The three services compared

Service Bus Event Grid Event Hubs
What for Reliable enterprise messaging Distributing discrete events Massive ingestion of telemetry
Unit Message (up to 256 KB / 100 MB) Event (64 KB) Record (small, in a stream)
Typical volume Thousands per second Millions per second Millions per second, sustained
Delivery model The consumer pulls The service pushes The consumer reads by offset
Ordering Yes, with sessions Not guaranteed Yes, within the partition
Retries and failed messages Complete Yes, with retries and a queue The consumer manages its position
Retention Until consumed 24 hours of retries A time window (1-7 days or more)
Case at Contoso reservas-confirmadas ReservaConfirmada, new blob Check-in desk telemetry

And do not forget the Azure Storage queues from 02-04: simple, very cheap, with no topics, no sessions and no transactions, with 64 KB messages and seven days of retention. They are enough when there is a single consumer, order does not matter and you need neither filters nor transactional delivery. cola-emision-tarjetas is exactly that case, which is why it is still a Storage queue. The moment you need topics, sessions, duplicate detection or transactions, it is Service Bus.

  1. Azure Service Bus

az servicebus namespace create -g rg-contoso-reservas-pro -n sb-contoso-pro \
  --location westeurope --sku Premium --capacity 1 \
  --tags entorno=produccion proyecto=contoso-reservas \
         centro-coste=CC-1042 [email protected]

# A TOPIC: one publisher, several interested parties with different criteria
az servicebus topic create -g rg-contoso-reservas-pro --namespace-name sb-contoso-pro \
  -n reservas-confirmadas --max-size 5120 --enable-duplicate-detection true

az servicebus topic subscription create -g rg-contoso-reservas-pro \
  --namespace-name sb-contoso-pro --topic-name reservas-confirmadas \
  -n tarjetas --max-delivery-count 5 --dead-lettering-on-message-expiration true

# Filter: fidelizacion only wants bookings made by members
az servicebus topic subscription rule create -g rg-contoso-reservas-pro \
  --namespace-name sb-contoso-pro --topic-name reservas-confirmadas \
  --subscription-name fidelizacion -n solo-socios \
  --filter-sql-expression "esSocio = true AND importe > 0"

The pieces you need to know:

  • Queue versus topic. A queue is point to point: each message is processed by one consumer. A topic is publish and subscribe: each subscription receives its own copy. Contoso uses a topic with the tarjetas, facturacion and fidelizacion subscriptions, so that adding a fourth interested party does not touch the sender.
  • Subscription filters. SQL rules over the message properties. fidelizacion receives only members' bookings, so it never sees what does not concern it: less processing and less cost.
  • Sessions. Without them there is no guaranteed ordering. With a SessionId — the record locator, for example — all the messages in that session are processed by a single consumer and in order. That is what lets you guarantee that "booking created" is processed before "booking modified".
  • Peek-lock. The correct receive mode: the consumer locks the message without deleting it, does the work, and only then completes it. If the process dies, the lock expires and the message becomes available again. The alternative mode, receive-and-delete, loses messages on any failure.
  • Deferral. Setting aside a message that cannot be processed yet — a piece of data is missing and will arrive later — without blocking the queue, so you can retrieve it later by its sequence number.
  • Dead-letter queue. The piece that saves teams. After max-delivery-count failed attempts, the message is moved to a subqueue instead of blocking the process forever. There it is inspected, corrected and resubmitted. Without it, a poison message jams the queue indefinitely.
  • Transactions and duplicate detection. Several operations — completing one message and publishing another — can be grouped into an atomic unit within the same namespace. And with duplicate detection enabled, the service discards messages with the same MessageId for a configured time window.
Tier When
Basic Queues only. No topics and no sessions: it is hardly ever enough
Standard Topics, sessions, transactions. Shared, variable capacity
Premium Dedicated resources, predictable throughput, private endpoints. Production

Sending and consuming, with a managed identity and no connection strings:

// Publish: the fact carries properties so that the filters can act on them
var client = new ServiceBusClient("sb-contoso-pro.servicebus.windows.net",
                                  new DefaultAzureCredential());
var sender = client.CreateSender("reservas-confirmadas");

var message = new ServiceBusMessage(BinaryData.FromObjectAsJson(booking))
{
    MessageId = booking.Localizador,        // duplicate detection
    SessionId = booking.Localizador,        // ordering per booking
    ContentType = "application/json"
};
message.ApplicationProperties["esSocio"] = booking.EsSocio;   // used by the filter
message.ApplicationProperties["importe"] = booking.Importe;
await sender.SendMessageAsync(message);

// Consume with peek-lock: complete ONLY if the work finished successfully
var processor = client.CreateProcessor("reservas-confirmadas", "tarjetas",
    new ServiceBusProcessorOptions { MaxConcurrentCalls = 10, AutoCompleteMessages = false });

processor.ProcessMessageAsync += async args =>
{
    var booking = args.Message.Body.ToObjectFromJson<Booking>();
    try
    {
        await _boardingPassIssuer.IssueAsync(booking);   // idempotent (06-03)
        await args.CompleteMessageAsync(args.Message);
    }
    catch (InvalidDataException ex)
    {
        // Permanent error: retrying makes no sense, straight to the subqueue
        await args.DeadLetterMessageAsync(args.Message, "InvalidData", ex.Message);
    }
    // Any other exception: it is not completed, the lock expires and it is retried
};
await processor.StartProcessingAsync();

Note the distinction in the catch: on a permanent error it goes to the dead-letter subqueue immediately, because retrying malformed data five times only wastes time; on a transient error you let the lock expire and the message is retried on its own.

  1. Azure Event Grid

Event Grid is an event router with push-based publish/subscribe: the service pushes the event to the destination and handles the retries. There is nothing to poll.

  • System topics: Azure services themselves publish events with no configuration. A new blob in sttarjetascontosopro emits Microsoft.Storage.BlobCreated, and Contoso uses it to trigger indexing of the boarding pass just generated. The same goes for Key Vault warning about a certificate close to expiry, or ACR announcing a new image.
  • Custom topics: you publish them yourself, and that is where ReservaConfirmada goes.
  • CloudEvents schema: the CNCF standard, and today's recommendation over Event Grid's own schema, because it makes events portable.
  • Filters: by event type, by subject prefix or suffix and by advanced property values. Filtering happens in the service, so the subscriber receives only what interests it.
  • Retries and dead-lettering: if the destination does not respond, it retries with exponential backoff for 24 hours and then deposits the event in a configured storage account. If you do not configure one, the event is silently lost, which is the most common trap in the service.
az eventgrid topic create -g rg-contoso-reservas-pro -n evgt-contoso-reservas-pro \
  --location westeurope --input-schema cloudeventschemav1_0

az eventgrid event-subscription create --name sub-motor-disponibilidad \
  --source-resource-id $(az eventgrid topic show -g rg-contoso-reservas-pro \
      -n evgt-contoso-reservas-pro --query id -o tsv) \
  --endpoint-type azurefunction \
  --endpoint "<function id>" \
  --included-event-types Contoso.Reservas.ReservaConfirmada \
  --advanced-filter data.importe NumberGreaterThan 500 \
  --deadletter-endpoint "<dead-letter container id>"

The event in CloudEvents form, showing the essentials: it is lightweight and describes a fact, it does not carry the whole booking.

{
  "specversion": "1.0",
  "type": "Contoso.Reservas.ReservaConfirmada",
  "source": "/contoso/reservas/app-contoso-reservas-pro",
  "id": "b41f7c22-9e0a-4a11-9a3c-7d2f5c1e8a90",
  "subject": "reservas/XR7742",
  "time": "2026-08-15T09:14:22Z",
  "datacontenttype": "application/json",
  "data": { "localizador": "XR7742", "vuelo": "CA-1187",
            "origenDestino": "BCN-CDG", "importe": 214.50, "esSocio": true }
}

type lets you filter by class of event, subject by a specific resource — with prefix filters such as reservas/XR — and id is the identifier the consumer uses to detect duplicates.

  1. Azure Event Hubs

Event Hubs is not for business events but for large-scale data streams: telemetry, logs, time series. Contoso uses it for the check-in desks, which continuously emit the state of the passenger queue, the handling time and baggage incidents.

  • Partitions: the stream is split into partitions that are read in parallel. Ordering is guaranteed within a partition, so the partition key — the desk identifier — determines what ends up ordered. The number of partitions fixes the maximum parallelism and is worth thinking through when you create the resource.
  • Consumer groups: each consuming application has its own view of the stream with its own progress. The real-time dashboard and the analytics process read the same data without getting in each other's way.
  • Retention: records are not deleted when they are read; they stay for a time window, 1 to 7 days on Standard and longer on Premium. That allows reprocessing by moving the offset back, which is impossible with a queue.
  • Capture: it automatically writes the stream in Avro onto stlagocontosopro, in the Data Lake's bronce layer (03-06), without writing a line of code. This is the key integration with analytics.
  • Kafka compatibility: a Kafka producer or consumer points at Event Hubs by changing the connection configuration, without touching the code. It is the migration route that does not involve operating a cluster.
az eventhubs namespace create -g rg-contoso-reservas-pro -n evhns-contoso-telemetria-pro \
  --location westeurope --sku Standard --capacity 2 --enable-auto-inflate --maximum-throughput-units 10

az eventhubs eventhub create -g rg-contoso-reservas-pro \
  --namespace-name evhns-contoso-telemetria-pro -n facturacion-mostradores \
  --partition-count 8 --retention-time-in-hours 72 \
  --enable-capture true --capture-interval 300 \
  --destination-name EventHubArchive.AzureBlockBlob \
  --storage-account stlagocontosopro --blob-container bronce

--enable-auto-inflate raises the throughput units automatically on a spike — and here comes the cost warning: they go up on their own, but they do not come down on their own, so a one-off spike leaves the resource billing at maximum until somebody adjusts it.

  1. Contoso's complete flow

graph LR
  WEB["app-contoso-reservas-pro<br/>Confirm booking"] -->|1 message| SB["Service Bus<br/>reservas-confirmadas topic"]
  WEB -->|2 event| EG["Event Grid<br/>evgt-contoso-reservas-pro"]
  SB --> S1["tarjetas subscription"] --> F1["Function<br/>GenerarTarjetaEmbarque"]
  SB --> S2["facturacion subscription"] --> CA["ca-motor-facturacion"]
  SB --> S3["fidelizacion subscription<br/>filter esSocio = true"] --> F2["Function<br/>AcreditarMillas"]
  F1 --> BLOB["sttarjetascontosopro<br/>tarjetas-embarque"]
  BLOB -->|BlobCreated| EG2["Event Grid<br/>system topic"] --> LA["Logic App:<br/>notify the passenger"]
  EG --> ML["Demand model"]
  DESK["Check-in desks"] -->|telemetry| EH["Event Hubs<br/>facturacion-mostradores"]
  EH -->|capture| LAKE["stlagocontosopro / bronce"]
  EH --> PANEL["Real-time<br/>operations dashboard"]
  SB -.->|failures| DLQ["Dead-letter<br/>queue"]

Read it as a design decision on every arrow. The confirmation publishes a message to the topic, because issuing the boarding pass, billing and crediting miles have to happen and cannot be lost; and it publishes an event to Event Grid, because there may be interested parties that do not exist today — the demand prediction model is one — without the sender knowing about them. When the function drops the PDF into the container, Storage's system topic emits BlobCreated and a logic app (06-04) notifies the passenger: nobody had to program that connection. Check-in desk telemetry goes through Event Hubs, with automatic capture to the Data Lake and simultaneous reading by the real-time dashboard from another consumer group. And the passenger's request finishes as soon as the payment is taken and the seat is reserved: if loyalty is down, its message waits in the subscription and the purchase never notices. That was the problem at the start of the lesson, and it is solved.

  1. At-least-once delivery, ordering and idempotency

These are the three unavoidable consequences of the model, and it is better to accept them as part of the design rather than meet them as surprises.

At-least-once delivery. All three services guarantee that the message arrives, not that it arrives only once. A consumer can receive a duplicate if it crashes after processing and before completing, if the lock expires because it took too long, or if the sender retries after not receiving the acknowledgement. End-to-end exactly-once delivery does not exist in a distributed system; what does exist is idempotent processing, which makes it unnecessary.

Ordering. By default there is no global ordering. You get ordering per session in Service Bus and per partition in Event Hubs, and Event Grid does not guarantee it at all. Design so that order does not matter wherever you can — including a timestamp or a version number in the payload and discarding stale data — and reserve sessions for the cases where it genuinely matters, because they limit parallelism.

Idempotency, already seen in 06-03 and now mandatory in every consumer. The three tools: MessageId with duplicate detection in Service Bus, which covers the short window; the recorded event identifier on the consumer side, with the registroembarques table in stoperacionescontosopro; and naturally idempotent operations, writing with a deterministic key or upsert instead of insert. That last one is always the best because it needs no additional infrastructure.

  1. The cost of each service

Service How it is billed What drives the bill up
Storage queues Per transaction and storage Almost nothing; it is the cheapest option
Service Bus Standard Per operation, with a monthly base Aggressive polling: every empty receive attempt is an operation
Service Bus Premium Per messaging unit per hour Over-sizing "just in case"
Event Grid Per operation (million operations) Unfiltered subscriptions that receive everything
Event Hubs Per throughput unit per hour + events Auto-scaling that goes up and never comes down, and Capture

Three concrete tips: use long-polling receives in Service Bus instead of polling in a loop, because one empty receive per second is 2.6 million operations a month per consumer; filter in the service, not in the consumer, because what is filtered out in Event Grid is not billed as a delivery; and review the Event Hubs throughput units after every spike, because auto-inflate goes up but never comes down. In development, delete namespaces that are not in use: a Service Bus Premium or an Event Hubs with reserved units bills whether it is empty or full.

Common Mistakes and Tips

  • Using Event Hubs for business messaging. It has neither per-message locking nor a dead-letter queue: failure handling is yours and you will end up reimplementing Service Bus badly.
  • Not configuring the dead-letter queue in Service Bus or the dead-letter destination in Event Grid. Failures become invisible and data is lost.
  • Putting the full payload in the event. An event describes a fact and carries a reference; if you need to send 5 MB, upload it to sttarjetascontosopro and send its URI.
  • Assuming ordering where there is none. If order is essential, sessions or partitions; if not, a version stamp in the payload.
  • Not making the consumer idempotent. Sporadic duplicates, impossible to reproduce and always in production.
  • Retrying a permanent error indefinitely. Distinguish transient from permanent and send the latter to the subqueue immediately.
  • Polling in a tight loop. It sends the operations bill through the roof. Use long-polling receives.
  • Tip: give events past-tense fact names (ReservaConfirmada) and messages instruction names (EmitirTarjeta). The name forces you to decide which is which, and once you have, the service chooses itself.
  • Tip: version the schema from the very first event, with Contoso.Reservas.ReservaConfirmada.v1. The day the payload changes, old consumers will keep working.

Exercises

Exercise 1. Contoso Miles wants that, when a flight is completed, the miles are credited to the member, their tier is recalculated and a monthly summary is sent; on top of that, the data team wants every flight-completed event for its prediction model, and the boarding gates emit 400 readings per second that have to be archived and visualized live.

  1. Assign each need to a service and justify the choice using the message/event distinction.
  2. Design the topic, the subscriptions and their filters for the Service Bus part.
  3. State the mandatory tags and one cost-saving measure for the development environment.

Exercise 2. The facturacion subscription has accumulated 12,000 messages in its dead-letter queue. On inspection, they are all from the same day and have DeadLetterReason: MaxDeliveryCountExceeded.

  1. What happened, and why did the main queue keep working?
  2. Describe the procedure for recovering them, stating what has to be verified before resubmitting.
  3. Propose two measures so that it does not happen again, and one alert.

Exercise 3. A consumer of reservas-confirmadas takes around 8 minutes to process each message because it calls a slow external system. The Service Bus lock is 5 minutes. Explain what is going on, what you see in production, and give two solutions of a different nature.

Solutions

Solution 1:

  1. Crediting miles and recalculating the tier are messages: they are instructions that have to be executed and cannot be lost, so Service Bus. The notification to the data team is an event — "flight completed", a fact anyone can subscribe to, and tomorrow perhaps others will — so Event Grid. The 400 readings per second from the gates are high-volume telemetry that has to be archived and watched live: Event Hubs with capture to the Data Lake and a second consumer group for the dashboard.
  2. A vuelos-completados topic in sb-contoso-pro, with the millas subscription unfiltered and the nivel-fidelidad subscription with the filter esSocio = true AND millasAcreditadas > 0. Both with max-delivery-count 5 and dead-lettering enabled. The monthly summary is not a subscription: it is a scheduled process, and putting it here would confuse messaging with scheduling.
  3. entorno, proyecto=contoso-millas, centro-coste=CC-2077 and propietario. Savings in development: the Standard tier instead of Premium for Service Bus, a single throughput unit with no auto-inflate in Event Hubs with 24-hour retention, and deleting namespaces when they are not in use, because they bill even when empty.

Solution 2:

  1. The billing consumer failed systematically throughout that day — a deployment with a bug, or the billing system down. Each message was retried 5 times and, once max-delivery-count was exceeded, Service Bus automatically moved it to the subqueue. That is precisely why the main queue kept working: without that mechanism, the first poison message would have blocked the process and the other subscriptions would have been affected too. The subqueue did exactly its job.
  2. Before resubmitting anything, three things have to be verified: that the root cause is fixed and deployed, that the consumer is idempotent — because some of those messages may have been partially processed — and what DeadLetterReason and DeadLetterErrorDescription say for a representative sample, in case more than one cause is mixed in. Then the messages are read from the subqueue and resubmitted to the topic in controlled batches, watching the main queue so as not to repeat the jam.
  3. Measures: (a) distinguish permanent errors from transient ones in the code and send the former to the subqueue immediately, without burning through five attempts; (b) a circuit breaker that pauses the consumer when the failure rate exceeds a threshold, so that messages wait in the queue instead of burning their retries against a system that is down. The alert: on the subqueue length, with a low threshold — ten messages — and a page to the on-call team, because 12,000 messages means nobody was looking.

Solution 3: The lock expires after 5 minutes while the consumer is still working; Service Bus treats the message as unprocessed and delivers it to another consumer. When the first one finishes and calls CompleteMessageAsync, it fails with a lock-lost error. In production this shows up as messages processed two and three times, a delivery count that climbs until it runs out, and messages that end up in the subqueue despite having been processed correctly. Solution (a), tactical: renew the lock automatically while the work is in progress (MaxAutoLockRenewalDuration) and increase the subscription's lock duration. Solution (b), architectural and better: take the slow call out of the consumer; complete the message straight away and delegate the slow part to a separate flow — a Durable Functions orchestration or a second queue — because holding a message locked for eight minutes limits the throughput of the entire system.

Conclusion

You have solved the problem the lesson opened with. You know why you decouple: availability multiplies downwards, latency adds up and coupling freezes evolution; with an intermediary, the purchase does the bare essentials and publishes, and a peripheral component going down no longer takes the main revenue stream with it. You are clear about the distinction almost everybody confuses: a message is an instruction with a recipient that cannot be lost; an event is a past-tense fact that the sender publishes without knowing who reacts. And you know the name gives it away.

You handle the three services with judgment. Service Bus for reliable enterprise messaging: queues versus topics with subscriptions and SQL filters, sessions for ordering, peek-lock as the correct receive mode, deferral, the dead-letter queue — the piece that saves teams — transactions, duplicate detection and its three tiers, with the send and consume code for reservas-confirmadas distinguishing permanent errors from transient ones. Event Grid for distributing discrete events: system topics that emit with nothing configured — the BlobCreated from sttarjetascontosopro — custom topics, the CloudEvents schema, filtering in the service, and the dead-letter destination you must configure or events are silently lost. And Event Hubs for massive ingestion: partitions and ordering within the partition, independent consumer groups, retention that allows reprocessing, automatic capture into the bronce layer of stlagocontosopro and Kafka compatibility. Without forgetting that the humble Storage queues are enough when there is a single consumer and no demanding requirements.

You have designed Contoso's complete flow seeing every decision, and you take away the three unavoidable consequences: at-least-once delivery — exactly-once delivery does not exist, what exists is idempotent processing — ordering only per session or per partition, and mandatory idempotency in every consumer. Plus the cost of each service and what drives it up: polling in a loop, unfiltered subscriptions and auto-scaling that goes up and never comes down.

With this, Contoso's platform is modernized: containers, orchestration, functions, integrations and an event architecture holding it all up. One last step of the module remains, and it is of a different nature. Contoso now has data it could not exploit before — thousands of passenger complaints written in free text, passports that an agent types in by hand at check-in, boarding announcements available in only two languages, a portal that makes no sense outside Spain — and there are ready-made services for all of it. The next lesson, Azure AI Services, walks through that catalog, implements Contoso's concrete cases, explains Azure OpenAI Service and retrieval-augmented generation for the passenger support assistant, and gives proper space to what is not optional: bias, hallucinations, the privacy of the data sent to the service and prior review by the legal and compliance teams.

Azure Course

Module 1: Introduction to Azure

Module 2: Core Azure Services

Module 3: Azure Databases

Module 4: Security in Azure

Module 5: Azure DevOps

Module 6: Advanced Azure Services

Module 7: Monitoring and Management

Module 8: Cost Management and Optimization

Module 9: Case Studies and Best Practices

© Copyright 2026. All rights reserved