Skip to content

0050. The Read-Model Conflict Error Gets Its Own Name

Two unrelated exception classes were both called OptimisticLockError. Neither caught the other, neither derived from the other, and their constructors did not agree on a single argument. Only the import path distinguished them.

Status

Accepted.

ADR Impact

ADR Status
0029 Amended — its recorded exception, that ports/readmodels/ would carry an OptimisticLockError colliding by name with the domain one, no longer applies. The subpackage split it decided is untouched.
0041 Stands. The split it decided put infrastructure-meaning exceptions in ports/exceptions.py and domain ones in domain/exceptions.py; the read-model family was never part of either move and stays in ports/readmodels/exceptions.py.

Context

eventsource.domain.exceptions.OptimisticLockError(EventSourceError) is raised by append when a stream's version does not match expected_version. It carries aggregate_id, expected_version, actual_version.

eventsource.ports.readmodels.exceptions.OptimisticLockError(ReadModelError) is raised by save_with_version_check when a read model's stored save-count does not match the one the caller loaded. It carries model_id, expected_version, actual_version.

The two guard different things. The first protects the consistency boundary of an aggregate, and a caller handling it reloads the aggregate and re-runs a command. The second protects a row against a lost update, and a caller handling it re-reads the row and re-projects. A version on the read side counts saves of that row and has no relationship to any position in the event stream.

Sharing a name across that seam costs in three ways. except OptimisticLockError reads as though it covers both and covers exactly one, with which one decided by an import the reader has to scroll up to find. Prose has to disambiguate every mention — ports/readmodels/__init__.py carried a paragraph doing nothing else, and docs/architecture.md carried another. And an __init__.py that wants to re-export both cannot, which is why the public API exports the domain one and leaves the read-model one reachable only through eventsource.ports.readmodels.

Decision

Rename the read-model class to ReadModelVersionConflictError. The domain class keeps OptimisticLockError — it is the one the public API exports, the one the tutorials and the event-store reference discuss, and the one whose name matches the pattern's usual vocabulary.

The name is not ReadModelOptimisticLockError. The mechanism is optimistic locking either way; what a reader needs from the name is which thing conflicted, and version conflict on a read model is that. It also keeps the class inside the ReadModel* prefix the rest of the family already uses (ReadModelError, ReadModelNotFoundError).

No deprecation alias. The project's pre-1.0 no-shim policy (ADR 0030) applies: the old name is gone, and from eventsource.ports.readmodels import OptimisticLockError raises ImportError rather than resolving to something that will be removed later. A silent alias would preserve exactly the ambiguity this ADR exists to remove.

Consequences

A caller that imported the read-model error by name must update the import; one that caught it via ReadModelError is unaffected. The domain-side name and import path do not change, so write-side code is untouched.

The disambiguating prose in ports/readmodels/__init__.py and docs/architecture.md shrinks to a historical note — worth keeping, because a reader who encounters the old name in a changelog or an older branch still needs to know which class it was.

The public API remains free to export ReadModelVersionConflictError later without a collision. It does not do so here only because it exports no read-model exception today, and adding one alone would be arbitrary.