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.
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:
// 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:
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:
- The message itself (deserialized from the body)
MessageContext- metadata, headers, settlement actionsCancellationToken- for cooperative cancellation- Any registered service - resolved from DI (use
[FromServices]for explicitness)
// 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:
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:
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:
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:
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
Sending -
IMessageSender<T>is registered per message type. When you send aSetBatterySetpointRequest, the sender automatically setsApplicationProperties["MessageType"] = "SetBatterySetpointRequest"on the outgoing Service Bus message.Receiving - A single
PolymorphicHandlerDescriptorlistens on the queue. When a message arrives, it reads theMessageTypeproperty and dispatches to the matching sub-handler. Routing uses aFrozenDictionaryfor O(1) lookup.Error handling - If the
MessageTypeproperty 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:
// 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