intermediate~2h

The Transactional Outbox Pattern

Understand the dual-write problem that threatens any system saving data and publishing a message as two separate steps, and learn how the outbox pattern solves it by writing the message in the same local transaction as the business change.

Learning objectives

  • Explain the dual-write problem and give a concrete example of it failing
  • Describe exactly how the transactional outbox pattern uses ordinary database atomicity to solve it
  • Explain why Axon-based aggregates typically don't need a hand-built outbox table
  • Recognize the specific situation where you'd still need to build one yourself

◆ Imagine this

You write an important letter and file a copy in your own records — that part is done and safe. But then you get distracted and never actually walk it to the mailbox. As far as you're concerned, the letter is "sent" — your own filed copy proves you wrote it. But the recipient never receives anything, and has no idea it was ever meant to arrive at all.

Every system that saves a business change to a database and then separately publishes a message about it faces this exact risk. Saving the change and publishing the notification are two different operations against two different systems, and there's no guarantee that succeeding at one means succeeding at the other. this topic is about exactly what breaks that trust, and the pattern that fixes it.

◆ The problem

Saving a business change to a database and publishing a corresponding message to notify other services are, underneath, two separate operations against two separate systems. If the database save succeeds but the message publish fails right afterward — a brief network blip, a crash at exactly the wrong moment — the system ends up in precisely the letter story: the change is safely recorded, but nobody else in the system ever finds out it happened.

This is called the dual-write problem, and it's a genuinely common source of subtle production bugs in distributed systems. "The order exists in our database, but the warehouse was never notified to ship it" is exactly this failure, playing out in the real world — a loan record could be created successfully while the notification telling the accounts service to deposit the funds simply never arrives, leaving a loan that exists on paper but was never actually funded.

The instinctive fix — "just publish the message right after the save succeeds, in the same method" — doesn't actually solve anything, because that's still two separate operations happening one after the other, with a real gap between them where a crash can land. What's needed is a way to make the message's fate atomic with the save itself, without needing the database and the messaging system to somehow share one transaction directly, which they generally can't.

The fix is elegant precisely because it avoids the hard problem instead of solving it head-on. Rather than trying to make "save to database" and "publish a message" atomic with each other directly — hard, since they're different systems with no shared transaction — the pattern writes the message into a plain database table, the outbox, in the exact same local database transaction as the actual business change. A local database transaction is something ordinary database guarantees already make atomic; nothing exotic is required to get that part right.

A separate process, the relay, then reads unpublished outbox rows afterward and actually delivers them, completely decoupled in time from the original save. If the relay crashes or the message broker is briefly unavailable, the outbox row just sits there, unpublished, until the relay successfully retries — nothing is lost, because the row survived the one atomic transaction that mattered.

Both the business-change row and the outbox row commit together, or neither does — that's ordinary database atomicity, nothing exotic. The event doesn't need to be published atomically with the business change; it only needs to be durably recorded atomically with it. A separate, less time-critical process can handle the actual publish afterward, on its own schedule, with retries.

💻 Code example

