Skip to content

Core Concepts

Entity-First Registration

The API is organized around Service Bus entities (queues and topics). You first declare an entity, then attach handlers to it. Declaring an entity also auto-registers a typed sender (IMessageSender<T>) in DI.

csharp
var messaging = builder.Services.AddMessaging(options =>
{
    options.ConnectionString = "your-connection-string";
});

// Declare a queue > returns QueueBuilder<T>
var orders = messaging.MapQueue<OrderCreated>("orders");

// Attach a handler
orders.MapHandler(async (OrderCreated msg, MessageContext ctx, CancellationToken ct) =>
{
    await ctx.CompleteAsync(ct);
});

// Declare a topic > returns TopicBuilder<T>
var events = messaging.MapTopic<OrderCreated>("order-events");

// Attach a subscription handler
events.MapSubscription("audit",
    async (OrderCreated msg, MessageContext ctx, CancellationToken ct) =>
    {
        await ctx.CompleteAsync(ct);
    });

Sender-Only Entities

When you only send to an entity (no handlers), just declare it:

csharp
// No handler - just registers IMessageSender<PaymentRequested> in DI
messaging.MapQueue<PaymentRequested>("payments");

Entity Settings

Builders returned by MapQueue<T> and MapTopic<T> support fluent entity-level settings:

csharp
messaging.MapQueue<OrderCreated>("orders")
    .WithMaxDeliveryCount(5)
    .WithLockDuration(TimeSpan.FromMinutes(2))
    .WithDefaultMessageTimeToLive(TimeSpan.FromHours(24));

// Mark a queue as requiring sessions at the entity level
messaging.MapQueue<GatewayCommand>("commands")
    .WithRequiresSession();

These settings are captured in the topology and available via IServiceBusTopology for validation, export, and IaC generation. See Configuration for the full list of settings.

Message Handlers

Handlers are delegates - just like minimal API endpoints. Parameters are resolved from:

  1. The message itself (deserialized from the body)
  2. MessageContext - metadata, headers, settlement actions
  3. CancellationToken - for cooperative cancellation
  4. Any registered service - resolved from DI (use [FromServices] for explicitness)
csharp
// Minimal - just the message
messaging.MapQueue<OrderCreated>("orders")
    .MapHandler((OrderCreated msg) => { });

// With DI services - injected automatically, just like minimal APIs
messaging.MapQueue<OrderCreated>("orders")
    .MapHandler(async (
        OrderCreated msg,
        IOrderRepository orders,
        ILogger<Program> logger,
        CancellationToken ct) =>
    {
        logger.LogInformation("Processing order {OrderId}", msg.OrderId);
        await orders.CreateAsync(msg, ct);
    });

Use [FromServices] on parameters if you prefer explicit DI annotation.

Message Context

MessageContext is injected into handlers and gives you access to message metadata, settlement actions, and reply capabilities.

Metadata - MessageId, CorrelationId, SessionId, Subject, EnqueuedTime, DeliveryCount, SequenceNumber, ApplicationProperties, etc.

Settlement - CompleteAsync(), AbandonAsync(), DeadLetterAsync(), DeferAsync() for manual message disposition (when auto-complete is off).

Reply - ReplyAsync<T>() sends a response back when the message has a ReplyTo address. Check CanReply first if replying is conditional.

Handler Options

Pass an options delegate before the handler to configure concurrency, prefetch, and auto-complete per handler:

csharp
messaging.MapQueue<OrderCreated>("orders")
    .MapHandler(
        options => options
            .WithConcurrency(10)
            .WithPrefetch(20)
            .WithAutoComplete(true),
        handler);

The same pattern applies to topic subscriptions via .MapSubscription().

The API works with any .NET host - ASP.NET Core, worker services, or Host.CreateApplicationBuilder.

Sending Messages

Declaring an entity with MapQueue<T> or MapTopic<T> auto-registers a typed IMessageSender<T> in DI. The sender knows its destination - no magic strings:

csharp
app.MapPost("/orders", async (
    CreateOrderRequest request,
    IMessageSender<OrderCreated> sender,
    CancellationToken ct) =>
{
    await sender.SendAsync(new OrderCreated(request.CustomerId, request.Items), ct);
    return Results.Accepted();
});

The sender provides SendAsync, SendBatchAsync, ScheduleAsync, CancelScheduledAsync, and RequestAsync<TResponse> (for request/reply). All methods have overloads accepting Action<SendOptions> for setting correlation ID, session ID, TTL, application properties, etc.

Class-Based Handlers

For complex handlers, register a handler class in DI and inject it into the delegate:

csharp
builder.Services.AddScoped<OrderHandler>();

messaging.MapQueue<OrderCreated>("orders")
    .MapHandler((OrderHandler handler, OrderCreated order, MessageContext ctx, CancellationToken ct) 
        => handler.HandleAsync(order, ctx, ct));

The handler class uses regular constructor injection - no marker interfaces required.

Polymorphic Queues

A polymorphic queue carries multiple message types on a single Azure Service Bus queue. Messages are routed to the correct handler at runtime using a MessageType discriminator stored in the message's ApplicationProperties.

This is useful when several related commands share the same routing strategy (e.g., all targeted at a specific gateway via sessions) but have different payloads and handlers.

Registration

Use MapPolymorphicQueue on the messaging builder, then chain MapMessage<T>() calls for each message type:

csharp
var messaging = builder.Services.AddMessaging(options =>
{
    options.ConnectionString = connectionString;
});

// Declare a polymorphic queue with session support
var commands = messaging.MapPolymorphicQueue("gateway-commands", sessions: true);

// Sender-only (no handler on this side - e.g., the API/cloud side)
commands.MapMessage<PingGatewayRequest>();
commands.MapMessage<SetBatterySetpointRequest>();
commands.MapMessage<RefreshConfigurationRequest>();

// With handlers (e.g., the edge/consumer side)
commands.MapMessage<PingGatewayRequest>(
    (PingGatewayRequest req, SessionContext session, ReplyContext reply, CancellationToken ct) => ...);
commands.MapMessage<SetBatterySetpointRequest>(
    (SetBatterySetpointRequest req, SessionContext session, ReplyContext reply, CancellationToken ct) => ...);

How it works

  1. Sending - IMessageSender<T> is registered per message type. When you send a SetBatterySetpointRequest, the sender automatically sets ApplicationProperties["MessageType"] = "SetBatterySetpointRequest" on the outgoing Service Bus message.

  2. Receiving - A single PolymorphicHandlerDescriptor listens on the queue. When a message arrives, it reads the MessageType property and dispatches to the matching sub-handler. Routing uses a FrozenDictionary for O(1) lookup.

  3. Error handling - If the MessageType property is missing or doesn't match any registered type, the message is dead-lettered with a descriptive error.

Combining with sessions and request/reply

Polymorphic queues work with sessions and request/reply just like regular queues. The session ID routes to the correct consumer, and ReplyContext sends responses back through the reply queue:

csharp
// Send a command to a specific gateway and await a response
var response = await sender.RequestAsync<SetBatterySetpointResponse>(
    new SetBatterySetpointRequest { GatewayId = gatewayId, ... },
    opts => opts.SessionId = gatewayId,
    ct);

When to use

  • Multiple command types targeting the same entity (device, tenant, gateway)
  • Consolidating related commands to reduce Azure Service Bus queue count
  • Session-routed commands where all types share the same routing key