Skip to the content
Software Made Clear Diagrams that show the mechanism About

Transaction boundaries

ANSWER

Draw the boundary around the set of changes that must all succeed or all fail, and nothing else. Opened once per repository call it is too small to protect anything; wrapped around a network call it holds locks for as long as the other system takes to answer.

IN PLAIN TERMS

You are filling one order. Everything for it goes into a single box, and the box is either handed over complete or not at all. Pack each item into its own box, like five separate errands, and the customer can end up with three of the five things they asked for.

You know what a transaction is. What nobody tells you is where BEGIN belongs in an application that has a service layer, three repositories and an ORM that appears to be opening transactions on its own. The answer is one line long and it decides more about your failure modes than any other line in the codebase: the boundary goes around the set of changes that must all succeed or all fail, and around nothing else.

The boundary decides what fails together#

Put it at the edge of the use case — the request handler, the command, the job — and not inside the repository. The reason is easiest to see by doing it wrong.

three boundaries
# service layer — three boundaries, three independent outcomes
def place_order(cmd):
    order = orders.insert(cmd)               # BEGIN … COMMIT
    inventory.reserve(order.lines)           # BEGIN … COMMIT
    dispatch.book(order.customer, order.lines)   # BEGIN … raises

# The order exists. The stock is reserved. Nothing was booked for delivery.
# That state is reachable and no code path was written for it.

Each repository method is individually correct. Each one opens a transaction, does one thing, commits. That is exactly what makes this hard to spot in review: there is no bad line. The bug is in the spaces between the lines, where two committed changes now exist without the third, and nothing in the code knows that combination is not supposed to happen.

one boundary
# one boundary, at the edge of the use case
def place_order(cmd):
    with unit_of_work() as uow:              # BEGIN
        order = uow.orders.insert(cmd)
        uow.inventory.reserve(order.lines)
        uow.dispatch.book(order.customer, order.lines)
    #                                        COMMIT — or none of it

# The repositories no longer own the boundary. The use case does.

The visible difference is that the with block moved outward by one level and the repositories now take the unit of work rather than owning one. That is the whole change, and it is the whole lesson.

Stated as a rule: the boundary is a claim about what must be true after a failure. So choose it by asking, of each change in the group, would it be wrong to keep this one on its own? Where the answer is yes, the changes belong inside one boundary. Where the answer is no, they do not, and forcing them together only makes the transaction longer.

One thing makes this harder than it sounds, and it is the reason so many codebases have boundaries nobody chose. Frameworks and ORMs open transactions implicitly. SQLAlchemy’s Session, for example, autobegins a transaction the moment you use it and holds that transaction until you commit, roll back or close the session. Nothing is wrong with that — but it means the boundary ends wherever the session’s life ends, which in a typical web application is the end of the request. If nobody has thought about it, your transaction boundary is whatever your framework’s request teardown happens to do. Go and find out what that is before you design around it.

Why this is the same question as how big an aggregate is#

An aggregate is a cluster of objects treated as one unit, reached from outside only through a single root, and loaded and saved whole. The rule that goes with it is the one that matters here: transactions should not cross aggregate boundaries. Read that from the other direction and you get the point of this section — the aggregate is the transaction boundary, described from the model side instead of the code side. They are not two decisions that need to agree. They are one decision, and if your aggregates and your transactions disagree, one of them is wrong.

Which turns an abstract modelling question into a concrete one you can answer. If two things must always agree, they sit inside one boundary and are therefore one aggregate. If it is acceptable for them to agree a second later, they are two, and something has to carry the change between them — an event, a scheduled reconciliation, a retry.

Both directions cost you something, and the costs are not symmetrical:

01

Too big

Unrelated work ends up contending for the same rows. The textbook case is an aggregate rooted on something every operation touches — a tenant, an account, a warehouse — which serialises the whole system through one row and looks like a mysterious throughput ceiling.

02

