The Outbox Pattern, Done Right

Transactional outbox vs. dual-write; idempotent consumers; ordering and deduplication.

Chapter 20Data Across the Seam

Chapter 17 introduced the transactional outbox at the exact moment the monolith needed it: the Notification extraction had to stop running a third-party-adjacent side effect inside checkout’s own @Transactional, and the outbox was the mechanism that let the write survive without smuggling a second system into that transaction’s blast radius. That was the right amount of outbox for a chapter whose real subject was event-driven extraction. This chapter’s subject is the outbox itself — not what it is, but what it costs to run correctly once checkout depends on it in production rather than a one-paragraph aside. Every concern below is a concern a team actually hits running this exact mechanism in production: what happens when delivery duplicates (it will), what happens when two events for the same order arrive out of order (they can, if you key wrong), how fast “eventually” really is, when to stop polling a table and start tailing a log instead. None of this is new code. It is the same examples/00-monolith/ outbox and the same examples/03-notification-service/ consumer Chapter 17 already built, read again at the depth a reader needs before they’d trust this pattern with their own checkout flow.

The code is in examples/00-monolith/ and examples/03-notification-service/ — the same two example directories Chapter 17 built. There is no new run script for this chapter; the run scripts already there build/set up and run the outbox write, the relay, and the consumer exactly as described below, and each directory’s README.md covers how to drive them.

The dual-write problem: two systems, one guarantee you don’t have

Here is the version of “save to the database, then publish to Kafka” that looks correct and isn’t:

// NOT what this project does — the pattern this chapter explains the cure for
orderRepository.save(order);                      // write 1: Postgres
kafkaTemplate.send("order.placed", event);         // write 2: Kafka
return toDto(order);

It looks correct because, read top to bottom on the happy path, it does exactly what the business wants: persist the order, then tell the world about it. The problem is that these are two independent writes to two independent systems over two independent network paths, and nothing anywhere in that snippet makes them succeed or fail together. Kleppmann’s Designing Data-Intensive Applications names this precisely as the dual-write problem: whenever an application needs to keep two systems in sync by issuing two separate writes from its own code, there is a window — however small — in which one write has landed and the other has not, and the application has no way to make that window disappear just by writing careful code around it. The failure modes are not exotic. The process can crash between the two lines. The Kafka client can time out while Postgres already committed. The pod can be rescheduled by the orchestrator mid-method. Every one of these leaves the order persisted with no event ever published for it — silently, because nothing downstream is watching for an order that exists with no corresponding event, only for an event that failed to send. The inverse ordering (publish first, then save) is not a fix, only a relocation of the same hole: now a customer can receive a notification for an order that was never actually persisted, because the save failed after the publish already succeeded. There is no sequencing of two independent writes to two independent systems that closes this gap. You can retry, you can log, you can add a circuit breaker — none of it changes that the two writes are not one operation, and “the two writes are not one operation” is the entire problem.

It is worth being precise about the vocabulary here, because marketing copy for message brokers throws around “exactly-once” in a way Kleppmann’s treatment of stream processing is careful to puncture. No distributed system spanning an unreliable network can deliver a message exactly once end to end — the sender can never be certain the receiver got it without an acknowledgment, and the acknowledgment itself can be lost, which forces a retry, which is a duplicate. What a well-built pipeline can actually promise is at-least-once delivery paired with idempotent processing on the receiving end, and the combination of the two is what the literature calls effectively-once: duplicates may arrive, but processing them twice produces the same observable state as processing them once. That phrase — at-least-once plus idempotent — is this chapter’s spine. The outbox supplies the first half. The consumer you’ll see below supplies the second. Neither half alone is the pattern; the pattern is the pair.

The transactional outbox: moving the guarantee inside one transaction

