Messaging Β· 07

Event-Driven Patterns

Brokers move messages; patterns make a system correct. This page covers the problems that appear the moment a service both changes its database and tells others about it: the dual write and the transactional outbox, the inbox that absorbs duplicates, sagas that replace distributed transactions with compensations, CQRS read models, event sourcing and when not to use it, how to design and evolve events, how to test all of it, and the anti-patterns to avoid. A plain-Java build shows an outbox relay crashing mid-publish and the inbox fixing the duplicate.

13
Chapters
6
Core patterns
2
Runnable builds
1
Crash demo
01
Chapter One - The Problem

The Dual Write, the Outbox and Its Table

Page 01 introduced the dual-write problem. Here it is again in the form you meet it in real code: a service must save an order and tell the rest of the system about it. The database and the broker are two separate systems, and there is no transaction that spans both.

Dual write vs transactional outbox
Dual write: two systems, no shared transaction Order service 1. INSERT order 2. publish OrderPlaced crash here: order saved, event never sent Outbox: one local transaction, then a separate relay Service database orders row outbox row one COMMIT: both or none Relay (poll or CDC) read unsent Broker topic publish Consumers mark sent Relay may crash after publish and before mark sent: the event goes out twice. Consumers must be idempotent. Outbox moves the unreliable step out of the business transaction and makes it retryable.
Order of operationsWhat a crash in between causesTypical symptom
Save to DB, then publishOrder exists, event is lostCustomer charged nowhere, no email, inventory never reserved. Found by a support ticket
Publish, then save to DBEvent is out, order was never saved (or was rolled back)Downstream services act on an order that does not exist
Publish inside the DB transaction, before commitSame as above if the commit then fails; also holds the DB transaction open on network I/OPhantom events and connection-pool exhaustion when the broker is slow
Publish in an after-commit hookEvent lost if the process dies between commit and publishRare but real: appears during deploys and OOM kills

The transactional outbox removes the second system from the transaction. The service writes the business row and an event row into an outbox table in the same local transaction. A separate process, the relay, reads the outbox and publishes to the broker. Either both rows exist or neither does, so an event can never be lost relative to its business change.

Key insight: The outbox turns "do two things atomically" (impossible across systems) into "do one thing atomically, then keep retrying the second until it works" (always possible). You trade the lost event for a possible duplicate, and duplicates are handled by idempotent consumers (chapter 04).
⚠ Using the framework event bus as an outbox
  • ApplicationEventPublisher with @TransactionalEventListener(phase = AFTER_COMMIT) is a good way to react to a commit inside one process. It is not durable: events live in memory, so a crash after the commit loses them. Use it to trigger the relay early, never as the only record that an event must be sent. See Spring: Transactions for what AFTER_COMMIT does and does not guarantee.
The outbox table

The outbox is an ordinary table in the same database as the business data (otherwise there is no shared transaction). Design it as an append-only queue of events to publish.

SQL - PostgreSQL DDL for orders, outbox and inbox
CREATE TABLE orders ( id UUID PRIMARY KEY, customer_id TEXT NOT NULL, total_cents BIGINT NOT NULL, status TEXT NOT NULL, created_at TIMESTAMPTZ NOT NULL DEFAULT now() ); CREATE TABLE outbox ( id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, event_id UUID NOT NULL UNIQUE, -- travels with the message; consumers dedup on it aggregate_type TEXT NOT NULL, -- 'order' -> which topic aggregate_id TEXT NOT NULL, -- 'o-123' -> message key, ordering lane type TEXT NOT NULL, -- 'OrderPlaced' payload JSONB NOT NULL, created_at TIMESTAMPTZ NOT NULL DEFAULT now(), sent_at TIMESTAMPTZ -- NULL = not published yet ); -- the relay only ever looks at unsent rows: keep that set cheap to find CREATE INDEX outbox_unsent_idx ON outbox (id) WHERE sent_at IS NULL; -- consumer side (chapter 04) CREATE TABLE inbox ( consumer TEXT NOT NULL, event_id UUID NOT NULL, processed_at TIMESTAMPTZ NOT NULL DEFAULT now(), PRIMARY KEY (consumer, event_id) );
ColumnWhy it is there
event_idA unique id generated by the producer when the event is created. It is the dedup key for every consumer. Do not use the auto-increment id: it can change meaning if you ever restore or re-partition the table
aggregate_type, aggregate_idThe entity the event is about. aggregate_id becomes the broker message key, so all events of one order land on one partition and stay in order (page 02)
typeEvent name in the past tense: OrderPlaced, PaymentCaptured. Consumers switch on it
payloadThe event body as JSON (or bytes for Avro/Protobuf). Serialise at write time so the event records what was true then, not what the entity looks like when the relay runs
created_atFor metrics ("age of the oldest unsent row") and cleanup
sent_atSet by the polling relay. A CDC relay does not need it (chapter 03)
Gaps and out-of-order ids
  • A sequence or identity value is handed out at INSERT time, not at commit time. Transaction A can take id 10, transaction B take id 11, and B can commit first. A relay that remembers "last id I published = 11" would then skip 10 forever. Selecting by sent_at IS NULL (as here) is immune to this. If you use a cursor on id you must account for it.
02
Chapter Two - In Spring

Writing the Outbox Row and Relaying It

The write side is plain JDBC inside the same @Transactional method that changes the order. This example uses JdbcClient (Spring Framework 6.1 and later, included in Spring Boot 3.2+) and the Spring Kafka KafkaTemplate (Spring Kafka 3.x, where send returns a CompletableFuture).

