After the article about aggregates and consistency boundaries the next question was almost unavoidable for me: if an aggregate defines a consistency boundary, where should the database transaction actually begin and end?
I used to think in a simple way: one business operation equals one transaction.
As a use case grows, however, that transaction starts absorbing queues, external APIs, heavy data preparation, logging, notifications, and work that should never hold database locks.
Now I look at the state that truly must change consistently rather than at the total size of the use case.
One Business Operation Does Not Mean One Big Transaction
Business operation: Activate product Possible steps: 1. load product2. validate domain rule3. change product state4. save product5. write audit entry6. send notification7. call external service8. refresh cache9. publish eventFrom the user perspective, this may be one action.
From the consistency perspective, it contains several different kinds of work.
The Product state change may be critical. Sending a notification or refreshing cache does not necessarily need to participate in the same database transaction.
If I keep everything together only because "this is one use case," the transaction becomes longer without a domain reason.
I Start from the Invariant, Not from the Use Case
Invariant: An active productmust have valid required state. This must be atomic: validate |change state |save |commitIf the rule says "an active product must always be in a valid state," that is the transition I want to protect.
Reading current state, validating it, changing it, and persisting it should form one consistent decision.
An email about successful activation is not part of that invariant.
If the email fails, Product may still remain correctly activated.
The Application Service Helps Make the Transaction Boundary Visible
public function handle( ProductId $productId,): void { DB::transaction(function () use ( $productId, ) { $product = $this->products ->getForUpdate($productId); $product->activate(); $this->products->save( $product, ); }); In the article about Application Service and Domain Service I wrote that the application service coordinates the use case.
This is a good place to make the distinction visible: this part is transactional, while this part runs after commit.
That structure explains the system requirements more clearly than one large DB::transaction() around the whole method.
The Longer the Transaction, the Longer We Hold Resources
DB::transaction(function () use ($product) { $product->activate(); $this->products->save($product); $this->externalApi->sync($product); $this->mailer->sendActivated($product); $this->cache->rebuild($product);});I covered this separately in a separate article about short transactions and avoiding work under locks.
While the transaction is open, we may hold locks and increase the chance of conflicts.
An external API may take seconds. A mailer may stall. Cache rebuilding may be expensive.
If none of that work is required for the invariant itself, I do not want it inside the critical section.
When One Transaction Is Still Better Than an Event
This does not mean I try to move everything out of the transaction.
If two changes together form one invariant, they need to be persisted together.
For example, if the system must never observe a new status without required related state, splitting that through an event would make consistency weaker.
I covered that situation in the article about when one transaction is better than an event.
An Event Is for a Reaction After the Fact, Not a Replacement for an Invariant
Transaction: Product activated | vcommit After commit: ProductActivated event | +-- notification +-- analytics +-- search updateIf the second action is a reaction to an already successful change, an event is often a better fit.
For example, Product is already activated. Notifications, analytics, or search can react afterward.
This cleanly separates the domain fact from secondary processes. I discussed this in more detail in the article about when an event is the right choice.
Secondary Work Should Not Observe State That Can Still Roll Back
DB::transaction(function () use ($product) { $product->activate(); $this->products->save( $product, ); DB::afterCommit(function () use ( $product, ) { ProductActivated::dispatch( $product->id, ); });});This is another practical boundary I now check explicitly.
If a listener or job starts before commit, it may observe old state or react to a change that later rolls back.
Reactions to an already persisted fact should therefore start only after a successful commit.
Two Aggregates in One Use Case Do Not Automatically Become One Aggregate
Use case Product aggregate | +-- change state Access aggregate | +-- maybe create/update Same use case:yes Same aggregate:not necessarilyThis is especially easy to confuse.
One use case may change two aggregates. Technically, both may even be updated inside one database transaction.
That does not mean they should become one model.
I look at whether they protect one invariant or two separate invariants that happen to be coordinated by one use case.
I Find the Boundary by Asking What Is Allowed to Fail Separately
Ask: If notification fails,must the product rollback? If analytics fails,must the product rollback? If external sync fails,must the product rollback? If required product state fails,must activation rollback? The answers revealThis is probably the most useful practical test.
I ask: if this step fails, must the primary state change roll back?
If the answer is "yes, otherwise the system would be invalid," the step likely belongs inside the critical boundary.
If the answer is "no, we can retry it later," it is a strong candidate for post-commit work.
A Network Call Inside a Transaction Is Now a Warning Sign for Me
Not every network call inside a transaction is automatically wrong, but I always want a very strong reason for it.
The network is slow and unreliable compared with a local database change.
If an external system is unavailable, I do not want to hold a database lock longer simply because the whole use case was wrapped in one transaction.
Often the better design is to commit the local fact and make external synchronization retryable and observable separately.
In AVN Center, the Consistency Boundary Became Clearer Together with the Rules
While working on AVN Center, this distinction also became practical rather than theoretical for me.
When a use case is small, it is easy to wrap everything in one transaction and see no immediate problem.
As more rules, reactions, and integrations appear, it becomes important to separate what creates valid domain state from what merely reacts to an already successful change.
I intentionally avoid exposing internal AVN Center scenarios here. The useful lesson is general: the transaction boundary should grow from the invariant, not from the length of the use case.
Questions I Ask Before Expanding a Transaction
I check:
- Which invariant does this transaction actually protect?
- Which records truly need to commit or roll back together?
- Does this step change valid aggregate state or merely react to it?
- If this step fails, must the primary change roll back too?
- Is there a network or expensive operation inside the transaction?
- Can this secondary work be retried safely after commit?
- Are we holding a lock only because the whole use case is wrapped in one block?
The Real Transaction Boundary Follows the Rule
DDD and transactions became much simpler for me when I stopped equating one business operation with one large DB::transaction().
I identify the invariant first. Then I determine which state is required to protect it. That is the part that needs strong consistency.
Everything else gets a separate question: must this roll back with the primary change, or can it happen after commit?
For me, a transaction no longer wraps the whole use case. It wraps the part of the use case without which the rule could become false.
One business operation can be large. The real transaction boundary is often much smaller: only the state that must remain consistent together.



