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
}

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.

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