← Back to the library
ArchitectureHow it works · Deep dive · 16 min

Event-driven architecture: events, sagas, outbox and read models

What does "event-driven" actually mean, and how do you publish events, coordinate multi-service workflows and keep read models correct without distributed transactions?

"Event-driven" covers four different patterns: event notification, event-carried state transfer, event sourcing and CQRS, each with different coupling and costs. Making any of them reliable comes down to a few mechanics: write the event in the same transaction as the state (outbox), relay it with CDC or polling, consume idempotently, coordinate long workflows with sagas, and design the UI for eventual consistency.

THE MENTAL MODEL

An event is a fact about the past, named by the producer (OrderPlaced); a command is a request for the future, addressed to someone who may refuse it (ReserveStock). Event-driven systems replace "call and wait" with "record a fact and let interested parties react", which removes temporal coupling but moves the hard parts elsewhere: the producer must never commit state without its event or the reverse (so the event goes into the same database transaction and a relay publishes it later), every consumer will see duplicates and must be idempotent, there is no cross-service rollback (so multi-step work becomes a saga of local transactions with compensations), and every read model lags the write. Payloads become a public contract that outlives the code that wrote them. Choose the lightest pattern that solves the problem: a notification is cheap, a replicated state copy costs schema discipline, and event sourcing is a storage model you adopt for one bounded context with a real audit or temporal need, not a messaging style.

HOW IT FITS TOGETHER

Choreographed order saga with a compensationNo coordinator: each service reacts to events and publishes its own. When stock fails after payment, Payment compensates by voiding, and Order rejects. Every arrow from a service is published through its outbox.
  1. Client → Order servicePOST /orders (Idempotency-Key)
  2. Order service → Client202 Accepted, status PENDINGOrder row and OrderPlaced outbox row commit in one local transaction.
  3. Order service → Broker (pub/sub)OrderPlaced
  4. Broker (pub/sub) → Payment serviceOrderPlaced
  5. Payment service → Broker (pub/sub)PaymentAuthorizedA compensable step: it can be undone by voiding the authorisation.
  6. Broker (pub/sub) → Inventory servicePaymentAuthorized
  7. Inventory service → Broker (pub/sub)StockReservationFailedA business failure, not an exception: it is a fact other services react to.
  8. Broker (pub/sub) → Payment serviceStockReservationFailed
  9. Payment service → Broker (pub/sub)PaymentVoided (compensation)Compensations are new local transactions, must be idempotent, and can themselves fail and need retries.
  10. Broker (pub/sub) → Order servicePaymentVoided
  11. Order service → Broker (pub/sub)OrderRejectedOrder marks the order REJECTED; notification and analytics consumers pick this up.
  12. Client → Order serviceGET /orders/42
  13. Order service → Client200 status REJECTED, reason out of stockThe UI polls or subscribes; it never assumed the order succeeded.
From state change to read modelThe transactional outbox makes publishing as durable as the write; idempotent consumers make at-least-once delivery safe; the read model is a disposable, rebuildable projection.
  1. Command handlerValidates the command, updates business rows and inserts an outbox row in the same local transaction.
  2. Outbox tableid, aggregate type, aggregate id, event type, payload. Committed if and only if the state change is.
  3. Relay: CDC or pollingDebezium tails the WAL/binlog, or a poller selects unsent rows. Either can publish a row twice after a crash.
  4. Broker topicKeyed by aggregate id so events for one order stay ordered within a partition.
  5. Idempotent consumerRecords the event id in a processed-events table in the same transaction as its effect; acks after commit.
  6. ProjectionApplies the event to a denormalised read model, guarded by the aggregate's version or sequence number.
  7. Query and UIReads the projection. It lags the write by the relay and consumer delay, so the UI must cope.
