What Every Software Engineer Should Know About Messaging
Most teams either over-engineer with queues or stay fully synchronous. Both extremes cost you. This guide explains message brokers, Service Bus patterns, error handling, the outbox problem, and when to use streams instead.
Problem
Two Teams, Same Misunderstanding
When you search for guidance on messaging in distributed systems, you will find one of two narratives. The first is enthusiastic: queues give you scalability, decoupling, resilience, and async processing. The second is dismissive: we don't need that complexity, our REST calls work fine. Both perspectives share a common flaw. They are not based on understanding what messaging actually does.
The result? Two archetypes of teams that we encounter repeatedly in real projects.
The Overengineered Team
They have read about microservices and event-driven architecture. They reach for a message broker for every interaction, including ones that are inherently synchronous. Their system now has queues for user login, queues for read queries, queues everywhere. Debugging a single user action requires tracing across five services and three queues. Operational overhead has tripled. The team spends more time managing infrastructure than delivering features.
The Synchronous-Only Team
They are pragmatic and delivery-focused. Every service calls every other service via HTTP. When the email service is slow, checkouts time out. When the inventory service is down, orders fail. Long-running tasks block web threads. Their system becomes fragile as it grows. The team made bad decisions because they never learned how to use messaging. They simply did not know what they did not know.
The real problem in both cases is the same: a lack of foundational understanding of what a message broker actually is, what problems it solves, and, critically, what it costs. You cannot make good decisions about a tool you do not understand.
Messaging is not a silver bullet. It is a trade: you exchange synchronous coupling for asynchronous complexity. Whether that trade makes sense depends entirely on your context.
What Is a Message Broker?
The Core Concept
A message broker is an intermediary that accepts messages from producers and delivers them to consumers. The key word is intermediary. The producer does not call the consumer directly. It hands a message to the broker and walks away. The broker takes responsibility for delivery.
This creates a fundamental shift in coupling. The producer no longer needs to know:
- Whether the consumer is running right now
- How many consumers exist
- How long the consumer takes to process the message
- Whether the consumer succeeded or failed
This is the real value of a message broker: not "scalability" in the abstract, but the specific ability to decouple the lifecycle of the producer from the lifecycle of the consumer. That decoupling has real cost: your system is now distributed, your data flows are harder to trace, and failures manifest in non-obvious ways. You need to understand both sides of that trade.
- Producer
- The service that creates and sends a message. It cares about the message being accepted by the broker, not about who processes it or when.
- Consumer
- The service that reads and processes messages. It cares about receiving messages reliably and signaling success or failure back to the broker.
- Queue
- A storage unit in the broker that holds messages until a consumer picks them up. Messages are typically delivered to exactly one consumer.
- Topic / Subscription
- A publish-subscribe mechanism. A producer publishes to a topic; each subscription attached to that topic receives its own independent copy of the message.
- Acknowledgment (Ack)
- A signal from the consumer to the broker saying "I processed this message successfully, you can remove it." Without an ack, the broker will redeliver.
Service Bus: Point-to-Point vs Pub/Sub
The Fundamental Distinction
Azure Service Bus (and most enterprise brokers like RabbitMQ or AWS SQS/SNS) offer two delivery patterns. Understanding when to use each one is not optional. It is the foundation of every other decision you will make about messaging.
Commands say "do something." They go to a Queue (Point-to-Point). One sender, one receiver, one responsibility. Events say "something happened." They go to a Topic (Pub/Sub). One publisher, many subscribers, each reacting independently.
Point-to-Point: Queues for Commands
A queue delivers each message to exactly one consumer. This is the right model for commands: instructions to do something specific, exactly once.
Examples of commands that belong in a queue:
PlaceOrderCommand: process this orderSendEmailCommand: send this specific emailGenerateInvoiceCommand: create a PDF and upload itResizeImageCommand: transcode this asset
The guarantee you get from a queue is: the command will be processed exactly once (assuming proper
error handling). You do not want two instances of your consumer both processing the same
PlaceOrderCommand. That would create duplicate orders.
Sending a Command to a Queue (Azure Service Bus)
await using var client = new ServiceBusClient(connectionString);
await using var sender = client.CreateSender("place-order-queue");
var command = new PlaceOrderCommand(orderId: Guid.NewGuid(), customerId, items);
var message = new ServiceBusMessage(JsonSerializer.SerializeToUtf8Bytes(command))
{
MessageId = command.OrderId.ToString(),
ContentType = "application/json"
};
await sender.SendMessageAsync(message); Pub/Sub: Topics for Events
A topic with subscriptions delivers a copy of each message to every subscription. This is the right model for domain events: facts about something that happened in your system.
When an order is placed, multiple parts of your system need to react: inventory must be reserved,
billing must be notified, a confirmation email must be sent. The order service should not know
about any of these. It simply publishes OrderPlacedEvent. Each subscriber does its own job
independently.
OrderPlacedEvent: order service publishes, inventory/billing/notifications subscribeUserRegisteredEvent: auth service publishes, welcome email/analytics subscribePaymentProcessedEvent: payment service publishes, fulfillment/reporting subscribe
If you find yourself naming messages DoSomethingEvent or SomethingHappenedCommand, stop. Commands are imperatives (PlaceOrder). Events are past tense facts (OrderPlaced). The name tells you the pattern, and the pattern tells you which delivery mechanism to use.
Error Handling: Retries, Locks, and the Dead Letter Queue
Why Messaging Failures Are Different
In a synchronous HTTP call, failure is immediate and visible. The caller gets a 500 response. In an asynchronous messaging system, failure is deferred and invisible until you know where to look. The broker handles this for you, but only if you understand the mechanisms it provides.
Message Lock (Peek-Lock)
When a consumer receives a message, the broker does not immediately remove it. Instead, it locks the message for that consumer for a configurable duration (e.g. 30 seconds or 5 minutes). This is called peek-lock.
During the lock period, no other consumer can pick up the same message. The consumer has three options:
- Complete(): processing succeeded, the broker deletes the message permanently.
- Abandon(): processing failed, release the lock so the message becomes visible again.
- DeadLetter(): explicitly send the message to the dead letter queue with a reason.
If your processing takes longer than the lock duration, the broker assumes the consumer crashed and makes the message available again. For long-running operations, you must renew the lock periodically. Azure Service Bus SDKs can do this automatically via enableAutoLockRenewal.
Retries and Redelivery
Every time a message is abandoned (or the lock expires), the broker increments the delivery count. Most brokers let you configure a maximum delivery count. Azure Service Bus defaults to 10.
On each redelivery, your consumer gets another chance to process the message. This is valuable for transient failures: database timeouts, downstream service unavailability, temporary network errors. Most of these resolve within seconds.
Processing with Explicit Error Handling
processor.ProcessMessageAsync += async args =>
{
try
{
var command = JsonSerializer.Deserialize<PlaceOrderCommand>(args.Message.Body);
await orderService.PlaceOrderAsync(command);
await args.CompleteMessageAsync(args.Message);
}
catch (TransientException)
{
// Release lock, broker will redeliver
await args.AbandonMessageAsync(args.Message);
}
catch (PermanentException ex)
{
// Send directly to DLQ with reason
await args.DeadLetterMessageAsync(args.Message,
deadLetterReason: "BusinessRuleViolation",
deadLetterErrorDescription: ex.Message);
}
}; Scheduled Messages
Service Bus lets you enqueue a message with a future visibility time via
ScheduledEnqueueTimeUtc. The broker holds the message invisible until that time arrives,
then delivers it normally.
This is useful for patterns like:
- Retry with exponential backoff (schedule redelivery 5 minutes from now)
- Deferred workflows (send reminder email 3 days after signup)
- Time-limited commands (cancel the reservation if not confirmed by midnight)
Dead Letter Queue
The Dead Letter Queue (DLQ) is a special sub-queue that receives messages that could not be delivered successfully after exhausting all retries. Every queue and subscription in Service Bus has its own DLQ automatically.
Messages land in the DLQ for two reasons:
- Max delivery count exceeded: the consumer kept failing or abandoning.
- Explicit dead-lettering: the consumer determined the message is unprocessable.
Messages in the DLQ represent real business events or commands that were never processed. You need a process for monitoring the DLQ, alerting on it, and replaying or manually resolving its contents. An ignored DLQ is silent data loss.
Poison Messages
A poison message is a message that can never be processed successfully. Not because of a transient error, but because something is fundamentally wrong with it: malformed payload, missing required field, a business invariant that can never be satisfied.
The danger of a poison message is that it blocks the queue. If your consumer always crashes on message X, it keeps being redelivered, keeps failing, and prevents messages behind it from being processed. The delivery count is the broker's mechanism for detecting this, which is why you must configure it thoughtfully and handle dead-lettering explicitly.
The most common source of poison messages in real systems is a schema change: the producer starts publishing a new field, or renames one, while old consumers are still running. Always version your message contracts. Be backward-compatible. And ensure your DLQ is monitored so poison messages surface quickly.
The Database Consistency Problem
The Dual-Write Trap
Here is a pattern that almost every team eventually writes and later regrets:
The Dangerous Anti-Pattern
public async Task PlaceOrderAsync(PlaceOrderCommand command)
{
// Step 1: Save to database
await _repository.SaveAsync(new Order(command));
// Step 2: Publish event
// What if this crashes? Order is saved, event is never published.
await _messageBus.PublishAsync(new OrderPlacedEvent(command.OrderId));
} This is the dual-write problem. You have two writes to two different systems: the database and the message broker, with no transaction spanning both. A crash between steps 1 and 2 leaves your system in an inconsistent state: the order exists in the database, but no downstream service was notified.
Flipping the order does not help. Publishing first and then crashing before saving creates the opposite problem: notifications sent for an order that does not exist.
The Transactional Outbox Pattern
The solution is to write to both the business table and an outbox table inside a single database transaction. The outbox table stores the message you intend to publish. A background process, the outbox publisher, reads unpublished outbox entries and sends them to the broker.
Transactional Outbox — Writing Phase
public async Task PlaceOrderAsync(PlaceOrderCommand command)
{
await using var tx = await _db.BeginTransactionAsync();
var order = new Order(command);
await _db.Orders.AddAsync(order);
// Same transaction, atomically
var outboxEntry = new OutboxMessage
{
Id = Guid.NewGuid(),
Type = nameof(OrderPlacedEvent),
Payload = JsonSerializer.Serialize(new OrderPlacedEvent(order.Id)),
CreatedAt = DateTime.UtcNow,
ProcessedAt = null
};
await _db.OutboxMessages.AddAsync(outboxEntry);
await _db.SaveChangesAsync();
await tx.CommitAsync();
// If we crash here, BOTH the order and outbox entry are lost. Consistent.
} Outbox Publisher — Background Worker
public class OutboxPublisher : BackgroundService
{
protected override async Task ExecuteAsync(CancellationToken ct)
{
while (!ct.IsCancellationRequested)
{
var pending = await _db.OutboxMessages
.Where(m => m.ProcessedAt == null)
.OrderBy(m => m.CreatedAt)
.Take(50)
.ToListAsync(ct);
foreach (var entry in pending)
{
await _messageBus.PublishAsync(entry.Type, entry.Payload);
entry.ProcessedAt = DateTime.UtcNow;
}
await _db.SaveChangesAsync(ct);
await Task.Delay(TimeSpan.FromSeconds(5), ct);
}
}
} You do not have to implement this yourself. MassTransit has a built-in outbox using EF Core. Wolverine also provides transactional messaging with outbox support. Both handle the background polling, ordering, and idempotency for you.
The Inbox Pattern: Idempotent Consumers
The outbox guarantees at-least-once delivery. This means your consumer can receive the same message more than once: the outbox publisher might publish a message, then crash before marking it as processed, and publish it again on restart.
The solution on the receiving side is an inbox table: before processing any message,
store its MessageId in the database. If the same MessageId arrives again,
skip it.
Idempotent Consumer with Inbox Table
public async Task HandleAsync(OrderPlacedEvent evt, string messageId)
{
var alreadyProcessed = await _db.InboxMessages
.AnyAsync(m => m.MessageId == messageId);
if (alreadyProcessed)
return; // Duplicate, skip safely
await using var tx = await _db.BeginTransactionAsync();
await _db.InboxMessages.AddAsync(new InboxMessage { MessageId = messageId });
await _inventoryService.ReserveStockAsync(evt.OrderId, evt.Items);
await _db.SaveChangesAsync();
await tx.CommitAsync();
} Message Streams vs Message Brokers
Two Different Mental Models
When most teams say "messaging," they mean a broker like Azure Service Bus, RabbitMQ, or AWS SQS. But there is a second paradigm: message streams, represented by Apache Kafka, Azure Event Hub, and AWS Kinesis, that operates on entirely different principles.
Confusing the two is a common and expensive mistake. Teams sometimes adopt Kafka because it sounds powerful, then build command queues on top of it, only to discover they now have to implement retries, dead-lettering, and scheduling themselves, features a broker provides out of the box.
How Streams Are Different
A message stream is an append-only, ordered log. Messages are written to the end of the log and retained for a configurable period (hours, days, or forever). Consumers read from the log by tracking their own offset, a position in the log. Multiple independent consumer groups can read the same log simultaneously, each at their own pace.
This creates capabilities that a traditional broker cannot match:
- Replay: rewind to any offset and reprocess historical events
- Multiple independent consumers: each group maintains its own position
- Audit log: every event that ever happened, in order
- Event sourcing: the stream is the source of truth
- High throughput: millions of events per second via partitioning
But streams also lack features you may be taking for granted in a broker:
- No built-in retry with backoff: you manage your own offset
- No dead letter queue: you implement your own
- No scheduled delivery
- No message-level acknowledgment: only offset commits
- No per-message TTL
The Decision Guide
Choose a Message Broker When...
- You need commands processed exactly once
- You need retries, DLQ, scheduled delivery out of the box
- You are building task queues or job dispatch systems
- Message ordering is per-session, not global
- You need fine-grained ACK/NACK per message
Choose a Message Stream When...
- You need high-throughput event ingestion (IoT, telemetry, clickstreams)
- Multiple independent consumers need the same data
- You need replay and historical reprocessing
- You are building event sourcing or CQRS projections
- You need an immutable audit trail
A mature distributed system often uses both. Azure Event Hub ingests millions of telemetry events per second and fans them out to multiple processors. Azure Service Bus handles the actual command dispatch (reserve stock, process payment, send email) where reliability guarantees and retries matter. The two are complementary, not competing.
Summary
Messaging is not a checkbox for "modern architecture." It is a deliberate trade. You give up simplicity and immediate observability in exchange for temporal decoupling, resilience, and the ability to compose systems that can evolve independently.
That trade only pays off if you understand what you are buying:
- Use queues (Point-to-Point) for commands: work that must happen exactly once
- Use topics (Pub/Sub) for events: facts that multiple parts of your system react to
- Understand peek-lock: the broker holds the message until you ack or the lock expires
- Configure retry counts intentionally: distinguish transient from permanent failures
- Monitor your Dead Letter Queue: it is not a trash can, it is your error log
- Use the Outbox pattern whenever you write to a database and publish a message in the same operation
- Make your consumers idempotent: at-least-once delivery is the guarantee, not exactly-once
- Know the difference between message brokers and streams: they solve different problems
The team that understands messaging makes a conscious choice about when to use it. The team that does not either avoids it entirely and pays with fragility, or adopts it everywhere and pays with complexity. Understanding is the only way out.
Want to Work Together?
I help engineering teams deliver scalable systems through technical leadership, architecture guidance, and hands on mentoring. Let's discuss how I can help your team.