@Entity public class OutboxEvent { @Id @GeneratedValue private Long id; private String aggregateId; private String eventType; @Lob private String payload; private boolean published = false; private Instant createdAt; // getters/setters } @Service public class LoanService { private final LoanRepository loanRepository; private final OutboxEventRepository outboxRepository; @Transactional public void approveLoan(ApproveLoanRequest request) { Loan loan = loanRepository.save(new Loan(request)); // Same local transaction as the save above -- both commit together or neither does. OutboxEvent outboxEvent = new OutboxEvent(); outboxEvent.setAggregateId(loan.getId()); outboxEvent.setEventType("LoanApprovedEvent"); outboxEvent.setPayload(toJson(loan)); outboxEvent.setCreatedAt(Instant.now()); outboxRepository.save(outboxEvent); } } @Component public class OutboxRelay { private final OutboxEventRepository outboxRepository; private final MessagePublisher publisher; @Scheduled(fixedDelay = 1000) public void relayUnpublishedEvents() { for (OutboxEvent event : outboxRepository.findByPublishedFalse()) { publisher.publish(event.getEventType(), event.getPayload()); event.setPublished(true); outboxRepository.save(event); } } }

◆ Under the hood — why this rarely needs to be hand-built with Axon

This is genuinely worth pausing on: an event store like Axon Server's already behaves like a robust outbox by design. When AggregateLifecycle.apply() runs inside a command handler, the event is durably stored as part of handling that command, and the event store's own delivery mechanism — the event processors covered earlier — is what reliably gets it to every listener afterward, including retrying on failure. Any aggregate built on @CommandHandler and AggregateLifecycle.apply() already gets outbox-style reliability for free, without a team needing to hand-build an outbox table for it.

▲ Edge case — when you'd still need to build one yourself

The moment a piece of code writes to a plain, non-Axon-managed database and separately needs to publish a message through some other channel entirely — say, a plain JPA repository save alongside a direct call to a different messaging system that Axon isn't managing — that write is back in the exact dual-write problem this pattern describes. In that specific situation, building an explicit outbox table, exactly as shown above, is still the right fix, since Axon's own event store isn't involved in that write at all and can't offer its usual guarantee there.

▲ Common mistake — publishing inside the same transaction "to be safe"

Calling a message broker's publish method from inside the same database transaction as the business save doesn't fix the dual-write problem — it just moves it. A network call to a broker can still fail independently of the database commit, and a broker publish can't be rolled back the way a database write can if the surrounding transaction later fails for an unrelated reason. The outbox table exists specifically so the "publish" step is deferred to a separate, retriable process, rather than folded into the same transaction as an operation that isn't actually transactional with the database.

▲ Edge case — the relay must handle at-least-once delivery

A relay that marks a row published only after a successful publish call, but crashes between the publish succeeding and the row being marked, will retry that row and publish it again on the next pass. This means outbox-based delivery is at-least-once, not exactly-once — consumers reading these messages need to be idempotent, the same requirement that applies to any tracking-style, at-least-once delivery mechanism.

▲ Edge case — a growing, unpruned outbox table

An outbox table that never removes or archives published rows grows without bound, slowing down the relay's own query for unpublished rows over time. A periodic cleanup job that deletes or archives rows already marked published, once they're old enough to no longer be relevant to debugging, keeps the table's performance predictable.

The transactional outbox pattern shows up anywhere a service saves state in a plain relational database and needs to reliably notify other services about it through a message broker like Kafka or RabbitMQ, without that service being built on an event-sourcing framework that already provides the guarantee. E-commerce order services, inventory systems, and payment processors — anywhere "save this record" and "tell everyone else about it" are both required, and losing the second step silently would cause real business harm — are classic homes for this pattern.

It's also common in systems migrating gradually toward event-driven architecture: a legacy service with an existing relational database can adopt the outbox pattern as a first step toward reliable event publishing, well before any larger migration to a dedicated event-sourcing framework. Change-data-capture tools that watch a database's write-ahead log and turn outbox table inserts into published messages automatically are a common production-grade way to implement the relay half of this pattern without hand-writing a polling loop.

  • Q: What specifically is the dual-write problem? A: A database save and a message publish are two separate operations against two separate systems — one can succeed while the other fails, since there's no direct way to make them atomic with each other.
  • Q: How does the transactional outbox pattern solve it? A: By writing the message into a plain database table, the outbox, in the exact same local transaction as the business change, then relying on ordinary database atomicity. A separate relay process reads and publishes unpublished rows afterward, decoupled from the original save.
  • Q: Why doesn't a typical Axon-based aggregate need a hand-built outbox table? A: Axon's own event store already durably stores an event as part of handling the command that produced it, and its event processors reliably deliver that event afterward, including retrying on failure — the same guarantee an outbox table exists to provide.
  • Q: When would you still need to build an outbox table yourself in an Axon-based system? A: When code writes to a plain, non-Axon-managed database and separately needs to publish a message through a different channel Axon isn't managing — that write is outside Axon's own guarantee.
  • Q: Is outbox-based delivery exactly-once or at-least-once? A: At-least-once — a crash between a successful publish and marking the row published can cause the same message to be relayed again, so consumers need to be idempotent.

Want a visual for this concept?

Generate a diagram tailored to “The Transactional Outbox Pattern” — the AI picks whichever visual (flowchart, comparison, sequence, etc.) best fits.

Sign in to generate a visual →

Practice quiz

Next Step

Continue to The Saga Pattern — Why We Need It← Back to all Event-Driven Microservices chapters