Skip to content

Messaging Patterns

Microservices need to communicate without tight coupling. Asynchronous messaging decouples services in time — the sender doesn’t wait for the receiver.

One sender, one consumer. Each message is processed by exactly one consumer:

OrderService ──► [Queue] ──► EmailService

Use for: work distribution, task offloading, load leveling.

One publisher, multiple subscribers. Each subscriber gets a copy:

OrderService ──► [Topic] ──► EmailService
──► InventoryService
──► AuditService

Use for: event broadcasting, domain events, fan-out notifications.

Async request with a correlation ID and reply-to queue:

OrderService ──► [Request Queue] ──► PricingService
OrderService ◄── [Reply Queue] ◄── PricingService
(correlated by RequestId)

Terminal window
dotnet add package MassTransit
dotnet add package MassTransit.RabbitMQ

Define a message contract:

public record OrderPlaced(Guid OrderId, decimal Amount, string CustomerEmail);

Configure MassTransit:

builder.Services.AddMassTransit(x =>
{
x.AddConsumer<OrderPlacedConsumer>();
x.UsingRabbitMq((context, cfg) =>
{
cfg.Host("rabbitmq://localhost", h =>
{
h.Username("guest");
h.Password("guest");
});
cfg.ConfigureEndpoints(context);
});
});

Publish an event:

public class OrderService
{
private readonly IPublishEndpoint _publishEndpoint;
public OrderService(IPublishEndpoint publishEndpoint)
=> _publishEndpoint = publishEndpoint;
public async Task PlaceOrderAsync(Order order)
{
// save order...
await _publishEndpoint.Publish(new OrderPlaced(order.Id, order.Amount, order.CustomerEmail));
}
}

Consume an event:

public class OrderPlacedConsumer : IConsumer<OrderPlaced>
{
private readonly IEmailService _email;
public OrderPlacedConsumer(IEmailService email) => _email = email;
public async Task Consume(ConsumeContext<OrderPlaced> context)
{
await _email.SendConfirmationAsync(
context.Message.CustomerEmail,
context.Message.OrderId);
}
}

Terminal window
dotnet add package MassTransit.Azure.ServiceBus.Core
builder.Services.AddMassTransit(x =>
{
x.AddConsumer<OrderPlacedConsumer>();
x.UsingAzureServiceBus((context, cfg) =>
{
cfg.Host("Endpoint=sb://your-namespace.servicebus.windows.net/;...");
cfg.ConfigureEndpoints(context);
});
});

The same message contracts and consumers work unchanged across RabbitMQ and Azure Service Bus.


Guarantees that a message is published if and only if the database transaction commits. Prevents lost events on crashes.

┌─────────────────────────────────────────┐
│ BEGIN TRANSACTION │
│ INSERT INTO orders (...) │
│ INSERT INTO outbox_messages (...) ◄──┤── store message, not publish
│ COMMIT │
└─────────────────────────────────────────┘
[Background Worker polls outbox]
[Publishes to message broker]
[Marks outbox message as processed]

MassTransit Outbox (Entity Framework):

dotnet add package MassTransit.EntityFrameworkCore
x.AddEntityFrameworkOutbox<AppDbContext>(o =>
{
o.UseSqlServer();
o.UseBusOutbox(); // use outbox for all publishes
});

Now any Publish or Send inside a request that uses DbContext is automatically outboxed:

public async Task PlaceOrderAsync(Order order)
{
_db.Orders.Add(order);
await _publishEndpoint.Publish(new OrderPlaced(...)); // stored in outbox
await _db.SaveChangesAsync(); // both committed atomically
}

Saga Pattern (Choreography vs Orchestration)

Section titled “Saga Pattern (Choreography vs Orchestration)”

Long-running transactions that span multiple services.

OrderService publishes OrderPlaced
├► PaymentService listens → publishes PaymentProcessed
├► InventoryService listens → publishes StockReserved
└► ShippingService listens (waits for both above)

No central coordinator. Simple, but hard to visualize the overall flow.

Orchestration — a saga coordinates the steps

Section titled “Orchestration — a saga coordinates the steps”
public class OrderSaga : MassTransitStateMachine<OrderSagaState>
{
public State Submitted { get; private set; }
public State PaymentPending { get; private set; }
public State Completed { get; private set; }
public Event<OrderPlaced> OrderPlaced { get; private set; }
public Event<PaymentProcessed> PaymentProcessed { get; private set; }
public OrderSaga()
{
InstanceState(x => x.CurrentState);
Initially(
When(OrderPlaced)
.Then(ctx => ctx.Saga.OrderId = ctx.Message.OrderId)
.Send(new Uri("queue:payment"), ctx => new ProcessPayment(ctx.Saga.OrderId))
.TransitionTo(PaymentPending)
);
During(PaymentPending,
When(PaymentProcessed)
.TransitionTo(Completed)
.Finalize()
);
}
}

Messages that can’t be processed go to a Dead Letter Queue (DLQ) for inspection:

cfg.ReceiveEndpoint("order-processing", e =>
{
e.ConfigureConsumer<OrderPlacedConsumer>(context);
// Retry 3 times with exponential backoff before moving to DLQ
e.UseMessageRetry(r => r.Exponential(3,
TimeSpan.FromSeconds(1),
TimeSpan.FromSeconds(10),
TimeSpan.FromSeconds(2)));
});

Common DLQ strategies:

  • Inspect and replay: fix the bug, re-publish from the DLQ
  • Alert on DLQ growth: Prometheus/Azure Monitor metric threshold

PatternCouplingScalabilityTraceability
ChoreographyLowHighHard
Orchestration (Saga)MediumHighEasy
Point-to-Point QueueLowHighMedium
Pub/SubVery lowVery highMedium
Request/Reply (async)LowHighMedium
ScenarioRecommended Pattern
Email/notification after actionPub/Sub event
Background job (resize image, generate report)Point-to-point queue
Multi-step transaction across servicesSaga (orchestration)
Fanout to many downstream systemsPub/Sub topic
Reliable event with DB transactionOutbox pattern
Async call that needs a resultRequest/Reply