A capacity pool has ten units available. Two workers load it before either one writes:
worker A loads: version 0, reserved 0
worker B loads: version 0, reserved 0
worker A allocates 6 to allocation-a
worker B allocates 6 to allocation-bBoth decisions pass the aggregate’s rule. Each worker sees ten remaining units. They cannot both belong to one ordered history: after either allocation, only four remain.
The PostgreSQL reproduction first saves both objects without a
version condition. Both calls report success and both objects report version
one. The final row also says version one and six units reserved. It looks
reasonable until the child rows are inspected. Only allocation-b remains.
Worker A’s acknowledged allocation has disappeared.
Storing a version did not prevent anything. Optimistic concurrency begins when the loaded version becomes an atomic write precondition and failure changes the application’s outcome.
A transaction does not make a stale decision current
The naive fixture does use a database transaction for each save. Root and child rows change together, so neither save leaves a partially written aggregate. That solves atomicity within one write. It does not connect the earlier read to that write.
The two workers made their decisions from separate snapshots. Under the
fixture’s PostgreSQL READ COMMITTED isolation, each transaction can replace
the complete child collection and commit successfully. The later save is
internally consistent while still erasing an accepted result.
Incrementing a version column unconditionally has the same weakness:
UPDATE capacity_pools
SET version = version + 1
WHERE pool_id = :pool_idThe number records that writes happened. It does not prove that the state used to make this write was still current.
The optimistic form carries evidence from load to save:
UPDATE capacity_pools
SET version = :next_version
WHERE pool_id = :pool_id
AND version = :loaded_versionThe fixture lets worker A update pool-1 from version zero to one. Worker B
then asks to update the same pool from version zero. PostgreSQL affects zero
rows. The repository turns that result into ConcurrentChange rather than
returning success.
The affected-row check is the enforcement point. Remove the version predicate or ignore zero affected rows and the stale allocation replaces the committed one again.
The conflict belongs to the complete save
The pool owns its allocation collection, so the root comparison and child replacement form one persistence decision. The fixture deliberately replaces the children before attempting the root compare-and-swap, all inside one transaction:
$connection->beginTransaction();
try {
$this->replaceAllocations($pool);
$affected = $this->updateRootWhereVersionMatches(
pool: $pool,
loadedVersion: $pool->persistedVersion(),
);
if ($affected !== 1) {
throw ConcurrentChange::atVersion(
$pool->id,
$pool->persistedVersion(),
);
}
$connection->commit();
$pool->markPersisted($nextVersion);
} catch (Throwable $exception) {
$connection->rollBack();
throw $exception;
}Worker B deletes allocation-a and inserts allocation-b in its transaction.
Its root update then affects zero rows. Rollback restores allocation-a and
removes allocation-b. Commit the children before the comparison and the
negative control catches the lost allocation.
The in-memory object advances only after the database commit. Marking it first would let a commit failure leave the object claiming a version storage never accepted. That ordering is a small detail until the same object remains in a unit of work, records events against its version, or is passed to another collaborator after failure.
This fixture maps both a changed row and a deleted row to one broad
ConcurrentChange. A zero-row update cannot distinguish those cases by
itself. An application which promises separate “deleted” and “modified”
responses needs another reliable observation; the repository should not infer
one from the failed comparison.
Retry the decision, not the mutated object
A stale aggregate still contains the decision made against old facts. Giving it the current version and saving again is an overwrite with extra steps.
The fixture proves the difference with a bounded retry. Its first attempt loads
version zero and allocates six units to allocation-b. Before that save,
another worker commits allocation-a. The first save conflicts.
The retry discards the stale object, reloads version one, and calls allocate()
again. The aggregate now sees six reserved units and refuses the second
six-unit request:
attempt 1: accepted locally, rejected by save as stale
attempt 2: reloaded, rejected by the domain for insufficient capacityBlindly saving the first object a second time produces another version conflict. Moving the load outside the retry loop produces the wrong domain failure in the fixture because the same allocation is applied twice.
This does not make every command automatically retryable. Repeating a decision may repeat a remote call, consume a one-time token, use a changed price, or override a choice a person should see again. The application owns that policy. The repository can report a conflict; it cannot decide that the command remains safe and meaningful on new state.
For an HTTP request, returning a conflict may be the honest outcome. For a message whose intent is stable and whose effects are idempotent, reloading and re-running the complete handler may be appropriate. In both cases, the stale object is evidence of failure, not input for a hidden repair.
A row lock moves the decision inside the wait
Optimistic control lets both workers decide, then rejects one at save. Pessimistic control changes the order: a worker acquires the row before it loads the state used by the decision.
Laravel exposes this through lockForUpdate(). The transaction
has to span the locking read, domain decision, and save. Acquiring a lock in a
repository method which commits before the caller changes the aggregate
protects nothing after the method returns.
The fixture coordinates two real PostgreSQL sessions with explicit process barriers:
worker A: BEGIN; SELECT ... FOR UPDATE -> version 0
worker B: BEGIN; SELECT ... FOR UPDATE -> waiting
worker A: allocate 6; save; COMMIT -> version 1
worker B: locking read returns version 1 with 6 reserved
worker B: allocate 6 -> domain refusalWhile worker A holds the row, PostgreSQL reports worker B as active with
wait_event_type=Lock and wait_event=transactionid. After the commit, worker
B’s locking read returns the updated state rather than the snapshot worker A
originally saw. Remove FOR UPDATE, or commit before the decision, and the
wait-state assertion fails.
PostgreSQL’s row-lock documentation defines the blocking and transaction-lifetime behavior. The retained trace proves it for PostgreSQL 18.3 and this exact sequence. It does not establish that waiting is cheaper than handling conflicts in another workload.
Use this shape when all contenders share one database, the decision is short, and holding a transaction open while it runs is acceptable. It is a poor fit for a user interaction or remote call which may keep a row locked for an unbounded period. Optimistic control keeps that work outside the lock, at the cost of an explicit conflict path.
Sometimes the database can make the whole decision
The aggregate example contains identity-bearing allocations and replaces an owned collection. A scalar capacity counter has a smaller contract. PostgreSQL can enforce it in one statement:
UPDATE capacity_counters
SET reserved = reserved + :quantity
WHERE pool_id = :pool_id
AND reserved + :quantity <= capacityWhen two sessions run it for six units against capacity ten, one statement affects a row and the other affects zero after waiting for the first transaction. The final counter remains six. No aggregate version is needed because the database evaluates the current value and condition atomically.
This alternative is better when that statement expresses the complete operation. Its zero-row result still needs a documented meaning: the pool may be missing or lack capacity. It also does not preserve allocation identities, an owned collection, or rules which span those children. Add those obligations and either the statement, schema, or transaction has to grow with them.
Database constraints deserve the same preference. Unique allocation identity, positive quantity, and foreign-key ownership are facts the schema can enforce directly. A root version should not replace constraints with a single coarse conflict switch.
One root version deliberately rejects independent work
An aggregate-root version says every protected change has one serial order. That is exactly the point when rules span the root and its children. It may be needlessly restrictive when two children can change independently.
If separate allocations commute and no cross-child rule exists, append-only rows, per-child versions, or one conditional insert may allow both writes without conflict. If accepting one child changes whether another is valid, a root version or a database mechanism with equivalent scope is the honest boundary.
The repository’s in-memory implementation has to make the same promise if tests
treat it as substitutable. The fixture loads two copies from its in-memory
repository, saves one, and requires the second to raise the same conflict while
preserving the winner. Remove its version comparison and the contract test
fails. A fake which always accepts save() can test application flow, but it
cannot stand in for this concurrency contract.
Serializable transactions are another credible route when the complete transaction can be retried after a serialization failure. They do not remove the retry decision; they move conflict detection into the database. This fixture does not compare that mechanism, so it makes no claim about which option a workload should prefer.
Choose concurrency control from the decision that can go stale. If one atomic statement or constraint can decide it, use that smaller boundary. If a short database transaction can contain the decision, a row lock may be clearer. If work happens outside that scope, carry the loaded version into an atomic save, reject zero affected rows, and re-run only decisions which are safe to make again.
A version column is evidence. The save contract decides whether that evidence can stop a stale write.