The dual-write problem that breaks 'update the DB and publish an event' atomically, the outbox table fix, polling vs Change Data Capture, and the at-least-once guarantee it actually achieves.
Published September 23, 2026
@Transactional
void createOrder(Order order) {
orderRepository.save(order); // write 1: to the database
messagePublisher.publish(new OrderCreatedEvent(order)); // write 2: to the message broker
// these are TWO SEPARATE SYSTEMS — there's no way to commit both atomically together
}
A database transaction and a message-broker publish are two entirely separate systems with no shared transaction coordinator (short of 2PC, which — per Two-Phase Commit — doesn't fit this context well) — there's a real gap where the database commit succeeds but the publish fails (broker temporarily unreachable), or the publish succeeds but the database transaction later rolls back for an unrelated reason. Either way, the database and the event stream disagree about what happened — this is the dual-write problem, and it's a genuine, common source of silent data/event inconsistency in event-driven systems.
@Transactional
void createOrder(Order order) {
orderRepository.save(order);
outboxRepository.save(new OutboxEvent("OrderCreated", serialize(order))); // SAME transaction, SAME database
}
CREATE TABLE outbox_events (
id UUID PRIMARY KEY,
event_type VARCHAR(100),
payload JSONB,
published BOOLEAN DEFAULT FALSE,
created_at TIMESTAMP
);
The fix: write the event into an outbox table, within the same database transaction as the business data — now there's only ever one atomic write (both the Order row and the OutboxEvent row commit or roll back together, since they're in the same transaction against the same database). The event is guaranteed to be durably recorded if and only if the business data change actually committed — no more dual-write gap, because there's no longer a second, separate system involved in the atomic part of the operation.
@Scheduled(fixedDelay = 500)
void publishPendingEvents() {
List<OutboxEvent> pending = outboxRepository.findByPublishedFalse();
for (OutboxEvent event : pending) {
messagePublisher.publish(event);
event.setPublished(true);
outboxRepository.save(event);
}
}
A background job periodically reads unpublished outbox rows and actually sends them to the broker — the publish-to-broker step now happens outside the original transaction, asynchronously, decoupled from the request that created the order. The tradeoff: a small, bounded delay between the local commit and the event actually reaching the broker (proportional to the polling interval), and the polling itself adds a small continuous load on the outbox table.
Tools like Debezium take a different approach: instead of polling, they tail the database's own transaction log (the same write-ahead log ACID Properties covers as the durability mechanism) directly, streaming new outbox rows to the broker the moment they're committed — no polling delay, no extra load from repeated SELECT ... WHERE published = false queries. CDC is the more sophisticated, lower-latency option, at the cost of additional infrastructure (running and operating Debezium, or an equivalent) — polling remains the simpler, easier-to-reason-about starting point for many teams.
Outbox delivers at-least-once delivery, not exactly-once — a crash between publishing to the broker and marking the outbox row as published could cause the same event to be published again on the next polling cycle (the row is still published = false), meaning a consumer might see the same event twice. What outbox does guarantee absolutely: the event is never silently lost — if the business data committed, the event is durably recorded and will eventually be published, possibly more than once but never zero times. This is exactly why Eventual Consistency Design's "idempotent consumers" guidance matters directly alongside outbox — the at-least-once guarantee shifts the duplicate-handling responsibility to the consumer side, which needs to be able to safely process the same event twice without a bad outcome.
Q: Why not just publish the event FIRST, then write to the database? A: Same fundamental problem in reverse — if the publish succeeds but the subsequent database write fails, consumers act on an event describing a business change that never actually happened, arguably worse than the original ordering, since a downstream system might take irreversible action based on an event that turns out to be false.
Q: How does the polling publisher avoid publishing the SAME outbox row twice under concurrent polling (e.g. two instances of the scheduled job)? A: The findByPublishedFalse() + save() sequence shown has the same check-then-act race as elsewhere in this course unless protected — a real implementation needs either a database-level SELECT ... FOR UPDATE SKIP LOCKED (letting concurrent pollers each grab different unclaimed rows) or a single active poller instance, otherwise two instances could both read the same unpublished row and both attempt to publish it.
Q: When would CDC's added infrastructure complexity be clearly worth it over polling? A: When publish latency genuinely matters (a user-facing feature needing near-real-time event propagation, not just eventual within a polling interval) or when outbox table write volume is high enough that repeated polling queries become a measurable load concern — for many internal, non-latency-critical event flows, polling's simplicity is the better tradeoff.
Q: Does the outbox table need its own cleanup/archival strategy? A: Yes — published rows accumulate indefinitely without an explicit cleanup job (deleting or archiving rows older than some retention window, once you're confident they've been successfully consumed), the same unbounded-growth concern as any other table without an eviction policy, covered generally in Denormalization & Schema Trade-offs' soft-delete/archival discussion.