Transactional Outbox Pattern with Spring Boot: Reliable Event Publishing Without Dual Writes
Event-driven architecture looks straightforward at first.
A service updates its database, publishes an event to Kafka, and another service reacts to it.
Payment Service
|
+-- Save payment
|
+-- Publish PaymentCompleted
|
v
Kafka
But there is a reliability problem hidden in those two operations.
What happens if the database transaction succeeds, but publishing the event fails?
Your payment exists, but the rest of the system never hears about it.
And if publishing succeeds first but the database transaction later fails, consumers may receive an event describing something that never actually happened.
This is the dual-write problem.
The transactional outbox pattern is one of the most practical ways to solve it.
The Dual-Write Problem
Consider a typical Spring service:
@Transactional
public void completePayment(Payment payment) {
paymentRepository.save(payment);
kafkaTemplate.send(
"payments",
new PaymentCompletedEvent(payment.getId())
);
}
It looks reasonable.
The problem is that two different systems participate in this method:
-
the application database
-
Kafka
The database transaction does not automatically include Kafka.
Several failure scenarios are therefore possible.
Database succeeds, Kafka fails
Database Payment = COMPLETED ✓ Kafka PaymentCompleted ✗
The local state says the payment completed, but downstream services never receive the event.
Kafka succeeds, database fails
Depending on when and how publishing occurs, the opposite problem can also happen.
Kafka PaymentCompleted ✓ Database Payment = COMPLETED ✗
A consumer may now process an event representing state that was never committed.
Trying to coordinate these operations manually quickly becomes complicated.
What we really want is a simple guarantee:
If the business transaction commits, the intention to publish its event must commit with it.
That is where the outbox comes in.
The Transactional Outbox
Instead of publishing directly to Kafka inside the business transaction, we store the event in the same database transaction as the business data.
DATABASE TRANSACTION
┌─────────────────────────────┐
│ │
│ Update Payment │
│ + │
│ Insert Outbox Event │
│ │
└──────────────┬──────────────┘
│
COMMIT
│
v
Outbox Dispatcher
│
v
KafkaNow the database determines the atomic boundary. Either both records commit:
Payment ✓ Outbox Event ✓
or neither does:
Payment ✗ Outbox Event ✗
Kafka no longer needs to participate in the business transaction.
What Goes Into the Outbox?
An outbox table normally contains enough information to publish the event later.
Conceptually, a record could look like:
event_id = 01J...
event_type = PaymentCompleted
source = payment-service
payload = {...}
content_type = application/json
status = PENDING
created_at = ...
The important part is that the payload and its delivery state are persisted.
I particularly prefer keeping the payload readable rather than hiding it behind opaque serialization.
When something goes wrong in production, being able to inspect the exact event that was supposed to leave the service is extremely useful.
The outbox isn't merely a delivery mechanism.
It becomes part of your operational history.
Publishing the Event
A separate dispatcher polls pending outbox records.
For example:
PENDING
|
v
PROCESSING
|
+--------------------+
| |
v v
PUBLISHED retryable failure
|
v
PENDING
|
retry later
After a configurable number of attempts, permanently failing events can transition to:
FAILED
This separation is important.
Your business transaction is responsible for recording what happened.
The dispatcher is responsible for delivering that information.
Those are different responsibilities and should fail independently.
Why Not Just Retry Kafka Inside the Transaction?
A common first solution is to retry publishing:
@Transactional
public void completePayment(Payment payment) {
paymentRepository.save(payment);
retryTemplate.execute(context ->
kafkaTemplate.send("payments", event)
);
}
Retries can help with temporary failures.
But they do not remove the fundamental coupling.
Imagine Kafka is unavailable for several minutes.
Should your payment transaction remain open while the application repeatedly attempts to contact Kafka?
Probably not.
Long-running transactions consume database resources, increase lock duration, and couple business availability to messaging availability.
With an outbox:
Payment transaction
|
+---- commits quickly
|
v
Outbox
Kafka unavailable
|
+---- dispatcher retries independently
The business operation can succeed even while the broker is temporarily unavailable.
At-Least-Once Delivery Changes the Problem
There is an important consequence.
An outbox dispatcher can usually provide at-least-once delivery, not magically guaranteed exactly-once business processing.
Consider this sequence:
1. Dispatcher publishes event to Kafka 2. Kafka accepts the event 3. Application crashes 4. Outbox record was not yet marked PUBLISHED 5. Application restarts 6. Dispatcher publishes the event again
The same event may be delivered twice.
That is not necessarily a bug.
It is a consequence of choosing reliability over silently losing messages.
The architecture therefore becomes:
Transactional Outbox
+
At-Least-Once Delivery
+
Idempotent Consumer
This is why the inbox pattern naturally complements the outbox pattern.
We'll cover that separately.
Multiple Application Instances
Production systems rarely run a single instance.
Imagine three pods:
OUTBOX
|
+--------+--------+
| | |
Pod A Pod B Pod C
Without coordination, multiple pods could select the same pending events.
One common approach is database-level locking.
Conceptually:
SELECT ... FROM outbox WHERE status = 'PENDING' FOR UPDATE SKIP LOCKED;
Suppose the outbox contains:
1 2 3 4 5 6
Pod A may lock:
1 2 3
while Pod B skips those locked rows and receives:
4 5 6
This allows multiple workers to process the outbox concurrently without waiting on the same rows.
The exact implementation depends on the database and persistence strategy, but the principle is important:
Scaling the dispatcher horizontally should not mean publishing every event multiple times.
What About CDC?
Polling isn't the only way to implement an outbox.
Another common architecture uses Change Data Capture:
Application
|
v
Database Outbox
|
v
CDC
|
v
Kafka
Tools such as Debezium can stream database changes instead of having application workers poll the table.
CDC can be an excellent choice at larger scale or when an organization already operates the necessary infrastructure.
But it introduces another operational component.
Application-level polling has different advantages:
-
simpler infrastructure
-
easier local development
-
easier debugging
-
broker independence
-
application-controlled retry behavior
Neither approach is universally better.
The important architectural idea is not the polling mechanism.
It is the transactional boundary provided by the outbox.
Making This Reusable
After implementing this pattern several times, a lot of infrastructure starts repeating:
Outbox persistence Event serialization Dispatch scheduling Retry handling Concurrency Failure states Metrics Kafka integration SQS integration Operational endpoints
Business applications shouldn't need to rebuild all of that every time they need reliable event delivery.
That is one of the reasons I built NERV Event.
With NERV Event, the goal is to keep the application focused on expressing the event while the library handles the delivery lifecycle.
Conceptually:
eventPublisher.publish(
"PaymentCompleted",
paymentCompletedEvent
);
Behind that operation is the infrastructure required to persist and eventually dispatch the event reliably.
The architecture becomes:
Business Service
|
v
NERV Event
|
v
Transactional Outbox
|
v
Dispatcher
|
+--+--+
| |
Kafka SQS
The application owns the business event.
The infrastructure owns its reliable delivery.
Reliability Doesn't End at the Producer
The transactional outbox solves an important problem:
How do I make sure an event isn't lost after my business transaction commits?
But once that event reaches another service, a new set of problems begins.
What if the consumer processes the same event twice?
What happens if processing succeeds but acknowledgement fails?
How do we retry safely?
How do we know whether an event has already been processed?
Those are consumer-side reliability problems.
And that is where the Inbox Pattern comes in.
In the next article, we'll look at Inbox Pattern and Idempotent Consumers and build the other half of reliable event delivery.
NERV Event
NERV Event is an open-source event reliability framework for Spring Boot designed around transactional outbox/inbox processing, retries, idempotency, Kafka and SQS integration, and production operations.
GitHub: https://www.github.com/czetsuyatech/nerv-event
The broader introduction to the architecture is covered in:
Reliable Event-Driven Architecture in Spring Boot: Outbox, Inbox, Retries, and Idempotency
This series goes deeper into each of the reliability problems individually.




Post a Comment