The outbox’s fix for the dual-write problem is to stop treating “persist the order” and “record that an event needs publishing” as two writes to two systems, and instead make the second write a write to the same system as the first — the same Postgres database, the same transaction, the same commit. OutboxEvent is the entity that row becomes, and OrderService writes it in the identical @Transactional method that persists the order, charges the card, and dispatches the shipment:

// examples/00-monolith/.../order/OrderService.java#placeOrder (excerpt)
paymentService.charge(order, order.getTotalCents(), command.paymentMethod());
order.confirm();
shippingService.dispatch(order, order.getShippingAddress());

// write the event to the outbox, atomically, in THIS transaction. No Kafka
// client is touched here — the @Scheduled OutboxRelay is the only thing
// that talks to Kafka, on its own schedule, after this transaction has
// committed (or not).
writeOrderPlacedOutboxEvent(customer, order);
return toDto(order);

Nothing about this is exotic — writeOrderPlacedOutboxEvent is an ordinary outboxRepository.save(...) call, the same kind of write orderRepository already made two lines earlier. That ordinariness is exactly the point: the outbox row is not infrastructure glued on beside the business transaction, it is one more row written by that transaction, subject to the identical commit-or-rollback behavior as every other row it touches. A payment decline that throws from paymentService.charge(...) unwinds the whole method — the order row, the inventory reservation, and the outbox write, because none of them has committed yet. There is no interval, however narrow, in which the order exists and the outbox row doesn’t, or the reverse — which is precisely the guarantee a dual-write across two systems structurally cannot offer, no matter how its two calls are ordered or wrapped.

What the outbox does not do is make the second hop — outbox row to Kafka — atomic with anything. That hop is a separate, asynchronous OutboxRelay, and the gap between “the business fact is durable” and “the world has been told” is now explicit, bounded, and owned by one piece of infrastructure instead of hidden inside application code that pretends it isn’t there. Closing the first gap (business-fact atomicity) by deliberately accepting a second, smaller, well-understood gap (publish latency) is the trade this pattern makes, and the remainder of this chapter is about running that second gap correctly.

The dual-write hazard versus the transactional outbox, stacked for contrast: on top, saving to the database and publishing to Kafka as two independent writes with a gap nothing closes; on the bottom, the business write and the outbox row committing together in the same transaction, with the second hop to Kafka made explicit and owned by the relay.
Figure 20.1 — Dual-write versus the transactional outbox

How the code works

Four pieces carry this pattern end to end: the row, the relay that publishes it, the write that creates it, and the consumer that absorbs its at-least-once delivery. Each is real, already-running code.

The outbox pattern's four pieces as one labeled flow: the write that persists the outbox row inside placeOrder's transaction, the relay that polls for unpublished rows and publishes them to Kafka, the broker topic the relay blocks on for acknowledgment, and the idempotent consumer that checks before inserting.
Figure 20.2 — The outbox pattern's four pieces: write, relay, broker, consumer

The row — OutboxEvent. Its columns are the minimum needed to answer the relay’s one question (“what is unpublished, and in what order should I send it?”) and the consumer’s one question (“what happened, and to which aggregate?”):

// examples/00-monolith/.../common/outbox/OutboxEvent.java
@Entity
@Table(name = "outbox")
public class OutboxEvent {
    @Id @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    @Column(name = "aggregate_type", nullable = false) private String aggregateType;
    @Column(name = "aggregate_id", nullable = false)   private String aggregateId;
    @Column(name = "event_type", nullable = false)     private String eventType;
    @JdbcTypeCode(SqlTypes.JSON)
    @Column(name = "payload", nullable = false)        private String payload;
    @Column(name = "created_at", nullable = false)      private Instant createdAt;
    @Column(name = "published_at")                      private Instant publishedAt;
    // ...
    public void markPublished() { this.publishedAt = Instant.now(); }
}

