Event-Driven Microservices with Azure Service Bus in .NET
A friendly, step-by-step guide to building event-driven microservices in .NET using Azure Service Bus topics, subscriptions, and the ServiceBusProcessor.
A school notice board, not a phone call
Imagine your school wants to tell everyone that sports day is moved to Friday.
One way is to phone each person: call the students, then call the teachers, then call the bus driver, then the canteen. If one call does not connect, that person never finds out. And if you forget someone, they are left out. This is slow and fragile.
A much better way is to pin one notice on the board. Anyone who cares — students, teachers, bus driver, canteen — walks past and reads it when they are ready. You only wrote the notice once. You did not need to know who reads it. And if a new club joins the school tomorrow, they can read the same board without you changing anything.
Azure Service Bus is that notice board for your software. Instead of one service phoning another directly, a service pins an event ("OrderPlaced") to a topic. Every other service that is interested has its own little inbox, called a subscription, and reads the event when it is ready. The sender does not know or care who is listening.
This style is called event-driven microservices. Let us learn why it helps, and how to build it in .NET step by step.
Why not just call the other service directly?
When microservices call each other directly over HTTP, they become tightly tied together. Here is the problem.
If the Email Service is slow or down, the Order Service is stuck waiting. The customer sees an error, even though their order was fine. One sick service makes the whole chain sick. This is called temporal coupling — everything must be awake at the same moment.
Event-driven messaging breaks that chain. The Order Service just says "an order was placed" and moves on. The other services pick up the news whenever they are ready.
Now if the Email Service naps for ten minutes, the message simply waits in its subscription. When the service wakes up, it reads the waiting messages and catches up. Nobody else even notices.
Queues vs topics: two shapes of mailbox
Azure Service Bus gives you two building blocks. Picking the right one matters.
| Feature | Queue | Topic + Subscriptions |
|---|---|---|
| Pattern | One-to-one (point to point) | One-to-many (publish/subscribe) |
| Who reads a message | Exactly one receiver | Every subscription gets a copy |
| Good for | A job one worker should do | An event many services care about |
| Example | "Resize this image" | "An order was placed" |
| Add a new reader later | Hard, they fight over messages | Easy, just add a subscription |
A queue is like a single ticket counter. People line up, and each ticket is handled by one clerk, then it is done.
A topic is the notice board. One notice, many readers, each with their own copy. For event-driven microservices we usually want topics, because many services react to the same event.
How a topic delivers one event to many readers
Steps
Publish
Order service sends OrderPlaced once
Topic
orders topic receives the message
Fan-out
Service Bus copies it per subscription
Subscriptions
email, shipping, analytics each hold a copy
Consumers
each service reads its own copy when ready
Setting up the project
First, add the modern SDK package. Use Azure.Messaging.ServiceBus. The older WindowsAzure.ServiceBus and Microsoft.Azure.ServiceBus packages are being retired on 30 September 2026, so we will not touch them.
dotnet add package Azure.Messaging.ServiceBus
dotnet add package Azure.IdentityIn Azure, create a Service Bus namespace, then a topic named orders, and one subscription per consumer (for example email-sub, shipping-sub, analytics-sub). You can do this in the Azure Portal, with the Azure CLI, or with infrastructure-as-code. The namespace gives you a connection string, but the safer choice in production is a passwordless connection using Microsoft Entra ID through DefaultAzureCredential.
Now register a single ServiceBusClient for the whole app. Creating one client and reusing it is important — it holds the network connection, so you should not make a new one per message.
using Azure.Identity;
using Azure.Messaging.ServiceBus;
var builder = WebApplication.CreateBuilder(args);
// One shared client for the whole app. Reuse it; do not new it up per message.
builder.Services.AddSingleton(_ =>
{
var fullyQualifiedNamespace = "my-namespace.servicebus.windows.net";
return new ServiceBusClient(fullyQualifiedNamespace, new DefaultAzureCredential());
});
var app = builder.Build();
app.Run();Publishing an event
When an order is placed, the Order Service sends one message to the orders topic. We send the event as JSON so any service in any language can read it.
using System.Text.Json;
using Azure.Messaging.ServiceBus;
public sealed class OrderPublisher
{
private readonly ServiceBusClient _client;
public OrderPublisher(ServiceBusClient client) => _client = client;
public async Task PublishOrderPlacedAsync(Guid orderId, decimal total)
{
// A sender is cheap; create one per topic and reuse it if you like.
ServiceBusSender sender = _client.CreateSender("orders");
var payload = new { OrderId = orderId, Total = total, Type = "OrderPlaced" };
var message = new ServiceBusMessage(JsonSerializer.Serialize(payload))
{
ContentType = "application/json",
// Subject acts like a label other services can filter on.
Subject = "OrderPlaced",
// MessageId lets the broker and consumers detect duplicates.
MessageId = orderId.ToString()
};
await sender.SendMessageAsync(message);
}
}Two small but powerful fields here:
- Subject is a short label. Subscriptions can filter on it so a service only gets the events it wants.
- MessageId is a unique stamp. It helps with duplicate detection, so the same order is not processed twice.
Receiving events with ServiceBusProcessor
On the consumer side, the recommended tool is the ServiceBusProcessor. It is like a tireless mail clerk: it pulls messages, hands them to your code, retries on failure, and reconnects automatically if the network blinks. You do not write a while loop yourself.
We run it inside a BackgroundService so it lives for the whole life of the app.
using System.Text.Json;
using Azure.Messaging.ServiceBus;
using Microsoft.Extensions.Hosting;
public sealed class EmailWorker : BackgroundService
{
private readonly ServiceBusClient _client;
private ServiceBusProcessor? _processor;
public EmailWorker(ServiceBusClient client) => _client = client;
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
_processor = _client.CreateProcessor("orders", "email-sub",
new ServiceBusProcessorOptions
{
// Handle a few messages at once for throughput.
MaxConcurrentCalls = 5,
// We will complete or abandon messages ourselves.
AutoCompleteMessages = false
});
_processor.ProcessMessageAsync += OnMessageAsync;
_processor.ProcessErrorAsync += OnErrorAsync;
await _processor.StartProcessingAsync(stoppingToken);
}
private async Task OnMessageAsync(ProcessMessageEventArgs args)
{
try
{
var body = args.Message.Body.ToString();
var order = JsonSerializer.Deserialize<OrderPlaced>(body);
// ... do the real work: send a confirmation email ...
// Tell Service Bus we are done so it removes the message.
await args.CompleteMessageAsync(args.Message);
}
catch (Exception)
{
// Put it back so it can be retried. After too many tries
// it goes to the dead-letter queue automatically.
await args.AbandonMessageAsync(args.Message);
}
}
private Task OnErrorAsync(ProcessErrorEventArgs args)
{
Console.WriteLine($"Service Bus error: {args.Exception.Message}");
return Task.CompletedTask;
}
public override async Task StopAsync(CancellationToken cancellationToken)
{
if (_processor is not null)
await _processor.StopProcessingAsync(cancellationToken);
await base.StopAsync(cancellationToken);
}
}
public record OrderPlaced(Guid OrderId, decimal Total, string Type);Register the worker, and it runs forever:
builder.Services.AddHostedService<EmailWorker>();The message lifecycle (lock, complete, abandon)
Service Bus does not just hand you a message and forget it. When a consumer receives a message, the broker puts a short lock on it so no other worker grabs the same one. Then one of three things happens.
- Complete: your code succeeded. The message is removed for good.
- Abandon: something failed. The lock is released and the message becomes available again for another try.
- Dead-letter: after the message fails too many times (the delivery count passes a limit), Service Bus moves it to a special dead-letter queue so it does not block the line.
This is why messages are not silently dropped. A bad message ends up in a clearly marked side mailbox where you can study it, fix the bug, and replay it later.
Filtering: let each subscription pick only what it needs
A great feature of topics is that each subscription can have a rule. The shipping service may only care about OrderPlaced, while a fraud service only wants high-value orders. Instead of every service reading everything and throwing most away, you filter at the broker.
| Subscription | Filter | Gets |
|---|---|---|
| email-sub | sys.Subject = 'OrderPlaced' | order confirmations |
| shipping-sub | sys.Subject = 'OrderPaid' | only paid orders |
| fraud-sub | Total > 50000 | only big orders |
These are called subscription filters. They keep traffic small and keep each service simple. The Order Service still publishes once; the broker decides who should get a copy.
Putting the whole flow together
Here is the end-to-end journey of a single order event, from the moment a customer clicks "Buy" to three services reacting in parallel.
End-to-end event flow for one order
Steps
Customer
clicks Buy on the website
Order API
saves order, publishes OrderPlaced once
orders topic
receives the single message
Subscriptions
email, shipping, analytics each get a copy
Workers
ServiceBusProcessor handles each copy with retries
Done
each worker completes its own message
And here is the same story as a sequence, showing time flowing downward.
Notice the Order API replies 200 OK to the customer right away, before Email and Shipping have done anything. That is the whole point. The customer is not kept waiting for slow background work.
Handling duplicates: be idempotent
Service Bus gives at-least-once delivery by default. If a worker crashes right after sending an email but before it could call CompleteMessage, the message will come back and the worker may send the email a second time.
The cure is to make your consumer idempotent — safe to run twice with the same result. A common way is to remember the MessageId of every message you have already handled, and skip it if you see it again. This pairs beautifully with the Inbox Pattern, where you record processed message ids in a database table inside the same transaction as your work.
private async Task OnMessageAsync(ProcessMessageEventArgs args)
{
var messageId = args.Message.MessageId;
// Skip work we have already done (idempotency check).
if (await _inbox.AlreadyProcessedAsync(messageId))
{
await args.CompleteMessageAsync(args.Message);
return;
}
// ... do the real work ...
await _inbox.MarkProcessedAsync(messageId);
await args.CompleteMessageAsync(args.Message);
}A note on libraries: MassTransit and MediatR
You may have heard of MassTransit and MediatR, which sit on top of brokers like Azure Service Bus and make messaging feel tidy. They are good tools, but be aware that both have moved to a commercial license for newer versions. For learning, hobby projects, or small teams, the plain Azure.Messaging.ServiceBus SDK shown above is completely free and is all you need. You can always add a higher-level library later if your team decides the license fits.
Good habits to remember
A few simple rules keep an event-driven system healthy:
- Reuse one
ServiceBusClient. It holds the connection. Making new ones per message is slow and wasteful. - Keep events small and self-describing. Send ids and key facts, not whole giant objects.
- Version your events. Add fields carefully so old consumers do not break.
- Watch the dead-letter queue. Set an alert. A growing dead-letter queue means something is failing.
- Make consumers idempotent. Assume every message can arrive twice.
- Use filters. Let the broker do the sorting so each service stays simple.
Quick recap
- Event-driven microservices talk through a notice board, not direct phone calls, so one slow service does not freeze the others.
- Azure Service Bus offers queues (one reader) and topics with subscriptions (many readers). For events that many services care about, use topics.
- Use the modern Azure.Messaging.ServiceBus package. Reuse a single ServiceBusClient for the whole app.
- Publish events with a ServiceBusSender, and receive them with a ServiceBusProcessor inside a BackgroundService.
- Each message is locked, then completed, abandoned, or sent to the dead-letter queue — nothing is lost by accident.
- Delivery is at-least-once, so make consumers idempotent, ideally with the Inbox Pattern.
- Subscription filters let each service receive only the events it cares about.
- MassTransit and MediatR are now commercially licensed; the plain SDK is free and enough to learn with.
References and further reading
- Azure Service Bus Queues, Topics, and Subscriptions — Microsoft Learn
- Azure Service Bus Topics Quickstart with .NET — Microsoft Learn
- Building Event-Driven Microservices with Azure Service Bus in .NET — Anton DevTips
- Azure Service Bus for Event-Driven Systems: A Practical Deep Dive — DEV Community
- AspNetCoreServiceBus sample by damienbod — GitHub
Related Patterns
The Outbox Pattern in .NET: Never Lose a Message Again
Learn the Outbox Pattern in .NET with simple, real-life examples. Save your data and your messages in one transaction so a broker outage can never lose an event. Includes EF Core code, diagrams, and best practices.
The Inbox Pattern in .NET: Handle Each Message Exactly Once
Learn the Inbox Pattern in .NET to stop duplicate messages from causing double charges and double emails. Simple real-life examples, EF Core code, diagrams, and how it pairs with the Outbox Pattern.
Building a Custom Domain Events Dispatcher in .NET (No MediatR Needed)
Build your own domain events dispatcher in .NET with EF Core. Simple analogy, full C# code, diagrams, and timing tips — no paid MediatR license required.
Event-Driven Architecture in .NET with RabbitMQ: A Beginner's Guide
Learn event-driven architecture in .NET with RabbitMQ using simple words, real-life examples, exchanges, queues, and clean async C# code you can copy.
Orchestration vs Choreography in .NET: A Friendly Guide
Orchestration vs choreography explained simply for .NET developers — events, commands, sagas, trade-offs, and when to pick each, with clear C# examples.
MassTransit with RabbitMQ and Azure Service Bus: Is It Worth a Commercial License?
MassTransit went commercial in v9. See how it works with RabbitMQ and Azure Service Bus, what the new license costs, and whether it is worth paying for.