Event-Driven Architecture: Decoupling Services with Message Queues#
In a request-driven system, when a user completes a purchase, the order service calls the inventory service, then the email service, then the analytics service, then the loyalty service. Every one of those calls adds latency, and if any of them fails, the purchase fails — even though the user does not care whether analytics recorded it.
Event-driven architecture inverts that. The order service announces "an order was placed" and moves on. Whoever cares reacts in their own time.
Commands vs events#
The distinction matters more than any tool choice:
- A command tells a specific service to do something:
ReserveInventory. The sender expects it to happen and usually cares about the result. - An event states a fact that already happened:
OrderPlaced. The publisher does not know or care who listens.
Events are named in the past tense and are immutable. You cannot reject a fact; you can only react to it.
What you gain#
- Decoupling. The order service does not need to know the loyalty service exists. A new consumer can subscribe without changing the producer.
- Resilience. If the email service is down, events wait in the queue and are processed when it recovers. The purchase still succeeds.
- Responsiveness. The user gets a response as soon as the core work is done, not after every side effect.
- Independent scaling. A slow analytics consumer can be scaled on its own.
What you pay#
- Eventual consistency. For a short time, the order exists but loyalty points have not been added. The product and the UI must accept that.
- Harder debugging. A flow is no longer one call stack; it is a chain of messages across services. Distributed tracing with propagated IDs becomes essential.
- Operational complexity. A broker to run, queues to monitor, poison messages to handle.
Designing good events#
An event is a public contract, so design it as carefully as an API:
1{2 "eventId": "9f1c2e4a-6b7d-4c3e-8a1f-2d5e6f7a8b9c",3 "eventType": "order.placed",4 "eventVersion": 2,5 "occurredAt": "2026-03-14T09:21:05Z",6 "data": {7 "orderId": "ord_8812",8 "customerId": "cus_311",9 "totalCents": 4599,10 "currency": "USD",11 "items": [{ "sku": "TSHIRT-M", "quantity": 1 }]12 }13}
- A unique event ID so consumers can deduplicate.
- A type and a version so the schema can evolve.
- The time it happened, not the time it was sent.
- Enough data to be useful without forcing every consumer to call back into the producer. Fat enough to be self-contained, lean enough to avoid leaking internal details.
Delivery guarantees, honestly#
Practical brokers offer at-least-once delivery. Messages can be delivered twice, after a consumer crash or a network blip. Exactly-once delivery across systems is effectively a myth in practice; exactly-once processing is achieved by making consumers idempotent:
1async function onOrderPlaced(event: OrderPlaced) {2 const isNew = await processed.tryInsert(event.eventId); // unique constraint3 if (!isNew) return;4 await loyalty.addPoints(event.data.customerId, pointsFor(event.data.totalCents));5}
On the producer side, use the transactional outbox pattern so events are never lost between the database commit and the publish.
Ordering#
Do not assume global ordering. Most brokers only guarantee order within a partition or queue. If order matters for a given entity — for example, OrderPlaced must be processed before OrderCancelled — route all events for that entity to the same partition using the entity ID as the key, and include a sequence number so consumers can detect gaps.
Poison messages and dead-letter queues#
Some messages fail every time: a malformed payload, a bug in the consumer. Without protection, they are retried forever and block the queue behind them. Configure a maximum number of delivery attempts, then move the message to a dead-letter queue, alert on it, and provide a way to replay it once the bug is fixed.
Choosing a broker#
- RabbitMQ: flexible routing, work queues, good for task distribution and command-style messaging.
- Kafka: a durable, replayable log, ideal for high-throughput event streams and multiple consumers reading at their own pace.
- Managed queues (SQS, Pub/Sub): less to operate, good defaults, fine for most teams.
- Redis-based queues: simple and fast for background jobs within one system.
Choose based on your actual needs. Most teams starting out are well served by a managed queue.
Conclusion#
Event-driven architecture lets services react to facts instead of calling each other in long synchronous chains. It brings decoupling and resilience at the price of eventual consistency and operational effort. Design events as versioned contracts, make every consumer idempotent, publish through an outbox, partition by entity when order matters and give failed messages a dead-letter queue. Done that way, adding a new reaction to an event becomes the easiest change in your system.