skip to content

Why can a caller that swallowed a data-access failure still get a rollback error when the boundary commits?

level: middleimportance: should knowfreq 49%

answer

  1. state on the unit, not on the value
  2. the flag survives the catch
  3. success returned, rollback delivered
  4. handle outside the boundary
  5. mark explicitly when you mean it

basics

~20 s

Because the failure already marked the unit rollback-only. That flag lives on the transaction, not on the exception, so catching and discarding the exception leaves the unit condemned and the boundary refuses to commit it.

solid answer

~40 s

When a failure reaches the database, most layers set a **rollback-only** flag on the transaction. Application code can also set it deliberately. The flag is state on the unit of work, so it outlives the catch block: swallowing the exception hides the symptom while leaving the unit condemned. The method then returns what looks like success, and the boundary - often a layer above, closing the unit the caller never opened - rolls back and reports that the transaction could not be committed. The caller sees a rollback error with no obvious cause, because the real cause was logged and dropped somewhere further in. The lesson is that a catch block cannot decide the fate of a transaction it did not open; only the code that owns the boundary can.

go deeper

for a junior

Recall that a transaction can be marked so that only a rollback is allowed, and that catching the exception does not clear that mark. The commit will fail even though your code caught everything.

for a middle

Explain what sets the flag, why it survives a catch block, and why the caller ends up with an error at the boundary rather than at the statement that actually failed.

for a senior

Show where failure handling belongs - outside the boundary - and how you keep an optional write from condemning required work without hiding its failure in a catch block.

for a principal

Decide the service-wide convention: which layer owns boundaries, who is permitted to mark, and how failures are correlated so a rollback error can always be traced to its cause.

## The flag is on the transaction, not on the exception A unit of work carries a **rollback-only** flag: a one-way switch meaning *this transaction may no longer be committed*. Once set, the only legal ending is a rollback, and an attempt to commit fails with an error saying so. That is the whole surprise. Developers reason about failure through exceptions, and an exception is a value you can catch and drop. The flag is not a value - it is state recorded on the unit - so it survives the catch block that made the exception disappear. ## What sets it - **A failure that reached the database.** Most layers mark the unit as soon as a statement is rejected, because from that point the transaction cannot be trusted to represent the intended work. - **Application code, deliberately.** A use case that decides the work must not stand can mark the unit rollback-only rather than throwing, so the boundary rolls back while the method still returns a value. - **An inner boundary that joined the caller's unit.** When nested work runs inside the same transaction, marking is how it makes its failure binding on the whole unit. Whether an inner boundary joins or runs separately is a demarcation choice, and it is exactly the choice that decides whether the inner failure can condemn the outer work. ## The surprise, step by step 1. A use case saves something; the write is rejected. 2. The layer marks the unit rollback-only and throws. 3. A catch block logs the failure at warning level and returns a default result. 4. The method continues, perhaps saving more, perhaps returning an object. 5. The boundary closes and tries to commit. 6. The commit is refused; the caller receives an error about the transaction being rolled back. The caller receives the failure furthest from its cause. The stack trace names the boundary, the log line naming the real problem is somewhere above at a lower level, and the two are often not correlated by anything. ## Swallow versus propagate | Choice | Effect on the transaction | Effect on the caller | |---|---|---| | Swallow and continue | still condemned; later work discarded | told success, then handed a rollback error | | Swallow and mark explicitly | condemned, intentionally | still a rollback error, but now deliberate | | Propagate | rolled back at the boundary | receives the real failure, with its cause | | Catch outside the boundary | already rolled back cleanly | receives a decision made with the full outcome | The last row is the one to aim for. Handling belongs **outside** the unit, where the transaction's fate is already settled and there is nothing left to corrupt. ## Why swallowing is attractive and still wrong The usual motivation is that one part of the work is optional: recording a statistic, touching a cache table, appending an audit row. Making the whole use case fail for that seems disproportionate, so the write is wrapped in a catch. The catch does not deliver what it promises. The optional write already failed inside the shared unit, so the required work is condemned anyway - the code now fails for the optional reason *and* obscures it. If the write really is optional, it must not share the unit with the required work: give it its own unit, or defer it to run once the important work is durable. Those are decisions about boundaries and about work scheduled after commit, and both are made by structuring the transaction rather than by catching inside one. ## What to do instead - **Do not catch a data-access failure inside the unit it broke** unless you immediately rethrow, or you have already decided the whole unit must roll back. - **Catch outside the boundary** and turn the failure into a response, a retry of the whole unit, or a report. - **When you do mean it, mark explicitly** rather than hoping the layer marked for you - the intent is then readable, and a reviewer does not have to know which failures mark implicitly. - **Log with the cause attached** at the point where the decision is made, so the rollback error the caller sees can be joined to the reason. ## An honest caveat Layers differ in exactly which failures mark the unit implicitly: some mark on any failure that reached the engine, others only on failures they classify as fatal, and a few leave the marking entirely to application code. Assume the strictest of those when reading unfamiliar code, and make marking explicit when you depend on it, so behaviour does not hinge on a default you cannot see at the call site.

  • When is marking rollback-only explicitly better than throwing?
    When the method must still return something - a report of what was rejected, a result object the caller inspects - but the work must not stand. Marking makes the rollback certain while control flow continues normally. It also reads clearly: a reviewer sees the intent instead of having to know which failures the layer marks for you.
  • One optional write inside a use case is allowed to fail. How do you arrange that?
    Keep it out of the unit that carries the required work. Either give it its own transaction, or schedule it to run once the important work is durable. A catch block around it does not help: the failure already condemned the shared unit, so the required work is lost anyway and the reason is now hidden in a log line.
  • The caller sees a rollback error with no useful cause. How do you make that diagnosable?
    Log the original failure with its cause chain and a correlation identifier at the point where it is first caught, and never downgrade it to a bare warning line with no detail. Better still, stop swallowing: if the failure propagates, the boundary's error carries the real cause and no correlation work is needed.

saying these in an interview costs you the question

  • Believing catching the exception undoes the failure's effect on the transaction
  • Wrapping an optional write in a catch block inside the required work's unit
  • Returning a success result from a method whose unit is already condemned
  • Logging the failure without its cause and then continuing as if nothing happened
  • Expecting the boundary to commit the good part of a condemned unit