Skip to main content
SEMastery

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.

12 min readUpdated April 14, 2026

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.

Direct calls make every service depend on every other service being awake.

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.

With a topic in the middle, the sender does not wait for anyone.

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.

FeatureQueueTopic + Subscriptions
PatternOne-to-one (point to point)One-to-many (publish/subscribe)
Who reads a messageExactly one receiverEvery subscription gets a copy
Good forA job one worker should doAn event many services care about
Example"Resize this image""An order was placed"
Add a new reader laterHard, they fight over messagesEasy, 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

Publish
Topic
Fan-out
Subscriptions
Consumers

Steps

1

Publish

Order service sends OrderPlaced once

2

Topic

orders topic receives the message

3

Fan-out

Service Bus copies it per subscription

4

Subscriptions

email, shipping, analytics each hold a copy

5

Consumers

each service reads its own copy when ready

The publisher sends once. Service Bus fans the message out to each subscription.

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.Identity

In 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.

A message moves through clear states. Nothing is lost by accident.
  • 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.

SubscriptionFilterGets
email-subsys.Subject = 'OrderPlaced'order confirmations
shipping-subsys.Subject = 'OrderPaid'only paid orders
fraud-subTotal > 50000only 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

Customer
Order API
orders topic
Subscriptions
Workers
Done

Steps

1

Customer

clicks Buy on the website

2

Order API

saves order, publishes OrderPlaced once

3

orders topic

receives the single message

4

Subscriptions

email, shipping, analytics each get a copy

5

Workers

ServiceBusProcessor handles each copy with retries

6

Done

each worker completes its own message

One publish, parallel reactions, safe retries.

And here is the same story as a sequence, showing time flowing downward.

The publisher does not wait for consumers. They work in their own time.

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

Related Patterns