DEV Community

Cover image for The Unit of Work Boundary in PHP: One Transaction Per Use Case
Gabriel Anhaia
Gabriel Anhaia

Posted on

The Unit of Work Boundary in PHP: One Transaction Per Use Case


A support ticket lands on your desk. An order exists in the database with a paid status, but no line items. The customer was charged. The warehouse has nothing to pick. You open the row, and it makes no sense: the header wrote, the items did not.

You trace the use case. It saves the order header, calls a downstream service, then saves the items. Somewhere in the middle it flushed. The header committed. The items were still in memory when a validation exception fired three lines later. The transaction that should have covered both never existed, because nobody drew it. Each save opened and closed its own.

This is the half-committed aggregate. It comes from one missing decision: where does the transaction begin and end. The answer is one transaction per use case, and the use case is the only thing that gets to say when it opens.

The Unit of Work you already have

If you use Doctrine, you already run a Unit of Work. It tracks every entity you touch, batches the writes, and sends them at flush(). The mistake is calling flush() inside a repository.

final class DoctrineOrderRepository implements OrderRepository
{
    public function __construct(
        private EntityManagerInterface $em,
    ) {}

    public function save(Order $order): void
    {
        $this->em->persist($order);
        $this->em->flush(); // the bug
    }
}
Enter fullscreen mode Exit fullscreen mode

That flush() looks harmless. It makes the repository "just work" in a test. But it hands the transaction boundary to the repository, and a repository has no idea what else the use case still has to do. Call save on the header, flush runs, the header is committed. Whatever fails after that leaves the database holding half the work.

Move the flush() out. A repository stages; it does not commit.

final class DoctrineOrderRepository implements OrderRepository
{
    public function __construct(
        private EntityManagerInterface $em,
    ) {}

    public function save(Order $order): void
    {
        $this->em->persist($order);
        // no flush here
    }
}
Enter fullscreen mode Exit fullscreen mode

Now nothing commits until someone above the repository says so. That someone is the boundary.

One transaction per use case

The rule is short. Every use case runs inside exactly one transaction. It opens when the use case starts, commits when the use case returns, rolls back if the use case throws. Nothing inside the use case opens a second one.

The tempting place to put the beginTransaction / commit pair is inside the use case body:

public function execute(PlaceOrderInput $in): PlaceOrderOutput
{
    $this->em->beginTransaction();
    try {
        // ... domain work ...
        $this->em->flush();
        $this->em->commit();
    } catch (\Throwable $e) {
        $this->em->rollback();
        throw $e;
    }
}
Enter fullscreen mode Exit fullscreen mode

It works, and it also drags EntityManagerInterface into a class that had no framework imports a minute ago. Every use case now repeats the same try/catch. The transaction is a cross-cutting concern wearing a domain costume.

Pull it out into a port. The application layer states the requirement in its own words.

<?php

declare(strict_types=1);

namespace App\Application\Port;

interface TransactionManager
{
    /**
     * Run $work in a single transaction. Commit on
     * return, roll back on any thrown exception.
     *
     * @template T
     * @param callable(): T $work
     * @return T
     */
    public function transactional(callable $work): mixed;
}
Enter fullscreen mode Exit fullscreen mode

The use case depends on that interface, not on Doctrine. The Doctrine adapter is the only class that knows what a transaction actually is.

<?php

declare(strict_types=1);

namespace App\Infrastructure\Persistence\Doctrine;

use App\Application\Port\TransactionManager;
use Doctrine\ORM\EntityManagerInterface;

final readonly class DoctrineTransactionManager
    implements TransactionManager
{
    public function __construct(
        private EntityManagerInterface $em,
    ) {}

    public function transactional(callable $work): mixed
    {
        return $this->em->wrapInTransaction(
            fn() => $work(),
        );
    }
}
Enter fullscreen mode Exit fullscreen mode

wrapInTransaction opens the transaction, runs the closure, flushes the EntityManager, commits on return, rolls back on any throwable, and rethrows. You never call flush() yourself. The wrapper flushes once, after all domain work is staged and before the commit. One flush, one commit, one boundary.

Keep it out of the domain

The cleanest version does not put transactional() in the use case body either. Wrap the whole use case in a decorator at wiring time.

<?php

declare(strict_types=1);

namespace App\Infrastructure\Persistence;