Too small

A rule that spans two aggregates has nowhere to live. It gets enforced by a check that reads one aggregate and writes the other, which is correct exactly until two of them run at once — and then it is quietly not.

The question that resolves most real cases is simpler than the modelling vocabulary suggests: what is the worst thing that happens if these two are briefly out of step? If the answer is a report that is stale for a second, they are two aggregates and you have just saved yourself a lock. If the answer is that stock is promised to two people at once, they are one.

The call inside the transaction#

The transaction that spans a network call. A handler opens a transaction, writes a row, calls a courier’s API over HTTP, and commits when the call returns. It reads as the safest possible arrangement — the row and the booking really do commit together, or so it looks. It is the worst shape in this article, and it fails in two separate ways.

The locks are held for someone else’s latency. The transaction stays open for as long as the remote service takes to answer. A database lock is normally held for single-digit milliseconds; an HTTP timeout is measured in seconds. You have connected the two, so the slowest response from a system you do not control now sets how long your rows stay locked. This is the shape that turns one degraded dependency into a database-wide stall, and it does it suddenly: everything is fine until the provider slows down, and then every request queues behind the same rows.

The commit can fail after the call succeeded. This one is worse, because no amount of care at runtime fixes it. The booking goes through, the commit then fails — a conflict, a lost connection, a timeout of your own — and the van is coming while the record of it rolled back. Retrying does not help, because the retry cannot tell whether the first attempt reached the provider. You are left reconciling by hand against someone else’s records.

Two fixes, and they compose:

Move the call outside the boundary and record the intention inside it. In the same transaction as the state change, write a row that says this call needs to be made. Commit. A separate process reads those rows and makes the calls. Now the two writes that must agree are both in your database, which is the one place they can commit together, and the remote call has become a delivery problem — which is a problem you can retry. This is the outbox, and it buys at-least-once delivery with a durable record, not exactly-once. Nothing does exactly-once here.

Which leads directly to the second fix, by way of the next stop on this path. The moment that outbox row becomes a message, what it promises to everyone downstream is narrower than it looks — an event guarantees delivery, not single delivery, and it cannot be withdrawn once it has been raised. That is precisely why the call has to be safe to make twice: a design property of the thing you are calling, and the subject of the stop after that, idempotency keys and safe retries. The architecture path reaches boundaries before either of them for exactly this reason.

IF YOU REMEMBER ONE THING

A transaction boundary is not a safety wrapper you make as large as you can afford. It is a statement about which changes have no meaning apart from each other — and anything inside it that is not one of those changes is just holding a lock.

Questions people also ask

5 QUESTIONS
Should one HTTP request be one transaction?

Usually one request is one use case, so the two line up. What makes it right is not the request, though — it is that the changes belong together. A request that does two independent things wants two boundaries, and a request that only reads wants none at all.

Can a transaction span two databases?

Not as a single atomic commit, unless you run a distributed transaction coordinator, which most teams should not. The practical answer is to put the changes that must agree into one database, and connect the rest with something that tolerates a gap — an outbox row, an event, a reconciliation job.

Why is a long transaction a problem if nothing else is running?

Because nothing else is running today. A transaction holds its locks and its snapshot until it ends, so a long one is a latent problem that appears the first time traffic doubles. On engines that keep old row versions for open transactions, it also stops routine cleanup from reclaiming space.

Where does the transaction go when the work is in a background job?

Around the job's own unit of work, exactly as it would around a request handler. The trap is a job that loops over a thousand records inside one boundary: one bad record rolls back the other 999. Commit per record, or per small batch, and make the job safe to run again.

Does my ORM open a transaction I did not ask for?

Very likely. SQLAlchemy's Session, for example, autobegins a transaction on first use and holds it until commit, rollback or close. That is not a bug, but it does mean the boundary exists wherever the session's life happens to end — so it is worth knowing where that is rather than assuming there is no boundary at all.