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.
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.
| Order of operations | What a crash in between causes | Typical symptom |
|---|---|---|
| Save to DB, then publish | Order exists, event is lost | Customer charged nowhere, no email, inventory never reserved. Found by a support ticket |
| Publish, then save to DB | Event 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 commit | Same as above if the commit then fails; also holds the DB transaction open on network I/O | Phantom events and connection-pool exhaustion when the broker is slow |
| Publish in an after-commit hook | Event lost if the process dies between commit and publish | Rare 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.
ApplicationEventPublisherwith@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 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.
| Column | Why it is there |
|---|---|
event_id | A 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_id | The 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) |
type | Event name in the past tense: OrderPlaced, PaymentCaptured. Consumers switch on it |
payload | The 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_at | For metrics ("age of the oldest unsent row") and cleanup |
sent_at | Set by the polling relay. A CDC relay does not need it (chapter 03) |
- 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 onidyou must account for it.
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).
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.
- **
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 itREADPAST. WithoutSKIP 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:
@Scheduledneeds@EnableSchedulingon a configuration class.fixedDelaywaits for the previous run to finish, so runs on one instance never overlap.
- 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_idso that each relay owns its own shard.
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 reads | A query on the table every N ms | Reads the database transaction log (PostgreSQL WAL, MySQL binlog) as changes are committed |
| Latency | Up to the poll interval plus the query time | Typically low and steady: driven by the log, not by a timer |
| Load on the database | A repeated query plus UPDATEs of sent_at; index maintenance on a hot table | No polling query and no sent_at UPDATE; rows are only INSERTed. Needs a replication slot (PostgreSQL), which retains WAL if the connector falls behind |
| Ordering | Commit order is not id order (chapter 02); you must design for it | Log order is commit order, so events leave in the order they were committed |
| Moving parts | Your application only | Kafka Connect cluster, the connector, DB permissions and replication settings, connector monitoring |
| Works with | Any SQL database, any broker (Kafka, RabbitMQ, SQS) | Databases with a Debezium connector; the standard path writes to Kafka |
| Best when | Moderate volume, small team, no Kafka Connect yet, a non-Kafka broker | High volume, low-latency needs, or you already operate Kafka Connect |
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.
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).
- 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.
| Concern | Practice |
|---|---|
| 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 |
| Monitoring | Alert on age of the oldest unsent row (or CDC connector lag), not on queue depth alone. A stuck relay looks like silence |
| Poison rows | A 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 |
| Batching | Batch for the read and the UPDATE, but keep publishing ordered per aggregate. Bigger batches mean longer transactions and longer lock time |
| Payload size | Keep 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 |
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.
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.
| Technique | How | Use when |
|---|---|---|
| Naturally idempotent operation | "Set status to SHIPPED", upsert by business key, "set balance to X" | Always prefer it: no extra table |
| Inbox table | Record event_id in the same transaction as the effect | The effect is not naturally idempotent (charge a card, send an email record, increment a counter) |
| Conditional update / version check | UPDATE ... WHERE id = ? AND version = ? | Events carry the entity version; stale or repeated events change nothing |
| Downstream idempotency key | Pass event_id to the payment API as its idempotency key | The effect is a call to an external system you cannot put in your transaction |
- 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.
- 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.
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 | Orchestration | |
|---|---|---|
| Control flow | Distributed: each service listens for events and emits its own | Central: the orchestrator sends commands and reads replies |
| Where the saga state lives | Nowhere explicit (implied by events already published) | One persistent record per saga instance |
| Coupling | Services know the events of their neighbours | Services know only their own commands; the orchestrator knows all of them |
| Visibility | Needs tracing across topics to see a flow | Ask the orchestrator for the state of order 42 |
| Cyclic dependencies | Easy to create by accident (A reacts to B reacts to A) | Avoided: dependencies point from orchestrator outward |
| Risk | The flow is nowhere in the code; changes touch many services | The orchestrator can become a "god service" that holds business rules which belong to the services |
| Good fit | Two or three steps, few participants, stable flow | Four or more steps, compensations, timeouts, a flow that changes |
- 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.
- 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.
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).
| Step | Command | Success event | Failure event | Compensation |
|---|---|---|---|---|
| 1. Authorize payment | AuthorizePayment | PaymentAuthorized | PaymentDeclined (end: nothing to undo) | VoidAuthorization / RefundPayment |
| 2. Reserve stock | ReserveStock | StockReserved | StockUnavailable | ReleaseStock |
| 3. Request shipment (pivot) | RequestShipment | ShipmentAccepted | ShipmentRejected | None once the carrier has shipped: handled as a business process (return, refund) |
| 4. Notify customer | SendConfirmation | (none needed) | Retry until it succeeds | None (retriable) |
AuthorizePaymentSTARTEDPaymentAuthorized arrivesPAYMENT_AUTHORIZED and writes the ReserveStock command to its outbox, in one transactionStockUnavailable arrivesREFUNDING. Orchestrator sends VoidAuthorization, waits for PaymentVoided, then sets CANCELLED and marks the order cancelledShipmentRejected arrivesRELEASING_STOCK, then REFUNDING, then CANCELLED: compensate in reverse order of the completed stepsA 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.
- Compensations are commands too, delivered at least once.
RefundPaymentfor the samesaga_idtwice must refund once: key the refund bysaga_idand 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.
| Kind | Meaning | Example here | Design rule |
|---|---|---|---|
| Compensable | Can be undone | Authorize payment, reserve stock | Put these first and give each a compensation |
| Pivot | The go/no-go point: after it succeeds the saga will complete, if it fails the saga compensates | Request shipment with the carrier (or capture payment) | Exactly one per saga; keep it as late as possible |
| Retriable | Follows the pivot and must eventually succeed; retry forever | Send confirmation, update loyalty points | Make idempotent; never compensate |
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.
- If events do not carry
saga_idororder_id, each service has to guess which flow an event belongs to. Put the correlation id (and theevent_id, plus acausation_idpointing at the event that caused this one) in every message envelope from day one.
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.
| Level | What changes | Cost |
|---|---|---|
| 1. Separate command and query code paths | Same database; commands and queries are different classes and DTOs | Almost none; often just good layering |
| 2. Separate read tables / views, updated in the same transaction | A denormalised table maintained by the write path | More writes, strong consistency preserved |
| 3. Separate read store updated by events | Different store (search index, Redis, a second database) fed by a projector from the event stream | Eventual consistency, a projector to run and monitor, rebuild tooling |
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.
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.
- 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.
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.
- 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.
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.
| Piece | What it is | Notes |
|---|---|---|
| Event store | Append-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) |
| Aggregate | Loads its events, applies them to rebuild state, validates a command, emits new events | The write model of CQRS |
| Snapshot | A saved copy of state at version N so replay starts there | A pure optimisation: delete all snapshots and nothing is lost. Take one every few hundred events, not for every aggregate |
| Projection | A read model built from the events (chapter 07) | Event sourcing and CQRS are separate ideas that fit together; you can use either alone |
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.
- 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.
- 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.
- 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.
Event-Carried State, Schema Evolution and Request-Reply
| Thin (notification) event | Event-carried state transfer (fat event) | |
|---|---|---|
| Content | Ids and the fact: { orderId, type: "OrderShipped" } | The data consumers need: address, lines, totals |
| Consumer needs | A callback (REST) to fetch details | Nothing: it updates its own local copy from the event |
| Coupling | Runtime coupling: consumer calls the producer, which must be up and fast | Data coupling: the event schema is the contract; no call-back |
| Staleness | Fetch returns current state, which may be newer than the event | Event shows state at that moment; consumer's copy lags |
| Risk | Thundering herd of callbacks after a burst; producer outage stalls consumers | Large messages; leaking data a consumer should not have; schema changes ripple |
| Best when | Few consumers, data is sensitive or large | Many 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.
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-configuredObjectMapper. - 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
schemaVersionandeventTypein the envelope** so a consumer can choose a parser.
| Strategy | How | Trade-off |
|---|---|---|
| Schema registry with compatibility checks | Avro, 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 variant | Enforced in CI/deploy; extra infrastructure and serialiser setup |
| Versioned event types | OrderPlacedV1, OrderPlacedV2; consumers handle both | Explicit; more types to maintain |
| Upcasting at read | Convert old to new when loading (chapter 08) | Consumer logic stays on the latest version; upcasters accumulate |
| Dual publishing | Produce old and new during migration, retire the old | Safe for consumers; temporary duplication |
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.
| Element | Purpose | Detail |
|---|---|---|
| Correlation id | Match the reply to the request | A fresh unique id per request, copied unchanged into the reply |
| Reply destination | Where the answer goes | A per-instance reply queue/topic (so the reply reaches the instance that is waiting), or a shared reply topic plus a filter |
| Timeout | Do not wait forever | The requester gives up and treats it as failure; the reply may still arrive later and must be ignored (idempotent, no waiting caller) |
| Expiry | Do not do work nobody awaits | Put 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.
Testing Event-Driven Systems and Avoiding the Anti-Patterns
| What to test | Technique | Notes |
|---|---|---|
| Producer and consumer agree on the message | Consumer-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 idempotent | Replay test: feed the same event twice (and a sequence with duplicates and reordering) and assert one effect and the same final state | The cheapest test with the highest payoff in this whole page |
| Atomic outbox write | Integration 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 exists | Proves the transaction boundary |
| Saga paths | A test per failure point: payment declined, stock out, shipping fails, timeout then late success | Assert final state and that every compensation ran exactly once |
| Projections | Replay a recorded stream into an empty projection; compare with a known-good snapshot | Doubles as a rebuild rehearsal |
| Crash recovery | Kill 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 |
Never Thread.sleep in a test. Poll with a timeout. Awaitility (org.awaitility:awaitility, included in spring-boot-starter-test) is the standard tool:
| Anti-pattern | What it looks like | Why it hurts | Fix |
|---|---|---|---|
| Events as remote procedure calls | A "event" CalculateTaxRequested that expects a TaxCalculated back for the flow to continue | A synchronous call dressed as messaging: all the latency and failure modes of both | Use a real command with request-reply, or a plain API call; use events only for facts that already happened |
| Distributed monolith | Services that cannot be deployed or fail independently, chained by events in a fixed order | You paid for distribution and kept the coupling | Re-draw service boundaries around business capabilities; reduce chatty flows |
| Shared database | Two 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 pointless | Each service owns its data; share through events or APIs |
| God event | One OrderChanged with 80 fields that every service consumes | Every consumer breaks on every change; no one knows who needs which field | Small, specific events (OrderPlaced, OrderCancelled) with a stable contract |
| Assuming global order | Consumer logic that breaks if OrderCancelled arrives before OrderPlaced | Order exists only per partition key; retries and parallel consumers reorder | Key by aggregate id; use versions; handle out-of-order gracefully |
| No idempotency | Handlers that charge, email or increment on every delivery | Duplicates are normal; this is a double charge waiting for a rebalance | Chapter 04: inbox or natural idempotency |
| Fire and forget the publish | DB save then kafka.send without outbox or error check | The dual-write problem | Chapters 01 and 02 |
| Events carrying secrets and PII | Full personal data in events retained for months, copied by every consumer | Data spread, deletion rights hard to honour | Minimise payloads, reference by id, encrypt where needed |
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.
- The event is never lost. Order and outbox row are created together, so after the crash
evt-1is still unsent and the restarted relay sends it. - The cost is a duplicate.
evt-1is 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=3for 2 orders). - With an inbox it is harmless (
charges=2): the secondevt-1hits 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.
- Make the compensation non-idempotent (for example
stock[0]++run twice) and callcompensatetwice 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 aSet<String>of compensated step names per saga to fix it.
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 LOCKEDdo 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 theEventRouterSMT 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 defaultBACKWARD) 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.
Cheat Sheet and Recap
| Pattern | Problem it solves | Remember |
|---|---|---|
| Transactional outbox | Dual write | Event row in the same transaction; relay publishes; at-least-once |
| Polling relay | Get outbox rows to the broker | FOR UPDATE SKIP LOCKED, small batches, stop on first failure, clean up |
| CDC (Debezium) | Same, via the transaction log | io.debezium.transforms.outbox.EventRouter; insert-only table |
| Inbox / idempotent receiver | Duplicates | Event id row + effect in one transaction |
| Saga (orchestrated) | Business transaction over several services | Persistent state, compensations newest first, timeouts, idempotent steps |
| Saga (choreographed) | Short simple flows | Correlation id everywhere; hard to see the whole |
| CQRS | Different read and write needs | Projections are idempotent, versioned, rebuildable; eventual consistency |
| Event sourcing | History is the requirement | Event store, optimistic concurrency, snapshots, upcasters; heavy |
| Event-carried state | Consumer autonomy | Fat events vs thin events; contract is the schema |
| Request-reply | Answer needed via a broker | Correlation id, reply-to, timeout, TTL |
- 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.
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.
- 08 Reliability & Operations - monitoring lag, retries, DLQs and running these patterns in production.
- 04 Kafka with Spring - the listener, producer and error-handling configuration used in this page.
- 01 Messaging Fundamentals - delivery guarantees and idempotency basics.
- Spring: Transactions - the transaction boundaries the outbox and inbox depend on.