1. The Dual-Write Anti-Pattern
When a microservice updates its relational database (INSERT INTO orders) and directly publishes an event to Kafka (producer.send("OrderCreated")), two separate storage engines are mutated without a shared transaction:
- If the process crashes after the DB commit but before Kafka ack, downstream services never learn the order exists (silent data loss).
- If the process publishes to Kafka before committing to the DB and the DB transaction rolls back, downstream services act on a phantom order.
sequenceDiagram
participant API as Order Service
participant DB as Local SQL Transaction (Orders + Outbox)
participant Relay as CDC / Outbox Relay
participant Broker as Kafka / Event Stream
participant Cons as Idempotent Consumer (Inbox Table)
API->>DB: BEGIN; INSERT orders; INSERT outbox (PENDING); COMMIT;
Relay->>DB: SELECT * FROM outbox WHERE status = 'PENDING'
Relay->>Broker: Publish Event (id = evt-1)
Note over Relay,DB: If Relay crashes before UPDATE outbox, evt-1 is sent twice
Relay->>DB: UPDATE outbox SET status = 'PUBLISHED'
Broker->>Cons: Deliver evt-1 (Attempt 1 & Attempt 2)
Cons->>Cons: Deduplicate by eventId in processedEventIds
How the Transactional Outbox + Idempotent Inbox Achieves End-to-End Exactly-Once Effects
- Atomic Local Transaction (
TransactionalStore.createOrderWithOutbox): Both the domain entity (orders) and the event record (outbox) are written inside the same local ACID transaction. Either both commit or neither commits. - At-Least-Once Relay (
OutboxRelay.pollAndPublish): An asynchronous relay tails or pollsPENDINGoutbox records and publishes them to the broker. Only after the broker acknowledges receipt does the relay mark the rowPUBLISHED. - Idempotent Consumer (
IdempotentConsumer.handleEvent): Because the relay guarantees at-least-once delivery (it may crash between broker ack andmarkPublished), every consumer recordseventIdin an inbox/deduplication table so duplicate deliveries are safely ignored.
2. Verification & Running the Lab
bash
node demo.js
node test.js