- Published on
Event-Driven Architecture, A Practical Introduction
- Authors

- Name
- Alex Peng
- @aJinTonic
Most backend systems start with direct service calls: Service A needs data from Service B, so it calls Service B's API and waits for a response. This works well until services start accumulating dependencies on each other, and a slowdown in one cascades into the entire system.
Event-driven architecture (EDA) is an alternative model: instead of calling other services directly, a service publishes an event describing what happened, and other services react to it independently.
The Core Idea
In a request-driven system:
Order Service → (HTTP) → Inventory Service → (HTTP) → Notification Service
If Notification Service is slow, Order Service waits. If it's down, the order fails.
In an event-driven system:
Order Service → publishes "order.created" event
↓
[Message Broker (Kafka, SQS, etc.)]
↓ ↓
Inventory Service Notification Service
(consumes independently) (consumes independently)
Order Service publishes the event and moves on. Inventory Service and Notification Service each consume it at their own pace. If Notification Service is slow, only notifications are slow, orders aren't affected.
Key Properties
Temporal decoupling: producer and consumer don't need to be running at the same time. The message broker buffers events.
Spatial decoupling: the producer doesn't know who (if anyone) is consuming the event. You can add a new consumer without changing the producer.
Backpressure handling: if a consumer is overwhelmed, events queue up in the broker rather than causing the producer to fail.
A Concrete Example
An e-commerce platform processes an order:
// Order Service, just publish the event, don't orchestrate
async function processOrder(order: Order) {
await db.orders.create(order)
await broker.publish('order.created', {
orderId: order.id,
customerId: order.customerId,
items: order.items,
total: order.total,
timestamp: new Date().toISOString(),
})
}
// Inventory Service, subscribes independently
broker.subscribe('order.created', async (event) => {
for (const item of event.items) {
await inventory.decrement(item.productId, item.quantity)
}
})
// Email Service, subscribes independently
broker.subscribe('order.created', async (event) => {
const customer = await customers.get(event.customerId)
await email.sendOrderConfirmation(customer.email, event)
})
The Order Service doesn't know about inventory or emails. Adding a new service (analytics, fraud detection, loyalty points) means adding a new subscriber, no changes to the Order Service.
Event Design
Events should describe what happened, not what to do:
// ❌ command-like
{ type: 'SEND_CONFIRMATION_EMAIL', orderId: '123' }
// ✓ fact-like
{ type: 'order.created', orderId: '123', customerId: '456', items: [...] }
A command implies a single intended consumer. A fact can be consumed by any interested service for any purpose.
Include enough context in the event that consumers don't need to call back to get the data they need. If the email service needs the customer name, include it in the event, otherwise you've just moved a synchronous dependency inside an asynchronous envelope.
At-Least-Once Delivery
Message brokers typically guarantee at-least-once delivery: an event may be delivered more than once (if a consumer crashes after processing but before acknowledging). Your consumers must be idempotent, processing the same event twice should produce the same result as processing it once.
broker.subscribe('order.created', async (event) => {
// Idempotent: no-op if we've already reserved for this order
const existing = await inventory.getReservation(event.orderId)
if (existing) return
await inventory.createReservation(event.orderId, event.items)
})
This is one of the main operational complexities of EDA that doesn't exist in request-driven systems.
When EDA Makes Sense
Good fit:
- Workflows where multiple services react to the same event
- High-volume write paths where you can tolerate eventual consistency
- Integrating with external systems (webhooks, third-party notifications)
- Audit logging and analytics pipelines
Poor fit:
- Read operations that need immediate consistency
- Simple CRUD where direct calls are clearer
- When you need a synchronous response (use request-response instead)
The Tradeoff
EDA improves resilience and decoupling at the cost of operational complexity: debugging is harder (distributed traces, no single call stack), testing is more complex (async event flows), and eventual consistency requires explicit handling in your product logic.
Start with synchronous calls. When you hit the limits, cascading failures, tight coupling, or the need for fan-out to multiple consumers, that's when EDA earns its complexity.