Event-Driven Architecture
Event-driven architecture (EDA) lets services collaborate by emitting and reacting to events — immutable records of something that already happened — instead of calling each other directly. A service publishes a fact like OrderPlaced and walks away; any number of consumers react on their own schedule. This decoupling in time, space, and knowledge is what makes large microservice systems resilient and independently deployable, but it trades immediate consistency for eventual consistency, which you must design for deliberately.
Domain events
A domain event captures a business fact in past tense and carries everything a consumer needs to act without calling back to the producer. Keep events small, versioned, and serializable. Treat the schema as a public contract.
// events.js — shared event shapes (ES modules)
import { randomUUID } from "node:crypto";
export function orderPlaced({ orderId, customerId, total }) {
return {
id: randomUUID(), // unique event id, used for idempotency
type: "order.placed",
version: 1,
occurredAt: new Date().toISOString(),
data: { orderId, customerId, total },
};
}
The producer’s only job is to record the fact and hand it to a broker. It never names its consumers.
// order-service.js
import { orderPlaced } from "./events.js";
async function placeOrder(broker, order) {
// ... persist the order in the same transaction as the event (outbox) ...
const event = orderPlaced(order);
await broker.publish("orders", event);
console.log(`Published ${event.type} (${event.id})`);
}
Tip: Publish events from an outbox table written in the same database transaction as your state change, then relay them to the broker asynchronously. This avoids the “saved the order but lost the event” failure mode of dual writes.
Choreography vs orchestration
There are two ways to coordinate a multi-service workflow. With choreography, each service reacts to events and emits its own — no central brain. With orchestration, a coordinator explicitly tells each service what to do and waits for replies.
| Aspect | Choreography | Orchestration |
|---|---|---|
| Control flow | Distributed across services | Centralized in an orchestrator |
| Coupling | Loose (services know events, not each other) | Tighter (orchestrator knows the flow) |
| Visibility | Hard to see the end-to-end flow | Flow is explicit in one place |
| Best for | Simple, additive reactions | Complex flows needing rollback/compensation |
A choreographed flow reads as a chain of independent reactions:
// inventory-service.js
broker.subscribe("orders", async (event) => {
if (event.type !== "order.placed") return;
await reserveStock(event.data.orderId);
await broker.publish("inventory", {
type: "stock.reserved",
data: { orderId: event.data.orderId },
});
});
Choreography keeps services autonomous but spreads the workflow across many subscribers. When a process needs ordered steps with compensation, prefer orchestration — see the saga pattern.
Eventual consistency
Because consumers process events asynchronously, the system passes through windows where different services hold different views of the truth. That is eventual consistency: given no new events, all replicas converge. Design UIs and APIs to tolerate it — show “processing” states, read from local projections, and never assume a downstream service has caught up.
// A read model updated from events, not from a synchronous call.
const orderStatus = new Map();
broker.subscribe("inventory", (event) => {
if (event.type === "stock.reserved") {
orderStatus.set(event.data.orderId, "confirmed");
}
});
The order service answers “confirmed?” from its local orderStatus projection — no blocking call to inventory at request time.
Idempotent event handlers
Brokers deliver at least once, so handlers must survive duplicate and out-of-order delivery. The standard technique is to record each processed event id and short-circuit repeats. The check and the side effect should commit together.
// idempotent-handler.js
const processed = new Set(); // back this with Redis/Postgres in production
export async function handle(event, apply) {
if (processed.has(event.id)) {
console.log(`Skipping duplicate ${event.id}`);
return;
}
await apply(event.data); // your business side effect
processed.add(event.id);
}
Output:
Published order.placed (8f3c1e2a-...)
Skipping duplicate 8f3c1e2a-...
Warning: Idempotency keyed on
event.idonly protects against the same event twice. Guard business invariants too — e.g. reject a secondstock.reservedfor an order that is already reserved — so reordered or replayed events cannot corrupt state.
Best practices
- Name events as past-tense facts (
order.placed), never commands, and include a stableid,type, andversion. - Use the transactional outbox pattern to publish atomically with your state change.
- Make every consumer idempotent; assume at-least-once, out-of-order delivery.
- Version event schemas and evolve them additively so old consumers keep working.
- Prefer choreography for simple reactions; reach for orchestration (sagas) when you need coordinated rollback.
- Build local read models from events instead of synchronous cross-service calls.
- Add a dead-letter queue and tracing so poison messages and slow consumers are observable.