You can talk to Azure Service Bus directly with its SDK. Most teams outgrow that quickly — retry policies, the outbox pattern, saga orchestration, and testability all end up hand-rolled and duplicated across services. MassTransit wraps the transport in a consistent abstraction so your message contracts and consumers stay the same even if the transport underneath changes.
- MassTransit gives you publish/consume, retries, and error queues as configuration, not code you write per project.
- Define message contracts as interfaces, not concrete classes, and only add fields — that discipline is what keeps consumers forward-compatible.
- Use the transactional outbox so a message publish is atomic with the database write that triggered it.
- Because MassTransit abstracts the transport, moving from Azure Service Bus to RabbitMQ later is mostly a configuration change, not a rewrite.
Setup
builder.Services.AddMassTransit(x =>
{
x.AddConsumer();
x.UsingAzureServiceBus((context, cfg) =>
{
cfg.Host(builder.Configuration.GetConnectionString("ServiceBus"));
cfg.ConfigureEndpoints(context);
});
});
Publishing an event
Contracts are plain interfaces. That's deliberate — it keeps the message shape decoupled from any single assembly's concrete implementation and makes versioning additive rather than breaking.
public interface OrderPlaced
{
Guid OrderId { get; }
string CustomerEmail { get; }
decimal Total { get; }
DateTimeOffset PlacedAt { get; }
}
public class OrderService(IPublishEndpoint publisher)
{
public async Task PlaceOrderAsync(Order order, CancellationToken ct)
{
await publisher.Publish(new
{
OrderId = order.Id,
order.CustomerEmail,
order.Total,
PlacedAt = DateTimeOffset.UtcNow
}, ct);
}
}
Consuming it
public class OrderPlacedConsumer(IEmailSender email, ILogger logger)
: IConsumer
{
public async Task Consume(ConsumeContext context)
{
var message = context.Message;
logger.LogInformation("Sending confirmation for order {OrderId}", message.OrderId);
await email.SendOrderConfirmationAsync(message.CustomerEmail, message.OrderId, context.CancellationToken);
}
}
The outbox: avoiding the dual-write problem
Saving an order to the database and publishing OrderPlaced are two separate operations. If the process crashes between them, you either lose the event or send it for an order that never actually committed. The transactional outbox writes the message to the same database transaction as your business data, then a background process delivers it — making the publish atomic with the write that caused it.
builder.Services.AddMassTransit(x =>
{
x.AddEntityFrameworkOutbox(o =>
{
o.UseSqlServer();
o.UseBusOutbox();
});
x.AddConsumer();
x.UsingAzureServiceBus((context, cfg) => cfg.ConfigureEndpoints(context));
});
Retries and error queues
MassTransit retries a failing consumer automatically, then moves the message to a conventionally named _error queue once retries are exhausted — instead of losing it or blocking the queue.
cfg.ConfigureEndpoints(context, endpointNameFormatter);
cfg.UseMessageRetry(r => r.Exponential(
retryLimit: 5,
minInterval: TimeSpan.FromSeconds(1),
maxInterval: TimeSpan.FromMinutes(1),
intervalDelta: TimeSpan.FromSeconds(2)));
The payoff for this abstraction shows up later, not on day one: when a client needs on-premises RabbitMQ instead of Azure Service Bus for compliance reasons, or you consolidate two systems onto one transport, the change is almost entirely in the UsingAzureServiceBus configuration block — your consumers and contracts don't move.