Java - the business method writes both rows in one transaction
@Service public class OrderService { private final JdbcClient jdbc; private final Outbox outbox; public OrderService(JdbcClient jdbc, Outbox outbox) { this.jdbc = jdbc; this.outbox = outbox; } @Transactional public UUID placeOrder(PlaceOrder cmd) { UUID orderId = UUID.randomUUID(); jdbc.sql(""" INSERT INTO orders (id, customer_id, total_cents, status) VALUES (:id, :customer, :total, 'PLACED')""") .param("id", orderId) .param("customer", cmd.customerId()) .param("total", cmd.totalCents()) .update(); outbox.append("order", orderId.toString(), "OrderPlaced", new OrderPlaced(orderId, cmd.customerId(), cmd.totalCents())); return orderId; // COMMIT writes the order row and the outbox row together } } @Component public class Outbox { private final JdbcClient jdbc; private final ObjectMapper json; public Outbox(JdbcClient jdbc, ObjectMapper json) { this.jdbc = jdbc; this.json = json; } // MANDATORY: fail fast if someone calls this without a surrounding transaction @Transactional(propagation = Propagation.MANDATORY) public void append(String aggregateType, String aggregateId, String type, Object event) { try { jdbc.sql(""" INSERT INTO outbox (event_id, aggregate_type, aggregate_id, type, payload) VALUES (:eventId, :aggType, :aggId, :type, CAST(:payload AS jsonb))""") .param("eventId", UUID.randomUUID()) .param("aggType", aggregateType) .param("aggId", aggregateId) .param("type", type) .param("payload", json.writeValueAsString(event)) .update(); } catch (JsonProcessingException e) { throw new IllegalStateException("cannot serialise " + type, e); } } }

Propagation.MANDATORY is the safeguard that matters: an outbox row written outside the business transaction is exactly the bug the pattern exists to prevent. The call throws IllegalTransactionStateException instead of silently opening its own transaction.

Java - polling relay with SELECT ... FOR UPDATE SKIP LOCKED
@Component public class OutboxRelay { record Row(long id, String eventId, String aggregateType, String aggregateId, String type, String payload) {} private final JdbcClient jdbc; private final KafkaTemplate<String, String> kafka; private final int batchSize; public OutboxRelay(JdbcClient jdbc, KafkaTemplate<String, String> kafka, @Value("${outbox.batch-size:100}") int batchSize) { this.jdbc = jdbc; this.kafka = kafka; this.batchSize = batchSize; } @Scheduled(fixedDelayString = "${outbox.poll-ms:500}") @Transactional public void drain() { List<Row> rows = jdbc.sql(""" SELECT id, event_id::text AS event_id, aggregate_type, aggregate_id, type, payload::text AS payload FROM outbox WHERE sent_at IS NULL ORDER BY id LIMIT :n FOR UPDATE SKIP LOCKED""") .param("n", batchSize) .query((rs, i) -> new Row(rs.getLong("id"), rs.getString("event_id"), rs.getString("aggregate_type"), rs.getString("aggregate_id"), rs.getString("type"), rs.getString("payload"))) .list(); List<Long> published = new ArrayList<>(); for (Row r : rows) { ProducerRecord<String, String> rec = new ProducerRecord<>(r.aggregateType() + ".events", r.aggregateId(), r.payload()); rec.headers().add("event-id", r.eventId().getBytes(StandardCharsets.UTF_8)); rec.headers().add("event-type", r.type().getBytes(StandardCharsets.UTF_8)); try { kafka.send(rec).get(10, TimeUnit.SECONDS); // wait: order matters, so one at a time published.add(r.id()); } catch (Exception e) { break; // stop at the first failure so later rows cannot overtake it } } if (!published.isEmpty()) { jdbc.sql("UPDATE outbox SET sent_at = now() WHERE id IN (:ids)") .param("ids", published) .update(); } } }
What the relay code decides
  • **FOR UPDATE SKIP LOCKED** locks the rows this transaction takes and silently skips rows another transaction already locked. Two relay instances therefore never pick the same row, and neither waits for the other. It is supported by PostgreSQL (9.5+), MySQL (8.0+) and Oracle; SQL Server spells it READPAST. Without SKIP LOCKED, the second instance blocks on the first one's locks.
  • **ORDER BY id** publishes in insertion order. Within one relay instance that preserves the order of events for each aggregate.
  • Stop on first failure. If row 3 fails, rows 4 and 5 are not sent, so they cannot overtake it. The loop commits only what succeeded; the failed row is retried on the next poll.
  • **Waiting on send(...).get(...)** makes the transaction (and its row locks) last as long as the broker round trips. Keep batches small (tens to low hundreds) so the transaction stays short.
  • Crash between publish and COMMIT leaves published rows still marked unsent. They are sent again on the next poll: this is the duplicate window, and it is why chapter 04 exists.
  • Scheduling: @Scheduled needs @EnableScheduling on a configuration class. fixedDelay waits for the previous run to finish, so runs on one instance never overlap.
⚠ Using SKIP LOCKED and expecting global ordering
  • With several relay instances, instance A can hold orders 1-100 while B takes 101-200 and publishes first, or A and B can split one aggregate's events across batches. SKIP LOCKED gives parallelism, not order. If per-aggregate order matters (it usually does), either run a single active relay (leader election, or one instance), or shard the outbox by a stable hash of aggregate_id so that each relay owns its own shard.
03
Chapter Three - Choosing a Relay

Polling Publisher vs Log-Based CDC (Debezium)

The relay can look at the outbox table in two very different ways. Both deliver at least once, and in both a duplicate is possible, so the consumer side is the same.

Polling publisher (chapter 02)Log-based CDC (Debezium)
How it readsA query on the table every N msReads the database transaction log (PostgreSQL WAL, MySQL binlog) as changes are committed
LatencyUp to the poll interval plus the query timeTypically low and steady: driven by the log, not by a timer
Load on the databaseA repeated query plus UPDATEs of sent_at; index maintenance on a hot tableNo polling query and no sent_at UPDATE; rows are only INSERTed. Needs a replication slot (PostgreSQL), which retains WAL if the connector falls behind
OrderingCommit order is not id order (chapter 02); you must design for itLog order is commit order, so events leave in the order they were committed
Moving partsYour application onlyKafka Connect cluster, the connector, DB permissions and replication settings, connector monitoring
Works withAny SQL database, any broker (Kafka, RabbitMQ, SQS)Databases with a Debezium connector; the standard path writes to Kafka
Best whenModerate volume, small team, no Kafka Connect yet, a non-Kafka brokerHigh volume, low-latency needs, or you already operate Kafka Connect
Debezium outbox event router

Debezium ships a single message transform for this pattern: **io.debezium.transforms.outbox.EventRouter** (documented as the outbox event router SMT). It reads INSERT events from the outbox table and rewrites each into a clean business message: payload column as the value, key from the aggregate id column, the event id as a header, and the topic chosen from a column. Its defaults expect columns named id, aggregatetype, aggregateid, type and payload; the table in chapter 01 uses different names, so the config below maps them.

JSON - Kafka Connect configuration for PostgreSQL (abridged)
{ "name": "orders-outbox", "config": { "connector.class": "io.debezium.connector.postgresql.PostgresConnector", "plugin.name": "pgoutput", "database.hostname": "orders-db", "database.port": "5432", "database.user": "debezium", "database.password": "<from a secret store>", "database.dbname": "orders", "topic.prefix": "orders", "table.include.list": "public.outbox", "transforms": "outbox", "transforms.outbox.type": "io.debezium.transforms.outbox.EventRouter", "transforms.outbox.table.field.event.id": "event_id", "transforms.outbox.table.field.event.key": "aggregate_id", "transforms.outbox.table.field.event.payload": "payload", "transforms.outbox.route.by.field": "aggregate_type", "transforms.outbox.route.topic.replacement": "outbox.event.${routedByValue}", "transforms.outbox.table.fields.additional.placement": "type:header:eventType" } }

Facts checked against the Debezium 3.7 outbox event router documentation: the SMT class name above; table.field.event.id (the id goes into a message header named id), table.field.event.key (default aggregateid), table.field.event.payload (default payload); route.by.field (default aggregatetype) and route.topic.replacement (default outbox.event.${routedByValue}, so a row with aggregate_type = order goes to topic outbox.event.order); and table.fields.additional.placement with the column:placement:alias syntax, where placement is header, envelope or partition. Connection properties vary by Debezium and database version, so treat the rest of the block as a template and use the connector reference for yours. The SMT expects the outbox to be insert-only: it ignores DELETE events, and UPDATEs are handled according to table.op.invalid.behavior (default warn).

Choosing
  • Start with the polling publisher. It is a screen of code you fully control and it is enough for most services. Move to Debezium when polling load, latency or ordering becomes a measured problem, or when your platform team already runs Kafka Connect.
Operating the outbox
ConcernPractice
Cleanup (polling)Rows with sent_at set are history. Delete in small batches on a schedule (DELETE ... WHERE sent_at < now() - interval '7 days'), or partition the table by date and drop old partitions. An unbounded outbox slows the index and the vacuum
Cleanup (CDC)Delete rows after a short grace period; Debezium's router ignores deletes, so cleanup is safe for the stream. A table that only grows still costs storage and backups
MonitoringAlert on age of the oldest unsent row (or CDC connector lag), not on queue depth alone. A stuck relay looks like silence
Poison rowsA row that always fails (payload too large for the broker, serialisation error) blocks every later row of its lane under "stop on first failure". Count attempts per row and park the row after N failures
BatchingBatch for the read and the UPDATE, but keep publishing ordered per aggregate. Bigger batches mean longer transactions and longer lock time
Payload sizeKeep payloads small and under the broker's message limit. If an event needs a large document, put the document in object storage and send a reference
04
Chapter Four - The Receiving Side

The Inbox and the Idempotent Receiver

At-least-once delivery plus an outbox means every consumer will see duplicates: after a relay crash, after a rebalance, after a lost ack. The idempotent receiver pattern makes processing the same event twice harmless. The most general version is the inbox: a table of event ids this consumer has already handled, written in the same transaction as the business effect.

The inbox: duplicates arrive, the business effect happens once
Broker redelivery of evt-7 Consumer @Transactional Consumer database INSERT inbox(event_id) business UPDATE / INSERT one tx 1st delivery of evt-7: inbox insert succeeds (1 row) -> run the business logic -> COMMIT. Redelivery of evt-7: inbox insert hits the primary key (0 rows) -> skip -> acknowledge the message. The inbox row and the business effect commit together, so a crash leaves neither or both. Without the shared transaction, a crash between the two steps either skips work or repeats it.
Java - Spring Kafka listener with an inbox
@Component public class PaymentListener { private final JdbcClient jdbc; private final PaymentService payments; public PaymentListener(JdbcClient jdbc, PaymentService payments) { this.jdbc = jdbc; this.payments = payments; } @KafkaListener(topics = "order.events", groupId = "payment-service") @Transactional public void onOrderEvent(ConsumerRecord<String, String> rec) { Header h = rec.headers().lastHeader("event-id"); UUID eventId = UUID.fromString(new String(h.value(), StandardCharsets.UTF_8)); int inserted = jdbc.sql(""" INSERT INTO inbox (consumer, event_id) VALUES ('payment-service', :id) ON CONFLICT DO NOTHING""") .param("id", eventId) .update(); if (inserted == 0) { return; // duplicate: acknowledge by returning normally } payments.handle(rec.value()); // business effect, same transaction } }

ON CONFLICT DO NOTHING is PostgreSQL syntax; on other databases catch the duplicate-key exception (DuplicateKeyException in Spring) instead. The listener method is transactional, but the Kafka offset commit is separate: if the process dies after the database COMMIT and before the offset commit, the record is redelivered, the inbox insert returns 0 rows, and the handler skips it. That is the whole mechanism.

TechniqueHowUse when
Naturally idempotent operation"Set status to SHIPPED", upsert by business key, "set balance to X"Always prefer it: no extra table
Inbox tableRecord event_id in the same transaction as the effectThe effect is not naturally idempotent (charge a card, send an email record, increment a counter)
Conditional update / version checkUPDATE ... WHERE id = ? AND version = ?Events carry the entity version; stale or repeated events change nothing
Downstream idempotency keyPass event_id to the payment API as its idempotency keyThe effect is a call to an external system you cannot put in your transaction
⚠ Dedup in memory or in Redis only
  • A set of seen ids in memory vanishes on restart, and a Redis key written before or after the DB commit has the same crash window as the problem you were solving. If the effect is in your database, the dedup record belongs in that database, in that transaction.
Retention
  • Keep inbox rows for at least as long as a duplicate can still arrive: broker retention plus the longest retry delay, with margin. Delete older rows with a scheduled job. A very small window turns a rare duplicate into a double charge.
05
Chapter Five - Long-Running Business Transactions

Sagas: Choreography vs Orchestration

Placing an order touches several services: payment, inventory, shipping. Each owns its database, so a single ACID transaction across them is not available (two-phase commit across services is rarely offered by brokers and APIs, and it couples availability). A saga replaces one big transaction with a sequence of local transactions. If a step fails, the saga runs compensating actions for the steps already done.

Choreography vs orchestration: who decides the next step?
Choreography: each service reacts to events Order Payment Inventory Shipping OrderPlaced PaymentTaken StockReserved No central owner. The flow exists only as the sum of subscriptions. Easy to start, hard to answer "where is order 42 now?" and hard to change. Orchestration: one coordinator tells services what to do Order saga orchestrator (state per order) Payment Inventory Shipping Notification commands Replies and events flow back to the coordinator, which persists the saga state and picks the next step.
ChoreographyOrchestration
Control flowDistributed: each service listens for events and emits its ownCentral: the orchestrator sends commands and reads replies
Where the saga state livesNowhere explicit (implied by events already published)One persistent record per saga instance
CouplingServices know the events of their neighboursServices know only their own commands; the orchestrator knows all of them
VisibilityNeeds tracing across topics to see a flowAsk the orchestrator for the state of order 42
Cyclic dependenciesEasy to create by accident (A reacts to B reacts to A)Avoided: dependencies point from orchestrator outward
RiskThe flow is nowhere in the code; changes touch many servicesThe orchestrator can become a "god service" that holds business rules which belong to the services
Good fitTwo or three steps, few participants, stable flowFour or more steps, compensations, timeouts, a flow that changes
Rule of thumb
  • Choreography for simple, short fan-out ("when an order is placed, update analytics and send an email"). Orchestration when you must answer "what state is it in?" and when failure handling is part of the business logic. Keep the orchestrator thin: it sequences, the services decide.
⚠ Treating compensation as a database rollback
  • A compensation is a new business action that semantically undoes an earlier one, and the earlier effect was visible meanwhile. A refund is not "the charge never happened": the customer saw the charge, and the refund appears on the statement. Design compensations like business operations: with their own events, their own failure handling, and their own idempotency.
06
Chapter Six - Worked Example

Order, Payment, Inventory, Shipping

An orchestrated saga for one order. The orchestrator owns a row per saga (saga_id, order_id, state, deadline_at, timestamps) and talks to the services through commands and events published with the outbox (chapter 01) and consumed with the inbox (chapter 04).

Order saga state machine (orchestrator view)
STARTED PAYMENT AUTHORIZED STOCK RESERVED SHIPMENT REQUESTED COMPLETED Forward path (each arrow = a successful reply) compensable steps pivot REFUNDING RELEASING STOCK CANCELLED stock failed shipping rejected refund done Payment declined: STARTED goes straight to CANCELLED (nothing to undo). Compensations run newest first. Each state is persisted so a restarted orchestrator can resume.
StepCommandSuccess eventFailure eventCompensation
1. Authorize paymentAuthorizePaymentPaymentAuthorizedPaymentDeclined (end: nothing to undo)VoidAuthorization / RefundPayment
2. Reserve stockReserveStockStockReservedStockUnavailableReleaseStock
3. Request shipment (pivot)RequestShipmentShipmentAcceptedShipmentRejectedNone once the carrier has shipped: handled as a business process (return, refund)
4. Notify customerSendConfirmation(none needed)Retry until it succeedsNone (retriable)
Order service stores the order as PENDING and, in the same transaction, creates the saga row and an outbox row AuthorizePayment
β–Ά
Outbox relay publishes the command; state is STARTED
PaymentAuthorized arrives
β–Ά
Orchestrator moves to PAYMENT_AUTHORIZED and writes the ReserveStock command to its outbox, in one transaction
StockUnavailable arrives
β–Ά
State becomes REFUNDING. Orchestrator sends VoidAuthorization, waits for PaymentVoided, then sets CANCELLED and marks the order cancelled
ShipmentRejected arrives
β–Ά
State RELEASING_STOCK, then REFUNDING, then CANCELLED: compensate in reverse order of the completed steps
The orchestrator crashes after a step
β–Ά
On restart it reloads the saga row and resumes from the persisted state; the inbox discards replies it already processed
Timeouts

A service that never replies must not leave a saga open forever. Store a deadline_at per step and have a scheduled job find overdue sagas (WHERE state IN (...) AND deadline_at < now()). On timeout, treat it as a failure and start compensation, but remember the reply may still arrive late: the state machine must reject a StockReserved that shows up after the saga is already CANCELLED, and the compensation ReleaseStock must cope with "nothing was reserved". A late success after a timeout is the case that most hand-written sagas get wrong.

Idempotent compensations
  • Compensations are commands too, delivered at least once. RefundPayment for the same saga_id twice must refund once: key the refund by saga_id and make the handler return the existing result.
  • A compensation must tolerate that the forward step never happened (a timeout can fire before the service acted). "Release reservation R-9" with no such reservation is a success, not an error.
  • A compensation must be retried until it succeeds. It cannot "fail" the saga the way a forward step can; a stuck compensation is an alert for a human and a manual-repair path.
Pivot and retriable transactions
KindMeaningExample hereDesign rule
CompensableCan be undoneAuthorize payment, reserve stockPut these first and give each a compensation
PivotThe go/no-go point: after it succeeds the saga will complete, if it fails the saga compensatesRequest shipment with the carrier (or capture payment)Exactly one per saga; keep it as late as possible
RetriableFollows the pivot and must eventually succeed; retry foreverSend confirmation, update loyalty pointsMake idempotent; never compensate
Process managers

A process manager is the general name for a component that holds the state of a multi-step flow and decides the next message based on the current state and the event that arrived. An orchestrated saga is a process manager specialised for failure and compensation. A process manager is also useful without compensations: for example "wait for both PaymentConfirmed and AddressVerified, then release the order". Its essential parts are the same: a persistent state per flow instance, a correlation id to route each event to the right instance, deadlines, and idempotent handling.

⚠ Choreographed sagas without a correlation id
  • If events do not carry saga_id or order_id, each service has to guess which flow an event belongs to. Put the correlation id (and the event_id, plus a causation_id pointing at the event that caused this one) in every message envelope from day one.
07
Chapter Seven - Separating Reads from Writes

CQRS and Read-Model Projections

CQRS (Command Query Responsibility Segregation) means using a different model for changing data than for reading it. Commands go to a model shaped for invariants (normalised, transactional). Queries go to a model shaped for answers (denormalised, indexed for exactly the screens and APIs that read it). It is a design choice, not a technology: the two models can even be two tables in one database.

CQRS: separate write model and read model, joined by events
Client Command side validate, apply business rules Query side return ready-made views commands queries Write store (normalised SQL) Read store (denormalised view / search) Events (outbox -> broker) projector The read store lags the write store by the event delay: eventual consistency. A client that writes then reads may not see its own change yet.
The three levels of CQRS
LevelWhat changesCost
1. Separate command and query code pathsSame database; commands and queries are different classes and DTOsAlmost none; often just good layering
2. Separate read tables / views, updated in the same transactionA denormalised table maintained by the write pathMore writes, strong consistency preserved
3. Separate read store updated by eventsDifferent store (search index, Redis, a second database) fed by a projector from the event streamEventual consistency, a projector to run and monitor, rebuild tooling
Projections

A projection (or projector) consumes events and keeps a read model up to date. It is just a consumer with a state store, so everything in this page applies to it: it will see duplicates and must be idempotent, it needs ordering per entity, and it needs a poison-message path.

Java - a projector that is idempotent by being an upsert guarded by version
@KafkaListener(topics = "order.events", groupId = "order-summary-projector") @Transactional public void on(ConsumerRecord<String, String> rec) { OrderEvent e = parse(rec.value()); // carries orderId, version, fields jdbc.sql(""" INSERT INTO order_summary (order_id, status, total_cents, version) VALUES (:id, :status, :total, :version) ON CONFLICT (order_id) DO UPDATE SET status = EXCLUDED.status, total_cents = EXCLUDED.total_cents, version = EXCLUDED.version WHERE order_summary.version < EXCLUDED.version""") // stale or repeated event: no change .param("id", e.orderId()) .param("status", e.status()) .param("total", e.totalCents()) .param("version", e.version()) .update(); }

The WHERE order_summary.version < EXCLUDED.version makes the upsert safe under duplicates and under reordering of events for one order: an older event never overwrites a newer state.

Eventual consistency in practice
  • Read-your-writes: after a command, return the new state in the command response, or have the UI show the result optimistically, or poll the read model until it reaches the returned version.
  • Show freshness where it matters ("updated a few seconds ago") and expose projector lag as a metric.
  • Do not read from the read model inside a command to decide a business rule: it may be stale. Rules are checked against the write model.
Rebuilding a projection

Because the event stream is the source, a read model is disposable: change the projection code or schema, reset the consumer group's offsets to the beginning (page 03) or replay from an event archive, and project into a new table or index. Switch queries to the new one when it has caught up, then drop the old one. This requires the stream to retain the full history (a long-retention or compacted topic, or an archive in object storage), and a projector that is deterministic. If events were deleted after seven days, you cannot rebuild from the broker.

⚠ CQRS by default
  • Level 3 CQRS adds a second store, a projector, eventual consistency and rebuild tooling. A CRUD service with simple queries should not pay that. Reach for it when read and write needs genuinely diverge: heavy read fan-out, search, reports over many aggregates, very different scaling.
08
Chapter Eight - Storing Facts

Event Sourcing: What It Is and What It Costs

In a conventional system you store the current state and lose the history. In event sourcing you store the sequence of events as the source of truth, and the current state is derived by replaying them. A bank account is the classic example: the statement (deposits and withdrawals) is the truth, the balance is a sum.

Event sourcing: state is the fold of the event history
OrderPlaced v1 ItemAdded v2 ItemAdded v3 AddressChanged v4 OrderPaid v5 Shipped v6 Event store: append-only stream per aggregate snapshot @ v4 Current state = fold(events) replay all Or: load snapshot v4, replay v5 and v6 Events are facts that never change. Fixing a mistake means appending a new event, not editing an old one.
PieceWhat it isNotes
Event storeAppend-only log of events, one stream per aggregate (order-42)Needs two guarantees: append in order, and optimistic concurrency: "append only if the stream is still at version N" (a unique (stream_id, version) constraint is enough in SQL)
AggregateLoads its events, applies them to rebuild state, validates a command, emits new eventsThe write model of CQRS
SnapshotA saved copy of state at version N so replay starts thereA pure optimisation: delete all snapshots and nothing is lost. Take one every few hundred events, not for every aggregate
ProjectionA read model built from the events (chapter 07)Event sourcing and CQRS are separate ideas that fit together; you can use either alone
SQL - a minimal relational event store
CREATE TABLE event_store ( stream_id TEXT NOT NULL, version INT NOT NULL, type TEXT NOT NULL, payload JSONB NOT NULL, recorded_at TIMESTAMPTZ NOT NULL DEFAULT now(), PRIMARY KEY (stream_id, version) -- two writers racing for version 7: one fails, retries );
Versioning and upcasting

Events are stored forever, so their schema must evolve without rewriting history. The usual technique is the upcaster: a function that converts an old stored event into the current shape when it is read. Store a schemaVersion with every event. When AddressChanged v1 had a single address string and v2 splits it into fields, the upcaster maps v1 to v2 on load (best effort, with explicit defaults), and the rest of the code only ever sees v2.

Costs and when NOT to use it
  • Complexity. Every developer must think in events, aggregates and projections. Simple CRUD becomes much more code.
  • Querying is indirect. Even "list customers in Berlin" requires a projection.
  • Schema evolution never ends. Old events live forever and need upcasters; mistakes in event design are permanent.
  • Eventual consistency everywhere the read side is separate.
  • Privacy and deletion. "Erase this person" collides with an immutable log. Common answers are per-subject encryption keys you destroy ("crypto-shredding") or keeping personal data out of events; both need design up front.
  • Operational tooling: replay, rebuild, snapshot management and stream archiving are yours to build or to buy.
When it earns its keep
  • The history itself is a business requirement (audit trails, ledgers, regulated domains), you need to answer "what did we believe at time T?", or the domain is naturally a sequence of facts. For most services, a normal database plus the transactional outbox gives you reliable events at a fraction of the cost.
⚠ Confusing event sourcing with using events
  • Publishing events to Kafka does not make a system event sourced, and an event-driven system does not need event sourcing. Event sourcing is a storage decision (events are the source of truth). Notification events published from a state-based service are a messaging decision. Teams often adopt the heavy one when they wanted the light one.
09
Chapter Nine - Designing the Messages

Event-Carried State, Schema Evolution and Request-Reply

Thin events vs event-carried state transfer
Thin (notification) eventEvent-carried state transfer (fat event)
ContentIds and the fact: { orderId, type: "OrderShipped" }The data consumers need: address, lines, totals
Consumer needsA callback (REST) to fetch detailsNothing: it updates its own local copy from the event
CouplingRuntime coupling: consumer calls the producer, which must be up and fastData coupling: the event schema is the contract; no call-back
StalenessFetch returns current state, which may be newer than the eventEvent shows state at that moment; consumer's copy lags
RiskThundering herd of callbacks after a burst; producer outage stalls consumersLarge messages; leaking data a consumer should not have; schema changes ripple
Best whenFew consumers, data is sensitive or largeMany consumers, need autonomy and offline resilience

A common middle path is a moderate event: the fields most consumers need plus the entity id and version, with a link for the rest. Whatever you choose, document it as a published contract.

Schema evolution

Producers and consumers deploy at different times, so for a while both old and new versions of an event are live. Rules that keep that safe:

  • Only additive changes: add optional fields with defaults. Never rename, retype or repurpose a field.
  • Consumers ignore unknown fields (the "tolerant reader"). With Jackson that is FAIL_ON_UNKNOWN_PROPERTIES = false, which is already the Spring Boot default for its auto-configured ObjectMapper.
  • Removing a field takes three steps: stop consumers reading it, make it optional, then stop producing it.
  • Breaking change = new event type or new version (OrderPlaced.v2, or a new topic) with both produced during a migration window.
  • **Put a schemaVersion and eventType in the envelope** so a consumer can choose a parser.
StrategyHowTrade-off
Schema registry with compatibility checksAvro, Protobuf or JSON Schema registered per subject; the registry rejects incompatible changes. In Confluent Schema Registry the default mode is BACKWARD (new consumers can read old data), not the transitive variantEnforced in CI/deploy; extra infrastructure and serialiser setup
Versioned event typesOrderPlacedV1, OrderPlacedV2; consumers handle bothExplicit; more types to maintain
Upcasting at readConvert old to new when loading (chapter 08)Consumer logic stays on the latest version; upcasters accumulate
Dual publishingProduce old and new during migration, retire the oldSafe for consumers; temporary duplication
Request-reply over messaging

Sometimes a caller needs an answer but you still want a broker between the services (buffering, decoupling from the other side's deployment). The pattern: the requester sends a message with a correlation id and a reply-to destination; the responder processes it and sends the answer to that destination with the same correlation id; the requester matches replies to waiting calls.

ElementPurposeDetail
Correlation idMatch the reply to the requestA fresh unique id per request, copied unchanged into the reply
Reply destinationWhere the answer goesA per-instance reply queue/topic (so the reply reaches the instance that is waiting), or a shared reply topic plus a filter
TimeoutDo not wait foreverThe requester gives up and treats it as failure; the reply may still arrive later and must be ignored (idempotent, no waiting caller)
ExpiryDo not do work nobody awaitsPut a TTL / deadline in the request so the responder can drop it once the caller has given up

Spring Kafka provides ReplyingKafkaTemplate for this (it sets a correlation id header and a reply topic header, and completes a future when the matching reply arrives), and RabbitMQ has reply-to and correlation_id message properties built in. Whichever you use, remember what you built: a synchronous call with extra latency and extra failure modes. If the answer is needed to continue the user's request, ask whether a plain HTTP call would be simpler; if not, prefer an asynchronous design where the result arrives as an event later.

10
Chapter Ten - Quality

Testing Event-Driven Systems and Avoiding the Anti-Patterns

Testing
What to testTechniqueNotes
Producer and consumer agree on the messageConsumer-driven contract tests: the consumer states the messages it expects; the producer's build verifies it still produces them (Pact message pacts, or Spring Cloud Contract)Catches a renamed field before deployment, without a shared test environment
Handler is idempotentReplay test: feed the same event twice (and a sequence with duplicates and reordering) and assert one effect and the same final stateThe cheapest test with the highest payoff in this whole page
Atomic outbox writeIntegration test with a real database (Testcontainers): call the service, assert order row and outbox row exist; force an exception after the insert and assert neither existsProves the transaction boundary
Saga pathsA test per failure point: payment declined, stock out, shipping fails, timeout then late successAssert final state and that every compensation ran exactly once
ProjectionsReplay a recorded stream into an empty projection; compare with a known-good snapshotDoubles as a rebuild rehearsal
Crash recoveryKill the relay or consumer between steps in a test and assert recovery (chapter 11 does this in plain Java)Use a test hook or a fault-injecting wrapper rather than real kills
Awaiting asynchronous results

Never Thread.sleep in a test. Poll with a timeout. Awaitility (org.awaitility:awaitility, included in spring-boot-starter-test) is the standard tool:

Java - integration test with Awaitility
@Test void orderPlacedEventReachesPaymentService() { UUID id = orderService.placeOrder(new PlaceOrder("c-1", 4000)); await().atMost(Duration.ofSeconds(10)) .pollInterval(Duration.ofMillis(200)) .untilAsserted(() -> assertThat(paymentRepository.findByOrderId(id)).isPresent()); } @Test void duplicateDeliveryHasOneEffect() { OrderPlaced evt = new OrderPlaced(UUID.randomUUID(), "c-1", 4000); UUID eventId = UUID.randomUUID(); handler.handle(eventId, evt); handler.handle(eventId, evt); // redelivery assertThat(paymentRepository.countByOrderId(evt.orderId())).isEqualTo(1); }
Anti-patterns
Anti-patternWhat it looks likeWhy it hurtsFix
Events as remote procedure callsA "event" CalculateTaxRequested that expects a TaxCalculated back for the flow to continueA synchronous call dressed as messaging: all the latency and failure modes of bothUse a real command with request-reply, or a plain API call; use events only for facts that already happened
Distributed monolithServices that cannot be deployed or fail independently, chained by events in a fixed orderYou paid for distribution and kept the couplingRe-draw service boundaries around business capabilities; reduce chatty flows
Shared databaseTwo services read and write the same tables "because it is simpler"The schema is an undeclared API; no service can change it alone; outbox becomes pointlessEach service owns its data; share through events or APIs
God eventOne OrderChanged with 80 fields that every service consumesEvery consumer breaks on every change; no one knows who needs which fieldSmall, specific events (OrderPlaced, OrderCancelled) with a stable contract
Assuming global orderConsumer logic that breaks if OrderCancelled arrives before OrderPlacedOrder exists only per partition key; retries and parallel consumers reorderKey by aggregate id; use versions; handle out-of-order gracefully
No idempotencyHandlers that charge, email or increment on every deliveryDuplicates are normal; this is a double charge waiting for a rebalanceChapter 04: inbox or natural idempotency
Fire and forget the publishDB save then kafka.send without outbox or error checkThe dual-write problemChapters 01 and 02
Events carrying secrets and PIIFull personal data in events retained for months, copied by every consumerData spread, deletion rights hard to honourMinimise payloads, reference by id, encrypt where needed
11
Chapter Eleven - Build It

An Outbox, a Relay and a Crash in Plain Java

The mechanism is small enough to simulate in one file. OutboxDemo.java has an in-memory "database" whose placeOrder writes the order and the outbox row together, a polling relay that publishes first and marks sent second, and a payment consumer that either blindly charges or checks an inbox. The relay is killed between the two steps. Compiled with javac on JDK 21 and run; the output below is the real output.

Java - OutboxDemo.java (no dependencies, Java 17+)
import java.util.*; /** In-memory outbox + relay + consumer. Shows: crash between publish and mark-sent -> duplicate; inbox fixes it. */ public class OutboxDemo { record OutboxRow(long id, String eventId, String type, String payload) {} /** Stand-in for the service database: business table and outbox change together or not at all. */ static class Db { final Map<String, String> orders = new LinkedHashMap<>(); final List<OutboxRow> outbox = new ArrayList<>(); final Set<Long> sent = new HashSet<>(); long seq = 0; synchronized void placeOrder(String orderId, int amount) { // one "transaction" orders.put(orderId, "PLACED"); long id = ++seq; outbox.add(new OutboxRow(id, "evt-" + id, "OrderPlaced", orderId + ":" + amount)); } synchronized List<OutboxRow> pollUnsent(int batch) { return outbox.stream().filter(r -> !sent.contains(r.id())).limit(batch).toList(); } synchronized void markSent(long id) { sent.add(id); } } /** Stand-in for the broker topic. */ static class Topic { final List<OutboxRow> log = new ArrayList<>(); void publish(OutboxRow r) { log.add(r); } } static class CrashException extends RuntimeException {} /** Polling publisher. crashAfterPublishOf != 0 simulates a process kill after publishing that row, before mark-sent. */ static void relayOnce(Db db, Topic topic, long crashAfterPublishOf) { for (OutboxRow r : db.pollUnsent(10)) { topic.publish(r); // step 1: publish System.out.println("relay: published " + r.eventId()); if (r.id() == crashAfterPublishOf) { System.out.println("relay: *** CRASH before markSent(" + r.id() + ") ***"); throw new CrashException(); } db.markSent(r.id()); // step 2: mark sent } } /** Consumer that charges a card. With an inbox it records eventId in the same "transaction". */ static class PaymentService { final boolean useInbox; final Set<String> inbox = new HashSet<>(); int charges = 0; PaymentService(boolean useInbox) { this.useInbox = useInbox; } void onMessage(OutboxRow r) { if (useInbox && !inbox.add(r.eventId())) { System.out.println(" payment[inbox]: " + r.eventId() + " already processed, skipped"); return; } charges++; System.out.println(" payment: charged for " + r.payload() + " (charge #" + charges + ")"); } } static void run(String title, boolean useInbox) { System.out.println("=== " + title + " ==="); Db db = new Db(); Topic topic = new Topic(); PaymentService payment = new PaymentService(useInbox); db.placeOrder("o-1", 40); db.placeOrder("o-2", 25); try { relayOnce(db, topic, 1); } // crashes after publishing o-1 catch (CrashException e) { System.out.println("relay: restarted"); } relayOnce(db, topic, 0); // restart: o-1 is still unsent -> published again System.out.println("topic now holds " + topic.log.size() + " messages"); topic.log.forEach(payment::onMessage); System.out.println("result: orders=" + db.orders.size() + ", charges=" + payment.charges + "\n"); } public static void main(String[] args) { run("Outbox + relay, consumer WITHOUT inbox", false); run("Outbox + relay, consumer WITH inbox", true); } }
Output - javac OutboxDemo.java && java OutboxDemo
=== Outbox + relay, consumer WITHOUT inbox === relay: published evt-1 relay: *** CRASH before markSent(1) *** relay: restarted relay: published evt-1 relay: published evt-2 topic now holds 3 messages payment: charged for o-1:40 (charge #1) payment: charged for o-1:40 (charge #2) payment: charged for o-2:25 (charge #3) result: orders=2, charges=3 === Outbox + relay, consumer WITH inbox === relay: published evt-1 relay: *** CRASH before markSent(1) *** relay: restarted relay: published evt-1 relay: published evt-2 topic now holds 3 messages payment: charged for o-1:40 (charge #1) payment[inbox]: evt-1 already processed, skipped payment: charged for o-2:25 (charge #2) result: orders=2, charges=2
What to notice
  • The event is never lost. Order and outbox row are created together, so after the crash evt-1 is still unsent and the restarted relay sends it.
  • The cost is a duplicate. evt-1 is on the topic twice, because the crash fell between "publish" and "mark sent". No relay design removes that window; it can only be made rare.
  • Without an inbox the duplicate is a double charge (charges=3 for 2 orders).
  • With an inbox it is harmless (charges=2): the second evt-1 hits the already-recorded id and is skipped.
  • The inbox is only correct because in a real consumer the id insert and the charge share one transaction. This simulation keeps them in one method, which is the same idea in memory.

A second example, SagaDemo.java, is a minimal orchestrator: it runs steps in order, remembers which completed, and on a failure runs the compensations of the completed steps newest first. It is deliberately in-memory and synchronous: it shows the control flow of chapter 06, not persistence, timeouts or messaging.

Java - SagaDemo.java (no dependencies, Java 17+)
import java.util.*; import java.util.function.Supplier; /** Tiny saga orchestrator: steps run in order; on failure, completed steps are compensated in reverse. */ public class SagaDemo { interface Step { String name(); boolean execute(String orderId); void compensate(String orderId); // must be idempotent } static Step step(String name, Supplier<Boolean> action, Runnable undo) { return new Step() { public String name() { return name; } public boolean execute(String id) { boolean ok = action.get(); System.out.println(" " + name + ": " + (ok ? "done" : "FAILED")); return ok; } public void compensate(String id) { undo.run(); System.out.println(" " + name + ": compensated"); } }; } static String run(String orderId, List<Step> steps) { Deque<Step> done = new ArrayDeque<>(); String state = "STARTED"; System.out.println("saga " + orderId + " " + state); for (Step s : steps) { if (s.execute(orderId)) { done.push(s); state = s.name() + "_DONE"; } else { state = "COMPENSATING"; System.out.println("saga " + orderId + " " + state); while (!done.isEmpty()) done.pop().compensate(orderId); // newest first state = "CANCELLED"; System.out.println("saga " + orderId + " " + state + "\n"); return state; } } state = "COMPLETED"; System.out.println("saga " + orderId + " " + state + "\n"); return state; } public static void main(String[] args) { int[] stock = {1}; int[] cardHold = {0}; Step payment = step("PAYMENT", () -> { cardHold[0] = 40; return true; }, () -> cardHold[0] = 0); Step inventory = step("INVENTORY", () -> { if (stock[0] > 0) { stock[0]--; return true; } return false; }, () -> stock[0]++); boolean[] carrierUp = {true}; Step shipping = step("SHIPPING", () -> carrierUp[0], () -> { }); run("o-1", List.of(payment, inventory, shipping)); // stock 1 -> 0, succeeds System.out.println("-> stock=" + stock[0] + " cardHold=" + cardHold[0] + "\n"); run("o-2", List.of(payment, inventory, shipping)); // out of stock -> refund System.out.println("-> stock=" + stock[0] + " cardHold=" + cardHold[0] + "\n"); stock[0] = 5; carrierUp[0] = false; run("o-3", List.of(payment, inventory, shipping)); // shipping fails -> release stock, refund System.out.println("-> stock=" + stock[0] + " cardHold=" + cardHold[0]); } }
Output - three orders: success, out of stock, shipping failure
saga o-1 STARTED PAYMENT: done INVENTORY: done SHIPPING: done saga o-1 COMPLETED -> stock=0 cardHold=40 saga o-2 STARTED PAYMENT: done INVENTORY: FAILED saga o-2 COMPENSATING PAYMENT: compensated saga o-2 CANCELLED -> stock=0 cardHold=0 saga o-3 STARTED PAYMENT: done INVENTORY: done SHIPPING: FAILED saga o-3 COMPENSATING INVENTORY: compensated PAYMENT: compensated saga o-3 CANCELLED -> stock=5 cardHold=0
Try this
  • Make the compensation non-idempotent (for example stock[0]++ run twice) and call compensate twice for o-3. Stock ends at 6, not 5. That is the bug an at-least-once command channel produces unless compensations are idempotent. Then add a Set<String> of compensated step names per saga to fix it.
12
Chapter Twelve - Interview

Questions You Should Be Able to Answer

Crisp answers for the questions that come up in interviews on this topic.

  • Q: What is the dual-write problem and how does the outbox solve it?
    A service must update its database and publish a message, but no transaction spans both, so a crash between them leaves them inconsistent. The outbox writes the event into a table in the same local transaction as the business change; a separate relay publishes from that table. The event cannot be lost relative to the change; the price is possible duplicates.
  • Q: Why can an outbox relay publish a message twice?
    It publishes, then marks the row sent. A crash between the two leaves the row unsent, so it is published again after restart. Delivery is at least once, so consumers must be idempotent.
  • Q: What does SELECT ... FOR UPDATE SKIP LOCKED do and why use it in a relay?
    It locks the selected rows and skips rows already locked by other transactions instead of waiting. Several relay instances can poll the same table without picking the same rows. It gives parallelism, not ordering.
  • Q: Polling publisher or Debezium CDC?
    Polling is simple, works with any database and broker, but adds query load and latency and has the commit-order vs id-order trap. CDC reads the transaction log: low latency, commit order, no polling, but needs Kafka Connect, a replication slot and operations. Debezium provides the EventRouter SMT for the outbox pattern.
  • Q: What is the inbox pattern?
    A table of processed event ids, written in the same transaction as the business effect. A duplicate hits the primary key and is skipped. It turns at-least-once delivery into effectively-once processing.
  • Q: Why must the inbox row and the effect share a transaction?
    Otherwise a crash between them either skips the work (record first) or repeats it (work first).
  • Q: Saga: orchestration vs choreography?
    Choreography: services react to each other's events; no central state, hard to follow. Orchestration: a coordinator sends commands and tracks state; visible, but must stay thin. Use choreography for short simple flows, orchestration for longer flows with compensations and timeouts.
  • Q: What is a compensating transaction? Does it roll back?
    A new business action that semantically undoes a completed step (refund, release stock). It does not roll back: the earlier effect was committed and visible. Compensations must be idempotent and retried until they succeed.
  • Q: What are pivot and retriable transactions in a saga?
    Steps before the pivot are compensable. The pivot is the point of no return: if it succeeds the saga completes. After it, retriable steps must eventually succeed and are retried forever, never compensated.
  • Q: What goes wrong with timeouts in sagas?
    A late success can arrive after the saga already compensated. The state machine must reject or compensate it, and compensations must tolerate "nothing to undo".
  • Q: What is CQRS and what does it cost?
    Separate models for commands and queries. The read model is built from events, which makes it fast and shaped for queries, at the cost of eventual consistency, a projector to operate, and rebuild tooling. Not needed for simple CRUD.
  • Q: How do you rebuild a projection?
    Project the full event history into a new read model with a fresh consumer group or table, switch reads over when it has caught up, then drop the old one. Requires retained history and deterministic projection code.
  • Q: What is event sourcing and when would you avoid it?
    Storing events as the source of truth and deriving state by replay. Avoid it when you do not need history as a requirement: it brings complexity, permanent schema evolution, indirect querying and deletion/privacy difficulty. A normal database with an outbox gives reliable events more cheaply.
  • Q: Snapshots and upcasting?
    A snapshot is a cached state at version N so replay is shorter; it is only an optimisation. An upcaster converts old stored events to the current schema when loaded, so history never needs rewriting.
  • Q: Thin event vs event-carried state transfer?
    A thin event carries ids and requires callbacks to the producer. Event-carried state transfer carries the data consumers need so they can keep local copies and not call back. The second gives autonomy and larger, more coupled schemas.
  • Q: How do you evolve an event schema safely?
    Additive, optional fields only; tolerant readers; never rename or retype; breaking changes become a new version or type with a migration window; enforce with a schema registry compatibility mode (Confluent default BACKWARD) in CI.
  • Q: How do you implement request-reply over a broker?
    Correlation id plus a reply-to destination; responder echoes the id; requester matches replies and times out. Replies after timeout must be ignored. It is a synchronous call with more failure modes, so use it sparingly.
  • Q: How do you test an event-driven system?
    Consumer-driven contract tests for message shape, replay and duplicate tests for idempotency, integration tests with a real DB for the atomic outbox write, a test per saga failure path, and Awaitility instead of sleeps.
  • Q: Name event-driven anti-patterns.
    Events as RPC, distributed monolith, shared database, god events, assuming global order, missing idempotency, fire-and-forget publish.
  • Q: Does the outbox give exactly-once?
    No. It gives atomic recording plus at-least-once publication. With an idempotent consumer the effect happens once, which is what people usually mean by exactly-once.
13
Chapter Thirteen - Summary

Cheat Sheet and Recap

Cheat sheet
PatternProblem it solvesRemember
Transactional outboxDual writeEvent row in the same transaction; relay publishes; at-least-once
Polling relayGet outbox rows to the brokerFOR UPDATE SKIP LOCKED, small batches, stop on first failure, clean up
CDC (Debezium)Same, via the transaction logio.debezium.transforms.outbox.EventRouter; insert-only table
Inbox / idempotent receiverDuplicatesEvent id row + effect in one transaction
Saga (orchestrated)Business transaction over several servicesPersistent state, compensations newest first, timeouts, idempotent steps
Saga (choreographed)Short simple flowsCorrelation id everywhere; hard to see the whole
CQRSDifferent read and write needsProjections are idempotent, versioned, rebuildable; eventual consistency
Event sourcingHistory is the requirementEvent store, optimistic concurrency, snapshots, upcasters; heavy
Event-carried stateConsumer autonomyFat events vs thin events; contract is the schema
Request-replyAnswer needed via a brokerCorrelation id, reply-to, timeout, TTL
βˆ‘ Event-driven patterns - what to remember
  • No transaction spans a database and a broker. Write the event to an outbox in the business transaction and publish it from there.
  • The outbox trades lost events for duplicates. Every consumer must be idempotent, ideally with an inbox in the effect's transaction.
  • SKIP LOCKED gives parallel relays, not ordering. Order per aggregate needs a single lane or a shard per aggregate.
  • Sagas replace distributed transactions with compensations, which are business actions that must be idempotent and retried to success.
  • Orchestrate when the flow matters; choreograph when it is short and stable.
  • CQRS read models are disposable and eventually consistent; event sourcing is a heavier, separate decision that most services should not make.
  • Schemas are contracts: additive changes, tolerant readers, versions for breaking changes.
  • Test the failure paths: duplicates, reordering, crash between steps, late replies.
Version note

Checked on 2026-10-31: Debezium outbox event router (class name, option names and defaults) against the Debezium 3.7 documentation; Confluent Schema Registry default compatibility BACKWARD against the Confluent Platform docs; the PostgreSQL SKIP LOCKED clause against the current PostgreSQL SELECT reference. The Java samples in chapter 11 were compiled and run on JDK 21. The Spring code uses JdbcClient (Spring Framework 6.1+) and Spring Kafka 3.x KafkaTemplate; it was written against those APIs but not run against a live database or broker. Support for SKIP LOCKED in MySQL 8.0+ and Oracle is stated from general knowledge and was not re-verified here.

Next in this section