ADR-010: Optimistic concurrency, enforced by every repository
Status: accepted
Context
Two requests can load the same tournament, both call advance(), and both save. Without a
guard the second write silently overwrites the first: a lost update. We reproduced it on
SQLite with two sessions before writing this ADR. A template that promises "two
interchangeable adapters" must also promise they fail the same way.
Decision
- Aggregates carry a
version: int(persistence data, never touched by domain logic). - The repository port's
save(aggregate) -> aggregatecontract: the stored version must equalaggregate.version; on success the write is applied and the returned aggregate carriesversion + 1; otherwiseConflictErroris raised and nothing is written. - SQLAlchemy adapter:
version_id_colon the row model, so every update isUPDATE ... WHERE id = ? AND version = ?and a zero-row update raisesStaleDataError, translated toConflictError. An explicit comparison covers the case where the row was already reloaded in the same session. New rows start at version 0 to match the in-memory adapter. - In-memory adapter: the same rule, by comparing versions.
ConflictErroris anApplicationError→ HTTP409with the usual envelope. The client reloads and retries; the CLI prints the error and exits 1.tests/tournaments/test_repository_contract.pyruns the same tests against both adapters, including two independent readers racing on one row.
What this does and does not guarantee
- Guaranteed: a stale write never overwrites a newer one, on every backend, without locks held across requests.
- Guaranteed: the conflict is observable as an application error, not as a mystery.
- Not guaranteed: a retry policy. Retrying is the client's decision; the template does not loop.
- Not guaranteed: cross-aggregate invariants. One request, one aggregate (ADR-004).
Consequences
- Use cases return what the repository stored (
saved = await repository.save(...)), so the response always reflects the persisted version. - The
versionfield is invisible to HTTP clients today. Exposing it (for example as anETag) is a one-line change in the response schema when a client needs to sendIf-Match.
Proof
- proof:optimistic-concurrency - two independent readers race on one row on both adapters;
the second write raises
ConflictError, exactly one version is stored, HTTP answers 409.