aggregateId exists for one reason only, and it drives two different downstream behaviors you’ll meet below: it becomes the Kafka message key (ordering), and it’s the field the consumer dedupes on (idempotency). payload is stored as jsonb and the relay never parses it — it only ever forwards the string byte-for-byte to Kafka, which is deliberate: the relay’s job is “move bytes reliably,” not “understand bytes,” and keeping it ignorant of the payload’s shape means a new event type never requires touching the relay. createdAt/publishedAt are the two timestamps that turn “is this row done” into a one-column WHERE clause (published_at IS NULL) instead of a join or a separate status table — the simplest state machine that could possibly work, with exactly two states.

The write — OrderService#writeOrderPlacedOutboxEvent. This is a plain method, called from inside placeOrder’s @Transactional, that builds the JSON payload and persists the row through the same OutboxRepository the relay reads from:

// examples/00-monolith/.../order/OrderService.java (excerpt)
private void writeOrderPlacedOutboxEvent(Customer customer, Order order) {
    String confirmationMessage =
            "Order #%d confirmed, total $%.2f".formatted(order.getId(), order.getTotalCents() / 100.0);
    OrderPlacedEvent event = new OrderPlacedEvent(
            order.getId(), customer.getId(), customer.getEmail(),
            order.getTotalCents(), confirmationMessage, Instant.now());
    try {
        String payload = objectMapper.writeValueAsString(event);
        outboxRepository.save(new OutboxEvent(
                "Order", String.valueOf(order.getId()), Topics.ORDER_PLACED, payload));
    } catch (JsonProcessingException e) {
        throw new IllegalStateException("Failed to serialize OrderPlacedEvent for order " + order.getId(), e);
    }
}

Two decisions here matter more than they look. First, String.valueOf(order.getId()) becomes the outbox row’s aggregateId — the order’s own primary key, not a synthetic event id, which is what lets the consumer dedupe by the business entity rather than by an opaque message identifier it would otherwise have to invent and track. Second, the catch block does not swallow a serialization failure and move on — it rethrows as an unchecked exception, which rolls back the whole transaction. That looks harsh (a JSON serialization bug now fails checkout, not just the notification), but it’s the correct failure mode for an outbox: silently dropping the write would mean the order commits with no event ever recorded for it, the exact failure the entire pattern exists to prevent. A loud failure here is strictly better than a quiet one.

The relay — OutboxRelay. A @Scheduled method polls on a fixed delay, pulls a bounded batch of unpublished rows oldest-first, and publishes each:

// examples/00-monolith/.../common/outbox/OutboxRelay.java
@Scheduled(fixedDelayString = "${outbox.relay.poll-interval-ms:2000}")
public void publishUnpublishedEvents() {
    List<OutboxEvent> batch = outboxRepository.findTop50ByPublishedAtIsNullOrderByCreatedAtAsc();
    for (OutboxEvent event : batch) {
        publishOne(event);
    }
}

private void publishOne(OutboxEvent event) {
    try {
        kafkaTemplate.send(topic, event.getAggregateId(), event.getPayload())
                .get(5, TimeUnit.SECONDS);
        event.markPublished();
        outboxRepository.save(event);
    } catch (ExecutionException | TimeoutException e) {
        // left unpublished; picked up again next tick
    }
}

Walk this call by call. findTop50ByPublishedAtIsNullOrderByCreatedAtAsc() is backed by the partial index idx_outbox_unpublished from V3__outbox.sql (CREATE INDEX ... ON outbox (created_at) WHERE published_at IS NULL), so this query stays a cheap index scan over the unpublished rows only, regardless of how many published rows have piled up — the published rows simply never enter the index. The cap at fifty exists so one scheduled tick can never hold an unbounded amount of work if the relay falls behind; a backlog of ten thousand unpublished rows gets drained across two hundred ticks instead of one enormous one. kafkaTemplate.send(topic, event.getAggregateId(), event.getPayload()) passes the aggregate id as the Kafka record key — not null, not the outbox row’s own surrogate id — and that one argument is what makes partitioning-by-aggregate work at all, covered in its own section below. .get(5, TimeUnit.SECONDS) is the single most consequential line in the method: it turns an asynchronous Kafka send into a blocking call the relay waits on, and it is what makes event.markPublished() on the next line trustworthy — by the time that line runs, the broker has already acknowledged the write. The catch block does nothing but log: it does not rethrow, it does not mark the row in any special failed state, it just lets the loop continue to the next event, and leaves this one for the next tick to retry.

