Inbox Pattern with SpringBoot Kotlin and Kafka
Building Reliable Event Consumers with Idempotency and Exactly-Once Semantics
In the previous article, we explored the Outbox Pattern to guarantee that events are published reliably from a producer service.
But publishing events safely is only half of the problem.
π What happens on the consumer side ?
What if the same Kafka message is delivered twice ?
What if the consumer crashes after partially processing an event ?
How do we avoid corrupting state when events are reprocessed ?
This is where the Inbox Pattern becomes essential.
1. Context : Kafka guarantees and their limits
Kafka provides at-least-once delivery by default.
This means :
messages may be delivered more than once
duplicates are not a bug β they are expected
Kafka prioritizes availability and throughput, not business-level idempotency.
π Therefore, consumers must be designed to handle duplicates safely.
2. First attempt : Naive implementation
A typical consumer looks like this:
@KafkaListener(topics = ["commands"])
fun handle(event: CommandCreatedEvent) {
orderService.createOrder(event)
}
This works⦠until something goes wrong.
3. Failure Scenario : Partial processing
Consider the following situation :
Kafka delivers an event
The service updates the database
The service crashes before committing the offset
Result :
Kafka re-delivers the same event
the database update runs again
π Duplicate side effects occur.
This is how data corruption happens in event-driven systems.
4. Why idempotency is not optional
In distributed systems :
You must assume that everything can happen more than once.
Retries, rebalances, restarts, and network failures all cause reprocessing.
π The solution is not to prevent duplicates, but to detect and ignore them.
5. Introducing the inbox pattern
The Inbox Pattern is based on a simple principle :
Persist every consumed event before processing it.
If the event was already processed, skip it.
This requires :
a persistent inbox store
a unique event identifier
6. Modeling the Inbox Collection
We use MongoDB because :
fast writes
natural document model
unique index support
@Document(collection = "inbox_event")
data class InboxEvent(
@Id val eventId: String,
val eventType: String,
val receivedAt: Instant = Instant.now()
)
MongoDB guarantees uniqueness via _id.
7. Reactive inbox repository
interface InboxRepository : ReactiveMongoRepository<InboxEvent, String>
A duplicate insert will fail automatically.
8. Safe event consumption flow
The correct flow becomes :
receive Kafka event
insert event ID into inbox
if insert succeeds β process event
if insert fails β ignore event
9. Reactive kafka consumer with inbox
@KafkaListener(topics = ["commands"], groupId = "orders")
fun consume(event: CommandCreatedEvent): Mono<Void> {
return inboxRepository.save(
InboxEvent(eventId = event.id, eventType = "CommandCreated")
)
.then(orderService.createOrder(event))
.onErrorResume(DuplicateKeyException::class.java) {
Mono.empty()
}
}
This ensures exactly-once side effects.
10. Offset commit strategy
Offsets must be committed after successful processing.
Spring Kafka supports :
manual acknowledgment
reactive backpressure
The inbox guarantees safety even if offsets are replayed.
11. Global architecture with outbox + inbox
Producer Service
β
Outbox Table
β
Debezium
β
Kafka
β
Consumer Service
β
Inbox Collection
β
Business Processing
12. Production benefits
With the Inbox Pattern :
no duplicate side effects
safe reprocessing
crash resilience
simpler retry logic
Combined with Outbox :
end-to-end reliability
strong consistency guarantees
Conclusion
The Inbox Pattern is the natural counterpart of the Outbox Pattern.
Outbox guarantees safe publishing. Inbox guarantees safe consumption.
Together, they form the foundation of reliable event-driven microservices.
In real-world systems, this combination is not optional β it is mandatory.