use App\Application\Port\TransactionManager;

final readonly class TransactionalUseCase
{
    public function __construct(
        private object $inner,
        private TransactionManager $tx,
    ) {}

    public function execute(object $input): mixed
    {
        return $this->tx->transactional(
            fn() => $this->inner->execute($input),
        );
    }
}
Enter fullscreen mode Exit fullscreen mode

Bind it in the composition root:

$c->set(PlaceOrder::class, fn(C $c) => new TransactionalUseCase(
    new PlaceOrder(
        $c->get(OrderRepository::class),
        $c->get(PaymentGateway::class),
        $c->get(EventBus::class),
        $c->get(Clock::class),
    ),
    $c->get(TransactionManager::class),
));
Enter fullscreen mode Exit fullscreen mode

The PlaceOrder class stays pure. It stages entities through repositories and never mentions a transaction. The boundary lives one ring out, in infrastructure, where it belongs. Read the use case in isolation and you see business steps, not commit ceremony.

Flush timing and the read-your-writes trap

Once the flush moves to the boundary, a class of bug disappears and a smaller one appears. The disappeared bug is the half-committed aggregate. The new one is stale reads inside the same use case.

If step three of your use case queries for something step one just created, and you flushed at the boundary, the query runs before the flush and finds nothing. Doctrine's identity map covers find() by primary key from memory, so that path is fine. A DQL query or a findBy on a non-id field hits the database and misses the not-yet-flushed row.

The fix is not to sprinkle flush() calls back in. It is to keep the aggregate in memory and pass the object, not re-query it. If a use case genuinely needs a mid-transaction flush so a later SELECT sees earlier writes, that is a signal the operation spans two aggregates and might want two use cases, or a read model that does not care about the open transaction.

What must live outside the transaction

Not everything belongs inside the boundary. Two things routinely leak in and should not.

External calls are the first. A payment charge over HTTP inside an open database transaction holds row locks for the length of a network round trip. If the gateway is slow, your locks are slow, and other requests queue behind them. Do the external call, get the result, then open the transaction to write the outcome. The database work should be short.

Domain events are the second. Publishing to RabbitMQ or Apache Kafka inside the transaction risks the classic dual-write: the message goes out, the transaction rolls back, and now a consumer acts on an order that does not exist. The fix is the outbox. Inside the transaction, write the event to an outbox table in the same commit as the aggregate. A separate relay reads the outbox and publishes after the commit is durable. The event and the state share one transaction; the network publish sits outside it.

public function transactional(callable $work): mixed
{
    // Correct: outbox row committed WITH the aggregate.
    // Wrong: $broker->publish() called inside $work,
    //        before the commit is durable.
    return $this->em->wrapInTransaction(
        fn() => $work(),
    );
}
Enter fullscreen mode Exit fullscreen mode

Nested boundaries and savepoints

One more trap. If a use case calls another use case, and both are wrapped, you get a transaction inside a transaction. Doctrine handles this with a nesting counter by default: the inner wrapInTransaction does not really open a new transaction, it increments a level, and only the outermost commit hits the database. That is usually what you want. The whole nested call is atomic.

The surprise is rollback. If the inner call throws and you catch it in the outer use case meaning to continue, the transaction is already marked rollback-only. The outer commit fails. If you need the inner unit to roll back independently while the outer continues, turn on savepoints with setNestTransactionsWithSavepoints(true) and the inner boundary becomes a savepoint that can roll back on its own. Reach for that only when you have a real reason. Most use cases want one flat transaction and no nesting at all.

The whole discipline is one sentence: the use case owns the boundary, and nothing below it gets to commit. Repositories stage. The transaction manager commits once, at the edge. Get that right and the half-written row stops arriving in your tickets.


If this was useful

A transaction boundary is a framework concern, and the reason it belongs at the edge is the same reason the payment client and the message broker do: keep the mechanics in adapters so the use case reads as business steps, not plumbing. That edge-versus-core split is the whole argument of Decoupled PHP — ports for the things that change, a domain that survives the framework, and a transaction manager that is one more adapter you can swap without touching a single rule.

Decoupled PHP — Clean and Hexagonal Architecture for Applications That Outlive the Framework

Available on Kindle, Paperback, and Hardcover. English, German, and Japanese editions out now — Portuguese and Spanish coming soon.

Top comments (0)