The consumer — NotificationService#recordOrderPlaced. On the far side of Kafka, this is the method that has to assume the relay’s at-least-once guarantee will occasionally mean the same event arrives twice:

// examples/03-notification-service/.../NotificationService.java
@Transactional
public void recordOrderPlaced(OrderPlacedEvent event) {
    if (repository.findByOrderId(event.orderId()) != null) {
        return;
    }
    Notification notification =
            new Notification(event.customerId(), event.orderId(), "EMAIL", event.confirmationMessage());
    repository.persist(notification);
}

findByOrderId first, insert second — check-then-insert, keyed on the exact aggregateId the outbox row carried across the wire as orderId. For the ordinary case (a single consumer replica, redeliveries arriving one at a time, which is the common case for a batch size of fifty and a two-second poll) this is the entire idempotency story, and it is cheap: a redelivered event costs one indexed SELECT and a no-op return, not a thrown exception caught somewhere upstream.

The fragile bit this walkthrough has to name: the check and the insert above are two separate statements, not one atomic operation, so two deliveries of the same event landing concurrently — two consumer replicas, or a redelivery racing a still-in-flight first attempt — can both pass the findByOrderId check before either one’s insert commits. The application code alone cannot close that window; only the database can, which is exactly why the next section exists as a second, independent layer rather than a nice-to-have.

The transactional-outbox guarantee contrasted with the smell it replaces: the old flow where checkout called notification synchronously inside the same transaction, any failure rolling back every other write; and the new flow where the last write before commit is an outbox row, published later by the relay to a consumer that is idempotent by construction.
Figure 20.3 — The old synchronous call versus the new outbox-and-relay flow

Done right, concern by concern

At-least-once delivery means the consumer must own idempotency, in two layers

The relay’s own class-level comment states its guarantee without softening it: “the Kafka send and the published_at stamp are two separate operations, not one atomic unit.” If the process dies in the gap between them — broker acked, stamp never committed — the row still reads as unpublished on the next poll and gets sent again, byte-for-byte identical. This is not a bug to be engineered away; it is the one guarantee a polling relay (or, as the next section shows, a log-tailing one) cannot give you for free, because “write to Postgres” and “write to Kafka” remain two different systems even after the outbox has made the first write — order plus event row — atomic. Accepting at-least-once at the relay is only safe because the consumer is idempotent, and this project’s consumer is idempotent by two independent layers, not one:

  • Layer one — the application check-then-insert shown above. This is the cheap, common-case path: an ordinary redelivery costs a read and a no-op, never a thrown exception.
  • Layer two — a database constraint that holds even when layer one races and loses:
-- examples/03-notification-service/.../V3__idempotent_order_id.sql
CREATE UNIQUE INDEX IF NOT EXISTS uq_notifications_order_id
    ON notification.notifications (order_id)
    WHERE order_id IS NOT NULL;

A partial unique index, scoped to rows where order_id IS NOT NULL so a future notification type that doesn’t originate from an order is never forced into the same constraint, is the backstop Postgres itself enforces regardless of what the application code believed when it ran its own check. The two layers answer different questions rather than duplicating one answer: the application check is the fast path that keeps the common case from ever touching a constraint violation; the unique index is the guarantee that holds in the one case application logic structurally cannot close on its own — two writers racing past the same read at nearly the same instant. Either layer alone leaves a gap: the application check alone has the race condition above; the unique index alone would work, but every ordinary redelivery would pay for a thrown-and-caught constraint violation instead of a cheap no-op read. Running both is not belt-and-suspenders paranoia — it’s matching each layer to the specific failure it, and only it, actually prevents.

