Why is a transaction boundary usually placed around one use case rather than around each repository call?
answer
- atomicity has a scope
- the boundary decides what rolls back
- one use case, one commit
- repositories enlist, they do not commit
- no wider than the use case
basics
~20 sA transaction commits or rolls back as a whole, so the boundary must enclose everything that has to be all-or-nothing - usually one use case. Per-call commits make each write permanent on its own, so a later failure leaves half-written data.
solid answer
~40 sThe boundary defines atomicity: whatever runs inside it either lands together or not at all. A use case - place an order, transfer funds, close an account - is normally that unit, because it is what the business treats as one change. If each repository call opens and commits its own transaction, the order row is already permanent when the stock update fails, and there is no rollback left to undo it; you end up writing compensation by hand. So demarcation belongs one layer above persistence, in the service or handler that owns the use case, while repositories stay boundary-agnostic and simply enlist in whatever is already open. The boundary should also be no wider than the use case: stretching it over a whole request holds locks during work that is not database work.
go deeper
Remember that a transaction is what commits or rolls back together, and that the code decides how much is inside it. Name the use case as the usual unit and the service layer as where it is opened.
Explain what per-call commits actually leave behind after a mid-way failure, and why a repository must not commit on its own. Mention that a boundary wider than the use case costs lock time.
Show how you spot a misplaced boundary from production evidence - partial data, hand-written compensation, lock waits that track request latency - and how you move it without breaking callers.
Frame it as a policy: one predictable default boundary per use case, with any deviation reviewed and its recovery path written down, so nobody invents per-case rules under deadline pressure.
## What a transaction boundary is A **transaction** is the smallest unit a database will make permanent as a whole. Everything issued between the moment it starts and the moment it commits shares one fate: all of it lands, or none of it does. **Demarcation** is the act of deciding where that start and that commit sit in your code. That makes demarcation a design decision, not a technical detail. It fixes exactly which set of writes the system promises to be all-or-nothing, and therefore which half-finished states a reader or a crash can ever expose. If nothing demarcates anything, a connection typically runs in **autocommit**: every statement is its own transaction and becomes durable the instant it succeeds. That is still a boundary - just the smallest one available, chosen by default rather than by you. ## Why the use case is the natural unit A **use case** is one business-meaningful change: accept an order, transfer funds, cancel a subscription. It is the right default unit for four reasons. - **Atomicity matches the rule.** The invariant the business states ("an order always has its stock reserved") is a statement about the use case, so the guarantee has to have the same shape. - **Rollback is the only cheap undo.** Inside one boundary, failure costs nothing but an abort. Across boundaries you must write compensating writes by hand, and those can fail too. - **Readers see one step.** Other transactions observe the change as a single event rather than catching it mid-way (the exact visibility rules are an isolation-level question, owned elsewhere). - **Retry has a unit.** A whole use case can be replayed after a transient failure; half a use case cannot. ## Granularity compared | Boundary | Atomic unit | Typical problem | |---|---|---| | Per statement (autocommit) | one statement | any multi-statement rule can break mid-way | | Per repository call | one repository method | half-written use cases with no undo left | | Per use case | the business change | the default worth defending | | Per request | everything the request touches | locks held during work that is not database work | The two rows either side of "per use case" are the common mistakes. The first is usually accidental - nobody opened a boundary. The last is usually deliberate and defended as convenience, and it hurts under load rather than in tests. ## Where the boundary is declared Put it in the layer that names the use case: the service method, command handler or interactor. Repositories should be **boundary-agnostic** - they issue statements on whatever transaction is currently in effect and never commit on their own. This has a practical payoff: 1. Two repository calls compose into one atomic change without either repository knowing about the other. 2. The same repository is reusable from a use case with a different boundary shape. 3. Tests can wrap a whole scenario in a boundary and discard it afterwards. A repository that opens and commits its own transaction destroys all three, because a caller can no longer group its calls. ## Symptoms of a misplaced boundary - Data that "sometimes" shows a parent row with no children, or a total that disagrees with its parts. - Cleanup code that deletes rows a previous step already committed - compensation written because rollback was unavailable. - Errors reporting failure to the caller while the change is partly visible in the database. - On the too-wide side: lock waits and timeouts that appear only under concurrency, and transactions whose duration tracks request latency rather than the work they do. ## What the boundary does not decide Demarcation answers *where the unit starts and ends*. It does not by itself answer how long the layer's tracked set of loaded objects stays usable, when connections are acquired and released, what concurrency anomalies remain visible at a given isolation level, or how a conflict is detected. Those are separate questions with their own answers; conflating them is why "just make the transaction longer" is such a common bad fix. ## The rule of thumb Start every write path with one boundary per use case, opened in the service layer. Narrow it only when a sub-operation genuinely has a life of its own, and widen it never - if two use cases must be atomic together, that is a sign they are really one use case and should be named as such.
- If two use cases must succeed or fail together, should you wrap both in one boundary?Occasionally, but treat it as a smell. If they are genuinely atomic, they are one use case and should be named as one, with a single service method owning the boundary. Wrapping two independently-invocable use cases in an outer boundary quietly changes the failure semantics of both and makes each one's contract depend on who called it.
- How do you keep repositories boundary-agnostic in practice?Give them no commit, rollback or boundary-opening calls at all - they only issue reads and writes on whatever transaction is currently in effect. The caller owns the boundary. A quick audit is to grep the persistence layer for commit and rollback: any hit there is a repository making a decision that belongs to the use case.
saying these in an interview costs you the question
- Thinks separate per-call commits still give the use case all-or-nothing behaviour
- Opens and commits a transaction inside the repository, so callers cannot group calls
- Believes a rollback can undo writes an earlier, already-committed call made
- Widens the boundary to the whole request because it is convenient
- Cannot say what the boundary is for beyond 'the framework wants one'