Build1 publisher2 min readPublished
Marking the Inbox row PROCESSED before the business commit suppresses the retry
The Inbox pattern makes a consumer idempotent only when the dedup row and the business write commit together, and a Spring Boot walkthrough works through what each of the two wrong orderings costs you.
The Engineer · Build desk

What happened
- A dev.to walkthrough of the Inbox pattern in Spring Boot lists five steps for consuming an OrderCreated event, and says those steps do not automatically share one transactional boundary.
- The post's sample puts the Inbox lookup, the business call and markProcessed inside a single @Transactional method so the database commits or rolls back all three together.
- According to the post, a database transaction normally cannot make a Kafka acknowledgment atomic with the database commit, so the consumer spans two systems.
Compiled by The EngineerSomething wrong?How this is made
Why it matters
- constraint Because the ack cannot join the commit, the business write still has to tolerate replay however tight the database boundary is; the annotation buys consistency inside one datasource and nothing past it.
- decision Picking an ordering is picking which failure you will operate: a duplicate execution the business logic absorbs, or a suppressed retry that needs someone to reset the Inbox row by hand.
- exposure A refactor that moves the Inbox update behind a call with its own transaction turns a dedup consumer into a lossy one, and the annotation on the outer method still looks correct in review.
Call `process(event)` in the post's sample and one database transaction opens at the method boundary [8]. The Inbox lookup, the call into `businessService.apply` and `inbox.markProcessed()` all run inside it, so the database can commit or roll back those changes together [12]. The acknowledgment back to the broker happens outside that scope [10].
Three of the five steps the post lists are database work; the remaining two, delivery and the ack, belong to the broker [13]. A database transaction normally cannot make a Kafka acknowledgment atomic with the database commit, which leaves the consumer straddling two systems [10]. The annotation is a scope declaration, and the broker never sees it.
The two wrong orderings cost you different things. Commit the business operation in one transaction and let the Inbox update fail in another, and the broker may redeliver an event that looks unprocessed, so the consumer executes the business operation again [6]. That failure is survivable if the business write tolerates replay. Mark the Inbox row processed first and then roll the business transaction back, and the Inbox says PROCESSED while the business data says the opposite; the retry is suppressed because the Inbox reports the event as already handled, and the post says the system has effectively lost the opportunity to recover [7]. Keep both writes in one transaction and a failure on the Inbox update rolls the whole thing back, leaving the event available for retry [5].
The sample reads before it decides: `findByEventId`, an early return on `isProcessed`, then apply, then mark [8]. It contains no insert, so a row for that event id has to exist before `process` runs [15].
Writing as czetsuya on dev.to, the author said "It is tempting to think of this as one atomic operation" [16], and that "The important part isn't the @Transactional annotation itself. The important part is understanding which operations actually participate in that transaction." [9]
For the annotation to cover what the diagram claims, `businessService.apply` has to write through the same datasource and join the caller's transaction. The post's own warning is that the Inbox update and the business operation can accidentally end up in different transactional boundaries even inside one database [3]. The post does not disclose a broker acknowledgment mode, stopping at the two-systems statement [10].
The evidence is one developer's account of a reliability problem found while building an event system called NERV Event [11], not a measured failure population. The state machine it describes is small enough to check by hand: RECEIVED, PROCESSING, PROCESSED, against `event_id` [4].
What to watch
- Whether businessService.apply in a real consumer joins the caller's transaction or opens its own, since that decides if the sample's commit-together claim holds.
- Whether the Inbox table puts a unique constraint on event_id, for the case where the broker delivers the same event twice at once.
- A follow-up covering the broker side, since the current post stops at the statement that the database and Kafka cannot commit together.