Ordering: partition by aggregate key, not by none and not by everything

A Kafka topic’s ordering guarantee is per-partition, not per-topic — the broker promises that messages within one partition are delivered to a consumer in the order they were produced, and promises nothing about ordering across partitions. That single fact decides how an outbox relay must choose its message key. OutboxRelay calls kafkaTemplate.send(topic, event.getAggregateId(), event.getPayload()) — the second argument is the record key, and Kafka’s default partitioner hashes that key to select a partition, meaning every event sharing one aggregateId (one order) lands in the same partition every time and is therefore delivered to any given consumer in the exact order the relay sent them. That is the only ordering guarantee this pattern needs: a reader of the notification service never requires that order #41’s event be processed before order #42’s — those are independent aggregates whose events can interleave freely — but it does require that, if a single order ever produces more than one event in its lifetime (this chapter’s single order.placed topic doesn’t yet, but a future order.cancelled alongside it would), those events arrive in the sequence they were written. Keying by null would scatter even one order’s own events across every partition at random, destroying that guarantee for no benefit; keying by a constant would force every event through one partition regardless of which order it describes, destroying Kafka’s ability to parallelize consumption across partitions for no benefit either. Keying by the aggregate id is the one choice that buys ordering exactly where it’s needed and nowhere it isn’t, and it’s a design decision made once, at the relay, rather than something each future consumer has to reconstruct. In this project’s development and CI setup the order.placed topic auto-creates with a single partition, so every event is trivially ordered regardless of key — the keying choice is what makes it safe to grow the topic to many partitions later without silently breaking per-order ordering the day someone bumps the partition count for throughput.

The relay’s poll-interval and the latency it buys

outbox.relay.poll-interval-ms defaults to two thousand milliseconds, and that single number is the whole latency-versus-load trade-off in one place. Shorten it and a customer’s order confirmation arrives sooner after checkout, at the direct cost of the relay running its unpublished-rows query more often against the same table every other request is also writing to. Lengthen it and that read load drops, at the direct cost of a customer waiting longer, on average half the interval and up to the full interval in the worst case, before their event is even picked up for publishing — before any of the broker round-trip or consumer processing time is added on top. Chapter 17’s own cutover evidence puts a number on the composed effect: observed checkout-to-notification latency around five seconds end to end against this exact two-second poll interval, batch size, and broker round-trip. Nothing about two seconds is a law of nature — it’s a tuned constant, chosen to be fast enough to feel reasonably responsive in a demo without hammering Postgres with a sub-second poll loop, and it’s exactly the kind of fragile, environment-specific number the depth standard asks a chapter to name rather than bury: a reader deploying this relay under real load should expect to retune it against their own table size, their own write volume, and their own tolerance for “eventually.”

published_at is stamped only after the broker acknowledges

It bears restating as its own concern, because getting the order of these two lines wrong is the single easiest way to quietly break the at-least-once guarantee into something worse: .get(5, TimeUnit.SECONDS) blocks until the broker has acknowledged the send, and only then does event.markPublished() run, followed by the save that commits the stamp. If those two lines were reversed — mark published first, send second — a crash between them would leave a row permanently stamped as published whose event was never actually sent, and nothing in the system would ever notice or retry it, because the relay’s own query explicitly excludes published rows. That failure mode is strictly worse than a duplicate: a duplicate is merely redundant work for an idempotent consumer to absorb, while a falsely-stamped row is a silently lost event, which is the exact failure the entire outbox pattern exists to rule out. “Publish, then stamp” is not a stylistic preference; it’s the line that decides whether this mechanism’s failure mode is “occasionally redundant” or “occasionally silent,” and those are not close to equally bad.

Cleanup and retention — the gap this project leaves open

