Azure Service Bus in .NET: publishing and consuming messages with a BackgroundService and DLQ

In larger systems I use a framework (MassTransit, NServiceBus, Wolverine): they provide ready-made retries, outbox, sagas and serialization.

csharp
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
// ======================================================================
// Q: Show me publishing and consuming a message with Azure Service Bus in .NET.
//
// In larger systems I use a framework (MassTransit, NServiceBus, Wolverine):
// they provide ready-made retries, outbox, sagas and serialization. Watch the
// licenses: MassTransit v9 and NServiceBus are commercial.
// ======================================================================

// Program.cs: access via Managed Identity, no connection string (Q15)
builder.Services.AddAzureClients(b =>
{
    b.AddServiceBusClientWithNamespace(builder.Configuration["ServiceBus:Namespace"]!);
    b.UseCredential(new DefaultAzureCredential());
});
builder.Services.AddSingleton<OrderEventsPublisher>();
builder.Services.AddHostedService<OrderPlacedConsumer>();

// Publisher: ServiceBusClient and the sender are thread-safe, so singleton
public class OrderEventsPublisher(ServiceBusClient client)
{
    private readonly ServiceBusSender _sender = client.CreateSender("order-events");   // topic

    public Task PublishAsync(OrderPlaced e, CancellationToken ct) =>
        _sender.SendMessageAsync(new ServiceBusMessage(BinaryData.FromObjectAsJson(e))
        {
            MessageId = e.EventId.ToString(),        // for duplicate detection
            Subject   = nameof(OrderPlaced),         // message type, for routing / filters
            SessionId = e.OrderId.ToString()         // ordering (FIFO) per order, if the subscription has sessions
        }, ct);
}

// Consumer: BackgroundService, manual complete
public class OrderPlacedConsumer(ServiceBusClient client, IServiceScopeFactory scopes,
                                 ILogger<OrderPlacedConsumer> log) : BackgroundService
{
    protected override async Task ExecuteAsync(CancellationToken ct)
    {
        await using var processor = client.CreateProcessor("order-events", "erp-sync",
            new ServiceBusProcessorOptions { AutoCompleteMessages = false, MaxConcurrentCalls = 4 });

        processor.ProcessMessageAsync += async args =>
        {
            var msg = args.Message;
            await using var scope = scopes.CreateAsyncScope();               // scoped DbContext (Q5)
            var handler = scope.ServiceProvider.GetRequiredService<IErpOrderSync>();
            try
            {
                var evt = msg.Body.ToObjectFromJson<OrderPlaced>()!;
                await handler.HandleAsync(evt, msg.MessageId, args.CancellationToken);
                await args.CompleteMessageAsync(msg, args.CancellationToken);       // success: removed from the queue
            }
            catch (Exception ex) when (ex is JsonException or ValidationException)
            {
                // Poison message: no matter how many retries, it will never succeed
                await args.DeadLetterMessageAsync(msg, "InvalidMessage", ex.Message, args.CancellationToken);
            }
            catch (Exception ex)
            {
                log.LogWarning(ex, "Processing {MessageId} failed, attempt {Count}", msg.MessageId, msg.DeliveryCount);
                await args.AbandonMessageAsync(msg, cancellationToken: args.CancellationToken);
                // redelivered; after MaxDeliveryCount it goes to the DLQ automatically
            }
        };
        processor.ProcessErrorAsync += e =>
        {
            log.LogError(e.Exception, "Service Bus error on {Entity}", e.EntityPath);
            return Task.CompletedTask;
        };

        await processor.StartProcessingAsync(ct);
        try { await Task.Delay(Timeout.Infinite, ct); }
        catch (OperationCanceledException) { }
        await processor.StopProcessingAsync();
    }
}