Review this design. Every repository method opens its own transaction and commits before returning. One use case calls four repositories and then publishes an event. Support reports occasional orders with a payment row and no shipment row. What would you change and what would you leave alone?
Show the full answer Hide the answer
What is actually required
The use case has an atomicity requirement, and the code has four atomic writes. Four auto-committing repositories give three windows in which the process can die having done part of the work, and the reported symptom is precisely that shape: payment committed, shipment not.
At 50 orders a second, a 200 ms gap between commits produces a handful of inconsistent orders a day, because every gap is a window in which the process can exit having done half the work. That is the worst possible frequency: often enough that support notices, rare enough that nobody can reproduce it, and invisible to every test that runs the use case to completion. In production it usually surfaces in reconciliation days later rather than as an error.
What I would change
Move the decision about commit scope out of the repositories and into the application layer, where it belongs. Commit scope is policy: only the use case knows which writes form one business fact. The repositories should enlist in a transaction they did not start.
In a layered or ports-and-adapters design this does not break the dependency rule, provided the application owns the interface. Define a port in the application — a unit of work that takes a block and runs it inside one transaction — and implement it in the persistence adapter. The violation would be the application importing the driver's transaction type, not the application deciding when to commit.
What I would leave alone
The repository interfaces. The abstraction is fine; the scope was wrong, and replacing repositories with a data-access service moves the bug without fixing it.
Leave the event publish outside the transaction, then fix it properly. Publishing inside a transaction that may roll back announces work that did not happen. Publishing after commit loses the event if the process dies in between. Write an outbox row inside the same transaction and publish from it afterwards, which is the only arrangement where the event and the data agree.
When the simpler alternative wins
If the use case touches one aggregate, the framework's request-scoped transaction already does this and a unit of work is ceremony with extra files. Reach for an explicit unit of work when a use case spans multiple aggregates in one commit, and nowhere else. The trade is one more port and one more adapter in exchange for an atomicity guarantee you can state out loud; below two aggregates there is nothing to buy.
And consider that the four writes may be telling you something about the model. If they must always succeed together and never independently, that is the definition of one consistency boundary, and the real fix might be that these are one aggregate rather than four.
Common weak answers
- "Add retries." A retry over a partially committed order duplicates the payment. Retries need idempotency before they need enthusiasm.
- "Use distributed transactions." All four writes are in one database. Reaching for two-phase commit here buys coordination nobody needs and operational pain everybody feels.
- "Make the repositories share a connection." Necessary and not sufficient. Sharing a connection without naming who commits produces the same partial writes with a longer-held lock, and now the failure depends on which repository happened to autocommit first.
- "Wrap the whole use case in a transaction including the payment provider call." Holding a database transaction open across a network call to a third party converts a correctness bug into a connection-pool outage.