Fowler's four event-driven patternsThey are often mixed in one system, but they solve different problems and fail in different ways.
Event carriesSource of truthCouplingBuys youCosts you
Event notificationA fact and an id: OrderPlaced {orderId}Producer's databaseLow; consumers call back for detailsSimple decoupled reactionsFlow is hidden in subscriptions; callbacks add load and latency
Event-carried state transferThe changed state consumers needProducer; consumers keep local copiesConsumers depend on the payload schemaConsumers keep working when the producer is downDuplicated data, staleness, schema evolution work
Event sourcingEvery state change, stored foreverThe event log; state is a fold of itInternal; publish separate integration eventsAudit trail, temporal queries, rebuildable stateVersioning old events, replay side effects, querying is harder
CQRSWhatever keeps read models updatedWrite model; read models are derivedRead side depends on write-side eventsRead models shaped per screen, scaled separatelyTwo models, read lag, more moving parts

KEY TERMS

Messages, commands and events
A message is the envelope on the wire. A command (ReserveStock) asks one recipient to do something and may be rejected; an event (StockReserved) states a fact that already happened, is named in the past tense and may have zero or many subscribers. Fowler warns against "passive-aggressive" events: if the producer expects a specific action, send a command.
Pub/sub and topics
Producers publish to a topic without knowing subscribers; the broker fans out to each subscription. Ordering is usually per partition or key, so key by aggregate id when per-entity order matters. Delivery is at least once in practice; acks, redelivery and dead-letter queues are covered in the message queue delivery explainer.
Sagas: choreography vs orchestration
A saga replaces a distributed transaction with a sequence of local transactions plus compensating actions. In choreography each service reacts to others' events: no coordinator, but the flow lives in nobody's code and cycles creep in. In orchestration a coordinator (a state machine or a workflow engine such as Temporal) issues commands and runs compensations: explicit and testable, at the cost of a central component. Sagas have no isolation, so concurrent sagas need countermeasures such as semantic locks (a PENDING status).
Transactional outbox and CDC
You cannot atomically commit to a database and publish to a broker, so insert the event into an outbox table in the same transaction and let a relay publish it. Polling the table is simple; transaction log tailing (CDC, e.g. Debezium's outbox event router) has lower latency and no polling load but is database-specific. Both deliver at least once.
CQRS, read models and eventual consistency
Commands go to a write model that enforces invariants; queries hit read models built by projecting events. Separate stores mean the read side lags. UIs cope by returning the result of the command itself, showing a pending state, applying the change optimistically, or waiting until the read model reaches the version the command returned. Fowler's caution applies: use CQRS on a specific bounded context, not as a system-wide default.
Schema evolution and contracts
Events outlive their producers, especially in event-sourced stores. Additive changes (new optional fields) are safe if consumers ignore unknown fields; renames and type changes need a new version (OrderPlaced.v2) or upcasting on read. CloudEvents standardises the envelope (id, source, type, time); AsyncAPI documents channels, operations and message schemas the way OpenAPI documents HTTP.
Idempotent consumers
Because relays and brokers redeliver, processing the same event twice must have the effect of processing it once. Store (consumer, event id) in a processed-events table inside the same transaction as the side effect, or make the effect naturally idempotent with upserts and version guards. CloudEvents lets consumers treat identical source plus id as a duplicate.

IN YOUR STACK

TypeScript + SQL · node-postgres Server

-- Column names match Debezium's outbox event router defaults
CREATE TABLE outbox (id uuid PRIMARY KEY, aggregatetype text NOT NULL, aggregateid text NOT NULL,
  type text NOT NULL, payload jsonb NOT NULL, created_at timestamptz NOT NULL DEFAULT now());

// place-order.ts
import { randomUUID } from "node:crypto";
import type { Pool } from "pg";

export async function placeOrder(pool: Pool, cmd: { orderId: string; customerId: string; totalCents: number }) {
  const client = await pool.connect();
  try {
    await client.query("BEGIN");
    await client.query("INSERT INTO orders (id, customer_id, total_cents, status) VALUES ($1, $2, $3, 'pending')",
      [cmd.orderId, cmd.customerId, cmd.totalCents]);
    await client.query("INSERT INTO outbox (id, aggregatetype, aggregateid, type, payload) VALUES ($1, 'order', $2, 'OrderPlaced.v1', $3)",
      [randomUUID(), cmd.orderId, JSON.stringify(cmd)]);
    await client.query("COMMIT"); // both rows or neither; no broker call inside the transaction
  } catch (err) {
    await client.query("ROLLBACK");
    throw err;
  } finally {
    client.release();
  }
}
  • Never publish to the broker inside or after the transaction from request code: a crash between the two is a lost or phantom event (the dual-write problem).
  • With Debezium, aggregatetype routes to topic outbox.event.order and aggregateid becomes the message key, so events for one order stay ordered.
  • The outbox is a queue, not a history: delete or partition old rows. A polling relay instead selects unsent rows with FOR UPDATE SKIP LOCKED and marks them sent.
node-postgres documentation (opens in new tab)

TypeScript + SQL · node-postgres Server

CREATE TABLE processed_events (consumer text NOT NULL, event_id uuid NOT NULL,
  processed_at timestamptz NOT NULL DEFAULT now(), PRIMARY KEY (consumer, event_id));

// shipping/on-payment-captured.ts
import type { Pool } from "pg";

export async function onPaymentCaptured(pool: Pool, event: { id: string; data: { orderId: string } }) {
  const client = await pool.connect();
  try {
    await client.query("BEGIN");
    const claim = await client.query(
      "INSERT INTO processed_events (consumer, event_id) VALUES ('shipping', $1) ON CONFLICT DO NOTHING",
      [event.id]);
    if (claim.rowCount === 0) { await client.query("ROLLBACK"); return; } // duplicate: already applied
    await client.query("INSERT INTO shipments (order_id, status) VALUES ($1, 'ready')", [event.data.orderId]);
    await client.query("COMMIT"); // the dedupe record and the effect commit together
  } catch (err) {
    await client.query("ROLLBACK");
    throw err; // do not ack; the broker redelivers
  } finally {
    client.release();
  }
}
  • Ack the message only after COMMIT. A concurrent duplicate blocks on the primary key until the first transaction finishes, then inserts nothing.
  • Dedupe on the producer's event id (the outbox id or CloudEvents source + id), never on the broker's delivery id, which changes on redelivery.
  • This only covers effects inside your database. Calls to external APIs from a consumer need their own idempotency key.
node-postgres documentation (opens in new tab)

TypeScript · Plain TS Server

type AccountEvent =
  | { type: "Opened"; accountId: string; overdraftLimit: number }
  | { type: "Deposited" | "Withdrawn"; amount: number }
  | { type: "Closed" };
type Account = { balance: number; limit: number; open: boolean };
interface EventStore {
  read(stream: string): Promise<AccountEvent[]>;
  append(stream: string, events: AccountEvent[], opts: { expectedVersion: number }): Promise<void>;
}
const initial: Account = { balance: 0, limit: 0, open: false };
function evolve(s: Account, e: AccountEvent): Account {
  switch (e.type) {
    case "Opened": return { ...s, limit: e.overdraftLimit, open: true };
    case "Deposited": return { ...s, balance: s.balance + e.amount };
    case "Withdrawn": return { ...s, balance: s.balance - e.amount };
    case "Closed": return { ...s, open: false };
  }
}
export async function withdraw(store: EventStore, id: string, amount: number) {
  const history = await store.read(`account-${id}`);
  const state = history.reduce(evolve, initial);
  if (!state.open) throw new Error("account closed");
  if (state.balance - amount < -state.limit) throw new Error("insufficient funds");
  await store.append(`account-${id}`, [{ type: "Withdrawn", amount }], { expectedVersion: history.length });
}
  • expectedVersion is optimistic concurrency: if another writer appended first, the append fails and the command retries against fresh history.
  • Decisions use the folded state; evolve never rejects an event, because stored events are facts. Snapshots are an optimisation for long streams, not a different model.
  • These are internal domain events. Publish separate, deliberately designed integration events to other services so your storage schema does not become their contract.
Plain TS documentation (opens in new tab)

TypeScript · Temporal TypeScript SDK Server

import { proxyActivities } from "@temporalio/workflow";
import type * as activities from "./activities";
import type { OrderRequest } from "./types";

const { authorizePayment, voidPayment, reserveStock, releaseStock, confirmOrder, rejectOrder } =
  proxyActivities<typeof activities>({
    startToCloseTimeout: "30 seconds",
    retry: { maximumAttempts: 5, nonRetryableErrorTypes: ["CardDeclined", "OutOfStock"] },
  });

export async function placeOrderSaga(order: OrderRequest): Promise<"confirmed" | "rejected"> {
  const compensations: Array<() => Promise<void>> = [];
  try {
    compensations.unshift(() => voidPayment(order.id)); // register before: the call may half-succeed
    await authorizePayment(order.id, order.totalCents);
    compensations.unshift(() => releaseStock(order.id));
    await reserveStock(order.id, order.items);
    await confirmOrder(order.id); // pivot: after this, only retry forward
    return "confirmed";
  } catch (err) {
    for (const compensate of compensations) await compensate(); // reverse order via unshift
    await rejectOrder(order.id, String(err));
    return "rejected";
  }
}
  • Activities signal business failures with ApplicationFailure.nonRetryable(message, "OutOfStock"); listed types skip retries and go straight to compensation.
  • Compensations must be idempotent and safe when the forward step never ran. Temporal persists workflow history, so the saga resumes mid-compensation after a worker crash.
  • Workflow code runs in a deterministic sandbox: no network or file I/O, and the SDK replaces Date and Math.random with replay-safe versions. AWS Step Functions and Azure Durable Functions are alternative orchestrators.
Temporal TypeScript SDK documentation (opens in new tab)

HTTP · CloudEvents 1.0 Client + server

POST /events HTTP/1.1
Host: shipping.internal.example.com
Content-Type: application/cloudevents+json; charset=utf-8

{
  "specversion": "1.0",
  "id": "4f6c1a52-7c1e-4d55-9a5e-0b0f1c2d3e4f",
  "source": "https://orders.example.com/orders",
  "type": "com.example.orders.order.placed.v1",
  "subject": "orders/42",
  "time": "2026-10-11T09:30:00Z",
  "datacontenttype": "application/json",
  "dataschema": "https://schemas.example.com/orders/order-placed/v1.json",
  "data": {
    "orderId": "42",
    "customerId": "c-981",
    "totalCents": 12900,
    "currency": "EUR"
  }
}
  • id, source, specversion and type are required. Producers must keep source + id unique, and consumers may treat a repeat as a duplicate: that is your dedupe key.
  • This is structured mode. In binary mode the attributes travel as ce-id, ce-source, ce-type headers and the body is just data; Kafka and AMQP bindings follow the same idea.
  • Version in type or dataschema, add fields without breaking old consumers, and document the channels and message schemas in AsyncAPI so the contract is reviewable.
CloudEvents 1.0 documentation (opens in new tab)

TypeScript · node-postgres Server

// Read model shaped for the order list screen; rebuildable by replaying events.
// CREATE TABLE order_summaries (order_id text PRIMARY KEY, customer_name text, status text,
//   total_cents int, item_count int, version int NOT NULL);
import type { Pool } from "pg";

type OrderEvent =
  | { type: "OrderPlaced.v1"; orderId: string; version: number; customerName: string; totalCents: number; itemCount: number }
  | { type: "OrderShipped.v1" | "OrderCancelled.v1"; orderId: string; version: number };

export async function project(db: Pool, e: OrderEvent): Promise<void> {
  if (e.type === "OrderPlaced.v1") {
    await db.query(
      `INSERT INTO order_summaries (order_id, customer_name, status, total_cents, item_count, version)
       VALUES ($1, $2, 'placed', $3, $4, $5) ON CONFLICT (order_id) DO NOTHING`,
      [e.orderId, e.customerName, e.totalCents, e.itemCount, e.version]);
    return;
  }
  const status = e.type === "OrderShipped.v1" ? "shipped" : "cancelled";
  // The version guard turns duplicates and replays into no-ops and drops stale updates.
  await db.query(
    "UPDATE order_summaries SET status = $2, version = $3 WHERE order_id = $1 AND version < $3",
    [e.orderId, status, e.version]);
}
  • version is the aggregate's per-order sequence number. Consume from a topic keyed by order id so events for one order arrive in order.
  • To change the read model, build a new table by replaying from the start of the log or the event store, then switch reads over; never migrate a projection in place under load.
  • Expose the projected version so the UI can wait for the version its command returned instead of showing stale data.
node-postgres documentation (opens in new tab)

WHEN TO REACH FOR WHAT

Event notification

Use when Other parts of the system should react to something (send an email, invalidate a cache, start a job) and the producer does not care what they do.

Avoid when The producer needs a specific outcome (send a command), or every consumer would immediately call back for details and turn the producer into a hotspot.

Event-carried state transfer

Use when Consumers need the producer's data to do their job and must keep working when the producer is slow or down, for example a catalog copy inside the checkout service.

Avoid when The data must be strongly consistent at read time (balances, stock at the moment of sale), or you cannot commit to evolving the payload schema compatibly.

Event sourcing

Use when One bounded context with a real need for a complete audit trail, temporal queries ("what did we know on March 3") or rebuilding new views from history, as in ledgers and claims.

Avoid when As a system-wide default or a messaging technique. CRUD contexts gain nothing, and event versioning, replay side effects and querying cost a lot.

CQRS with separate read models

Use when Reads and writes have very different shapes or loads, a screen needs data joined across aggregates or services, or collaborative domains need task-based commands.

Avoid when The domain is simple CRUD. Fowler calls CQRS risky complexity for most systems; a reporting database or a SQL view is often enough.

Choreographed saga

Use when Two or three services, a short linear flow, and teams that each own their reaction to a well-known event.

Avoid when The flow has many steps, branches or timeouts, or nobody can answer "what happens after X" without reading four repos. Cycles between services are a red flag.

Orchestrated saga (workflow engine)

Use when Multi-step business processes with compensations, timers, retries and human steps, where you need visibility into each instance's state. Temporal, Step Functions or Durable Functions.

Avoid when A single-service operation that fits in one database transaction, or a team not ready to run or pay for the engine and learn its determinism rules.

Outbox with CDC relay

Use when Any service that must update its database and publish an event reliably. Use CDC (Debezium) for low latency and high volume; polling is fine at modest volume.

Avoid when You can emit the event from the database's own change stream with no outbox (DynamoDB Streams, event store subscriptions), or the event is truly fire-and-forget telemetry.

Plain synchronous call

Use when The caller needs an answer now to continue (price quote, authorisation, validation) and the dependency's availability is acceptable on the request path.

Avoid when The caller only needs to tell others something happened, or a chain of synchronous calls would couple availability and latency across many services.

WHERE IT BITES

  • Dual writes: committing to the database and then publishing to the broker (or the reverse) from application code. A crash in between loses an event or publishes one for a rolled-back change. Use an outbox or the database's change stream.
  • Non-idempotent consumers. Relays, brokers and retries all redeliver; a consumer that charges, emails or increments without a dedupe key will do it twice.
  • Assuming global ordering. Order is usually per partition key at best; events for different aggregates, or after a consumer rebalance, can arrive in any order. Guard updates with versions.
  • Breaking event schemas in place. Renaming a field breaks every consumer you do not know about and every stored event you replay. Add fields, version types, and run consumer contract tests.
  • Publishing event-sourcing internals as the integration contract, which freezes your aggregate design. Map to explicit integration events at the boundary.
  • UIs that read their own write from a lagging read model and show the old state. Return the command result or version, show pending states, or wait for the projection to catch up.

CLOSE THE AI. EXPLAIN THIS.

A checkout writes the order, publishes OrderPlaced, and the payment service charges the card. Sketch where the outbox row is written, what can cause the event to be delivered twice, how the payment consumer avoids a double charge, what compensates if inventory then fails, and what the user sees while the order list projection is behind.

SOURCES

Explainer reviewed