The Inbox Transaction Boundary: Getting Event Processing Right in Spring Boot
One of the hardest parts of building an event-driven system isn't getting messages from one service to another.
It's making sure the system behaves correctly when something fails halfway through processing.
While building NERV Event, I ran into an important reliability problem:
Where should the transaction boundary be when processing an event?
At first, an Inbox can look deceptively simple.
Receive a message.
Check whether it was already processed.
Execute the business operation.
Mark the message as processed.
Acknowledge the message.
But these operations don't automatically share the same transactional boundary.
And that creates some uncomfortable failure scenarios.
The Inbox Pattern
The Inbox pattern is commonly used to make event consumers idempotent.
A simplified flow looks like this:
┌─────────────────┐
│ Broker │
└────────┬────────┘
│
▼
┌─────────────────┐
│ Consumer │
└────────┬────────┘
│
▼
┌─────────────────┐
│ Inbox │
│ │
│ event_id │
│ status │
└────────┬────────┘
│
▼
┌─────────────────┐
│ Business Logic │
└─────────────────┘
The Inbox records that an event has been received and tracks its processing state.
For example:
RECEIVED
│
▼
PROCESSING
│
▼
PROCESSED
If the broker delivers the same event again, the consumer can inspect the Inbox and determine whether the event has already been processed.
This gives us a foundation for idempotent event processing.
But there's an important question:
How does the Inbox transaction relate to the business transaction?
The Transaction Boundary Problem
Consider a consumer processing an OrderCreated event.
The consumer needs to:
- Receive the event.
- Create an Inbox record.
- Execute the business operation.
- Mark the Inbox record as processed.
- Acknowledge the broker message.
It is tempting to think of this as one atomic operation.
It isn't necessarily one transaction.
The database transaction and the broker acknowledgment belong to different systems.
Even within the database, the Inbox update and business operation can accidentally end up in different transactional boundaries.
That matters when something fails.
When the Business Operation Succeeds but the Inbox Doesn't
Consider this sequence:
Business operation
│
▼
SUCCESS
│
▼
Inbox update
│
▼
FAILURE
If the business operation and Inbox update participate in the same database transaction, the failure can cause the entire transaction to roll back.
The event can then be retried.
But consider a design where they use separate transactions:
Transaction A
│
└── Business operation COMMITTED
Transaction B
│
└── Inbox update FAILED
Now the business operation has already committed.
The Inbox doesn't indicate that processing completed.
The broker may redeliver the event.
The consumer sees an event that appears unprocessed and executes the business operation again.
Now idempotency becomes critical.
When the Inbox Says Processed but the Business Operation Rolls Back
The reverse situation can be even more problematic.
Suppose the Inbox is marked as processed before the business operation is committed:
Inbox
│
└── PROCESSED
Then the business transaction fails:
Business transaction
│
└── ROLLBACK
If those changes aren't part of the same appropriate transactional boundary, the system can end up with contradictory state.
The Inbox says:
PROCESSED
But the business data says:
NOT PROCESSED
A retry may then be suppressed because the Inbox says the event has already been processed.
The system has effectively lost the opportunity to recover.
What Should Share the Transaction?
For a database-backed Inbox, the important relationship is between the Inbox state and the business state.
Conceptually:
┌──────────────────────────────────────────┐
│ Database Transaction │
│ │
│ Inbox state │
│ + │
│ Business state │
│ │
│ └── Atomic commit / rollback │
└──────────────────────────────────────────┘
The goal is that the Inbox processing state and the business changes have a consistent relationship.
A simplified Spring Boot example might look like:
@Transactional
public void process(Event event) {
Inbox inbox = inboxRepository.findByEventId(event.id());
if (inbox.isProcessed()) {
return;
}
businessService.apply(event);
inbox.markProcessed();
}
The important part isn't the @Transactional annotation itself.
The important part is understanding which operations actually participate in that transaction.
If businessService.apply(event) and the Inbox update use the same database transaction, the database can commit or roll back those changes together.
But What About the Broker?
This is where things become more interesting.
A database transaction normally cannot make a Kafka acknowledgment atomic with the database commit.
You effectively have two systems:
Database Broker
│ │
│ │
DB transaction Message ack
│ │
└──────────┬───────────────┘
│
Different systems
Even with a correctly designed Inbox transaction, there is still a failure window around the broker acknowledgment.
For example:
Database transaction
│
├── Business change
└── Inbox = PROCESSED
│
▼
COMMIT
│
▼
Broker acknowledgment
│
└── FAILURE
The broker may deliver the message again.
But this is exactly where the Inbox pattern provides its value.
On redelivery:
Event arrives
│
▼
Check Inbox
│
▼
Already PROCESSED
│
▼
Skip business operation
The consumer can safely tolerate duplicate delivery.
This is the fundamental relationship between the Inbox pattern and at-least-once delivery.
The Goal Isn't Exactly-Once Processing
A common misconception is that the Inbox pattern makes event processing exactly-once.
It doesn't.
The broker may still deliver the same message more than once.
Instead, the goal is closer to:
At-least-once delivery
+
Idempotent processing
=
Reliable consumer behavior
The Inbox gives the consumer persistent knowledge about an event's processing state.
That state can then be used to make retries deterministic.
Processing State Matters
A simple boolean such as:
processed = true
often isn't enough for a production event-processing system.
A useful Inbox model can distinguish states such as:
RECEIVED
PROCESSING
PROCESSED
FAILED
This makes failures inspectable.
For example:
RECEIVED
│
▼
PROCESSING
│
├──────────────► PROCESSED
│
└──────────────► FAILED
Now the system has an explicit record of what happened.
That becomes particularly valuable when retries are involved.
What Happens During a Retry?
Suppose processing fails:
Event
│
▼
PROCESSING
│
▼
Business operation fails
│
▼
FAILED
A retry can then use the Inbox state to determine what should happen next.
Depending on the architecture, the consumer can:
- retry immediately
- retry after a delay
- increment an attempt counter
- record the failure
- eventually move the event to a failure or DLQ mechanism
The important thing is that the failure becomes persistent state rather than disappearing with the consumer process.
The Inbox Is More Than Deduplication
This is the architectural distinction I find most important.
An Inbox is often introduced as:
A table that prevents duplicate messages.
That's only part of the story.
A production Inbox can provide:
- idempotency
- processing state
- retry state
- failure information
- inspection
- operational visibility
- deterministic recovery
That makes the Inbox part of the consumer's reliability boundary.
The question therefore isn't simply:
"Have we seen this event before?"
It becomes:
"What do we know about this event's processing lifecycle?"
Designing the Boundary Explicitly
When designing an event consumer, it helps to make the boundaries explicit.
Think about three separate concerns:
Broker
│
▼
Message Delivery
│
▼
┌───────────────┐
│ Inbox │
│ │
│ Receipt/state │
└───────┬───────┘
│
▼
┌───────────────┐
│ Database │
│ Transaction │
│ │
│ Inbox + │
│ Business Data │
└───────────────┘
│
▼
Broker Ack
The database transaction provides atomicity between the Inbox state and the business changes.
The Inbox provides persistent processing state.
The broker acknowledgment controls message delivery semantics.
These are related, but they are not the same thing.
Spring Boot Makes the Boundary Easy to Hide
Spring Boot makes transaction management straightforward.
That is useful, but it can also make the boundary easy to overlook.
For example:
@Transactional
public void handle(OrderCreatedEvent event) {
Inbox inbox = inboxService.startProcessing(event);
if (inbox.isProcessed()) {
return;
}
orderService.createOrder(event);
inboxService.markProcessed(inbox);
}
The code looks simple.
But several architectural questions are hiding behind it:
- Which database transaction is active?
- Which operations participate in it?
- Does
orderServicestart another transaction? - Can the Inbox update commit independently?
- When is the Inbox state persisted?
- When is the broker message acknowledged?
- What happens if the application crashes after the database commit?
- What happens if the broker acknowledgment fails?
The annotation doesn't answer those questions.
The architecture does.
Failure Windows Are Part of the Design
One of the most useful lessons from event-driven architecture is that failure isn't an exceptional edge case.
It is part of the normal operating model.
A consumer can crash after committing its database transaction:
Business operation
│
▼
Inbox + business data
│
▼
COMMIT
│
X
Application
crashes
│
▼
Broker redelivers
Or:
Inbox + business transaction
│
▼
COMMIT
│
X
Broker acknowledgment fails
│
▼
Redelivery
A reliable system doesn't try to pretend these scenarios cannot happen.
It defines what should happen when they do.
That's the real purpose of the Inbox.
Building NERV Event
These are the kinds of engineering decisions that shaped NERV Event.
The goal isn't simply to provide an Inbox implementation.
It's to provide explicit persistence and processing semantics that make event-driven behavior easier to understand, operate, and debug.
The Inbox is treated as part of the consumer reliability model rather than just a mechanism for detecting duplicate event IDs.
That distinction becomes increasingly important as systems move from simple message consumption toward production workloads involving:
- multiple application instances
- retries
- failures
- duplicate delivery
- long-running processing
- operational recovery
Final Thoughts
The Inbox transaction boundary is easy to overlook.
Most event-driven examples focus on the happy path:
Receive → Process → Acknowledge
Production systems need to think differently:
Receive
│
▼
Persist state
│
▼
Process
│
├── success ──► Commit
│
└── failure ──► Retry / Failure state
│
▼
Redelivery
The important question isn't whether failures will happen.
They will.
The important question is whether the system's state still makes sense after they do.
That's why the transaction boundary around the Inbox and business operation deserves deliberate architectural attention.
NERV Event
NERV Event is an open-source Spring Boot library for building reliable event-driven systems with patterns such as:
- Transactional Outbox
- Inbox
- Idempotent consumers
- Retries
- Failure handling
- Broker integrations
If you're building event-driven systems with Spring Boot, the transaction boundary is one of those details worth getting right before production forces you to think about it.

Top comments (0)