Here is a fragile bit worth naming rather than glossing over: nothing in examples/00-monolith/ deletes, archives, or partitions published outbox rows. idx_outbox_unpublished’s partial condition means the relay’s own hot query never slows down as published rows accumulate — that index simply never indexes them — but the table itself still grows forever, and an unbounded table is eventually a problem for autovacuum, for backup size, and for anyone who has to run an unindexed diagnostic query against outbox someday. A production deployment of this exact pattern needs a retention policy this codebase does not yet implement: a second scheduled job, running far less often than the publishing relay, that deletes (or moves to cold storage) rows where published_at is older than some retention window — long enough to outlive any plausible redelivery or audit need, short enough to keep the table bounded. This chapter is naming the gap rather than closing it, in keeping with the standing of every example in this book: credit the code with exactly what it does, and no more.

Poison messages — the retry this relay doesn’t yet bound

publishOne’s catch block leaves a failed row unpublished unconditionally, for any ExecutionException or TimeoutException, with no retry counter and no circuit breaker. For a transient failure — the broker briefly unreachable, a timeout under load — that is exactly the right behavior: the next tick tries again, and it eventually succeeds once the broker recovers. But the same code path handles a permanently failing event identically to a transiently failing one: a payload the broker consistently rejects (an oversized message past max.request.size, say) will be re-attempted, fail, and get re-attempted again, forever, on every single poll, since it keeps sorting to the front of “oldest unpublished first.” It never blocks other events in that batch — the for loop continues past a caught exception to the next row — but it does consume one of the batch’s fifty slots on every tick indefinitely, and a handful of such rows would eventually crowd out fresh events from ever being selected. A hardened version of this relay would track an attempt count per row (one more column), cap retries, and move a row that exceeds the cap into a separate dead-letter table or topic for a human or an automated remediation process to inspect — the event is never silently lost, but it also stops competing with healthy events for the relay’s limited per-tick attention. That hardening is not present in examples/00-monolith/ today; naming it here is this chapter doing exactly what the depth standard asks of a fragile bit, not waving it away as solved.

Two relay strategies: polling versus log-based CDC

Everything above describes one way to read an outbox table — have a scheduled process poll it. That is not the only way, and it is worth being precise about the alternative before deciding which one a given extraction deserves.

A polling relay, which is what this project’s notification pipeline uses, is a scheduled job that periodically issues an ordinary SELECT against the outbox table, looking for rows matching some “not yet published” predicate. Its cost model is straightforward: it adds a recurring read against a table your application is also writing to, and its latency is bounded below by the poll interval — an event can never be published faster than the next scheduled tick notices it, no matter how fast the broker itself responds. Its operational footprint is close to zero: it is a method in the application’s own process, with no new infrastructure to run, patch, or monitor beyond the application itself.

A log-based CDC relay — the kind a connector like Debezium implements — takes a structurally different approach: instead of asking the table “what’s new” on a schedule, it attaches to the database’s write-ahead log (the same log Postgres uses internally for crash recovery and streaming replication) and is pushed every row-level change the instant it’s written, in commit order, with no polling interval to wait out. Latency drops to roughly the time it takes the WAL record to reach the connector — commonly tens to a few hundred milliseconds rather than a multi-second poll cadence — and there is no periodic table scan competing with application traffic at all, because the mechanism is push-based rather than pull-based. That latency and read-load win is not free. Reading the WAL requires a replication slot — a durable bookmark the database keeps so it knows how much WAL history it must retain until the connector has consumed it, and a connector that falls behind or disappears can cause that retained WAL to grow unboundedly, a failure mode a polling relay simply cannot produce because it never asks the database to retain anything on its behalf. It also typically means standing up Kafka Connect (or an equivalent CDC runtime) as a new piece of infrastructure, with its own deployment, its own monitoring, its own upgrade cadence, and its own connector-specific configuration surface — real, ongoing operational weight that a two-line @Scheduled method inside an existing Spring process does not carry.

The decision this project made (DRQ-034, recorded in decisions.md) was deliberate about not paying that weight on the very first extraction to need an outbox: Notification’s event volume and latency tolerance don’t demand sub-second delivery, so a polling relay teaches the outbox’s real guarantee — atomic write, at-least-once publish — without pulling a connector and a replication slot into the stack a chapter early, purely to look more sophisticated than the problem requires. That calculus changes for the Inventory extraction. Stock levels are read on nearly every product page and checkout attempt across the whole system; a multi-second lag between a reservation committing and that fact becoming visible downstream is a much more direct cost there than a few extra seconds before a confirmation email is queued. The Inventory extraction (covered in Transaction Log Tailing, CDC & Extraction 3 — Inventory) is exactly where this project adopts Debezium-style CDC — not because CDC is categorically superior to polling, but because that extraction’s latency and read-load requirements are different enough from Notification’s to justify the connector and the replication slot the polling relay above got to avoid. The general rule this pair of chapters is teaching: reach for a polling relay first, because it is the plain, low-ceremony default; reach for log-based CDC only once a specific, named latency or load requirement can’t be met by shortening the poll interval, because that connector is infrastructure you now operate forever, not a line of configuration you tune once.

What you learned

  • The dual-write problem is structural, not a matter of careless sequencing: two independent writes to two independent systems can never be made atomic by application code alone, and Kleppmann’s framing of “effectively-once” (at-least-once delivery plus idempotent processing) is the realistic target, not the marketing fiction of “exactly-once.”
  • The transactional outbox closes that gap for the first hop only — the business write and the event row commit or roll back together, because they’re the same transaction on the same database — and leaves the second hop (table to broker) at-least-once, a trade this project’s relay documents rather than hides.
  • At-least-once delivery is only safe paired with a consumer that is idempotent on two independent layers: a cheap application-level check-then-insert for the common case, and a database constraint as the backstop for the race the application layer alone cannot close.
  • Keying by aggregate id, not by nothing and not by a constant, is what gives an outbox event stream the one ordering guarantee it actually needs — in order within an aggregate — without sacrificing a broker’s ability to parallelize across aggregates.
  • A polling relay is the right default — simple, low-ceremony, predictably bounded by its poll interval — right up until a specific latency or read-load requirement can’t be met by tuning that interval, at which point log-based CDC (Debezium reading the WAL via a replication slot) trades a lower latency floor for a connector you now operate permanently; this project makes that trade for the first time in the Inventory extraction.

Event Sourcing & CQRS picks up directly from the event stream this chapter hardened: once order.placed (and the events the Inventory and Payment extractions add beside it) can be trusted to arrive at-least-once, in order per aggregate, and to be processed exactly-effectively-once, that stream becomes a credible source from which a read model can be built and kept current — which is precisely the CQRS read side that chapter constructs. Transaction Log Tailing, CDC & Extraction 3 — Inventory is the chapter that actually puts log-based CDC into production in this project, the alternative relay strategy previewed above.


Verification status: unverified. Every artifact cited above is real, already-running code already verified by Chapter 17’s own cutover evidence: examples/00-monolith/’s OutboxEvent, OutboxRelay, OutboxRepository, OrderPlacedEvent, V3__outbox.sql, and OrderService#placeOrder/#writeOrderPlacedOutboxEvent; and examples/03-notification-service/’s OrderPlacedConsumer, NotificationService#recordOrderPlaced, and V3__idempotent_order_id.sql. This chapter adds no new code and reruns no new demo; its claims are a deeper reading of exactly the mechanism Chapter 17 already exercised end-to-end (including the negative check proving the consumer’s idempotency under redelivery). What a reader’s own run should confirm independently: the observed checkout-to-notification latency against the two-second poll interval on their own hardware and load; and that the cleanup/retention and poison-message gaps named above remain unaddressed in the current codebase rather than quietly fixed since this chapter was written — both are explicitly scoped as future hardening, not implemented here.