0041. Infrastructure Exceptions Move to ports/exceptions.py¶
Thirteen exception classes move out of domain/exceptions.py into a new
ports/exceptions.py: CheckpointError, CheckpointNotFoundError,
EventBusConnectionError, EventStoreConnectionError,
LockAcquisitionError, LockNotHeldError, PositionDecodeError,
PositionForeignError, SubscriptionError, SubscriptionConfigError,
SubscriptionStateError, SubscriptionAlreadyExistsError,
TransitionError. All thirteen stay rooted in EventSourceError; only
their module changes.
Status¶
Accepted. Implemented in the domain-ring hardening wave (task 7).
ports/exceptions.py defines the thirteen classes;
domain/exceptions.py no longer does. No shim: from
eventsource.domain.exceptions import LockAcquisitionError (or any of the
other twelve) now raises ImportError.
Amended by 0048 - Failure Paths Report Honestly and Retain What They Cannot
Handle, scoped to one class:
EventStoreConnectionError moves from SubscriptionError to
EventStoreError. The module it lives in and the other twelve classes are
unchanged.
Amends ADR 0030. ADR
0030's Decision is not retro-edited: exceptions.py still moved onto
domain/ exactly as that ADR describes. What changes is that
domain/exceptions.py's contents are no longer settled as one
undifferentiated hierarchy — this ADR carves thirteen of its members back
out into ports/. ADR 0030's Status section carries an "Amended by ADR
0041" pointer.
Amends ADR 0032. ADR 0032's
Decision §5 merged the subscription exception family into
domain/exceptions.py and rebased SubscriptionError onto
EventSourceError; neither of those choices is reversed here. What moves
is only the module: SubscriptionError and its seven subclasses relocate
from domain/exceptions.py to ports/exceptions.py, the same file the
lock and checkpoint exceptions land in by this same ADR. ADR 0032's Status
section carries an "Amended by ADR 0041" pointer.
Context¶
Tier 0 — stdlib and pydantic only, no I/O, importable from every ring
without violating the Dependency Rule — became, over the course of the
ring-migration campaign, the universal error taxonomy: domain/exceptions.py
is the only module every ring may import, because domain/ sits innermost
and every outer ring is permitted to depend on it. That made it the
path of least resistance every time a new subsystem needed an
EventSourceError subclass and a home for it, regardless of what the
subsystem actually did — ADR 0029 rebased the lock exceptions there, ADR
0032 rebased the subscription exceptions there, and CheckpointError and
the position-decoding errors accreted the same way. By the end of that
campaign, domain/exceptions.py held twenty-four classes, and roughly a
third of them described a port-contract failure — a lock could not be
acquired, a subscription's checkpoint could not be found, an event
store's connection dropped — with no bearing on aggregates, commands, or
event application. None of those thirteen carry domain meaning: an
AggregateNotFoundError or OptimisticLockError describes something a
decide() function or an aggregate's _apply could reasonably reason
about; LockAcquisitionError describes a LockManager adapter failing to
talk to Postgres advisory locks, which no domain code has any business
knowing about.
This is the same shape of problem ADR 0029 and ADR 0032 each widened
(rebasing a bare-Exception family onto EventSourceError) without
asking where the result should live — both moves defaulted to
domain/exceptions.py because that was the only ring-map location the
EventSourceError root already occupied. Task 7 of the domain-ring
hardening wave is the first pass that asks the placement question
directly, once ports/ was mature enough (ADR 0019, 0024, 0029) to be an
obvious second home for exceptions describing port-contract failures
specifically.
Decision¶
The thirteen classes named above move to a new module,
ports/exceptions.py, importing only EventSourceError from
domain/exceptions.py. Every class keeps its exact name, constructor
signature, attributes, and message format — this is a relocation, not a
redesign. SubscriptionError's seven subclasses move as a unit, preserving
the existing single-level inheritance under SubscriptionError itself.
No shim. import eventsource.domain.exceptions still succeeds (the
module still exists and still defines its remaining eleven classes), but
from eventsource.domain.exceptions import LockAcquisitionError now
raises ImportError rather than resolving through a compatibility
re-export. This follows the same standing pre-1.0 rule ADR 0025, ADR
0026, and ADR 0030 already established: the library has no external
consumers, so a deprecation shim would buy nothing beyond
__getattr__/__dir__ plumbing to maintain for a promise nobody is
relying on.
domain/exceptions.py keeps the broad domain-meaning categories —
EventStoreError and EventBusError remain there as the general-purpose
markers domain code itself raises or catches — along with everything
else domain code raises directly: OptimisticLockError,
AggregateNotFoundError, EventVersionError, UnhandledEventError,
AggregateNotCreatedError, the event-registry and handler exceptions,
SnapshotError and its subclasses, and the three tenant-context
exceptions ADR 0038 merged in. Nothing about those eleven classes' domain
membership is questioned by this ADR.
Consequences¶
Positive¶
domain/exceptions.pynow holds only exceptions with actual domain meaning — the entities ring's exception surface matches what the.claude/rules/architecture.mdring map has always claimed it should.ports/exceptions.pygives adapters and application code one place to import infrastructure-failure types from, symmetric with howports/locks.py,ports/subscribers.py, andports/coordination.pyalready hold the Protocol contracts those same failures relate to.except EventSourceErrorbehavior is completely unchanged: every one of the thirteen classes is stillEventSourceError-rooted, so no boundary handler that catches broadly needs to change.
Negative¶
- BREAKING: any import of the form
from eventsource.domain.exceptions import LockAcquisitionError(or the other twelve names) now raisesImportError. There is no transition window. Top-levelfrom eventsource import ...re-exports are unaffected — none of the thirteen were re-exported from the root barrel, so no root-level import breaks. - Two modules now define exception hierarchies with the same root
(
domain/exceptions.pyandports/exceptions.pyboth importEventSourceError), so a reader checking "where does this exception live" must know the split exists rather than assuming one file. This ADR and the exceptions reference doc (docs/api/exceptions.md) are where that split is now written down.
Alternatives Considered¶
Leave the thirteen classes where they were and document the
inconsistency instead of fixing it. Rejected: domain/exceptions.py
having no I/O and no adapter awareness is exactly what makes it safely
importable from every ring; the moment it names LockAcquisitionError
with a timeout attribute describing a database wait, it is describing
adapter behavior from inside the one module every ring trusts to be free
of it. The inconsistency was cheap to fix and only got more expensive to
unwind the longer it sat.
Split further, giving SubscriptionError's subclasses their own module
separate from the lock and checkpoint exceptions. Rejected: all
thirteen classes describe the same category of thing — a port contract
that could not be satisfied — and ports/ already groups Protocols by
port, not by exception family; one ports/exceptions.py module mirrors
how domain/exceptions.py itself is one flat module for its own broader
category, rather than one file per exception origin.
References¶
src/eventsource/ports/exceptions.py— the new modulesrc/eventsource/domain/exceptions.py— the eleven classes remaining after this move- ADR 0029 — the lock
exceptions' original rebase onto
EventSourceError, whose module this ADR now changes - ADR 0030 — settled
exceptions.pyontodomain/as a whole; amended above - ADR 0032 — the subscription
exceptions' original rebase and merge into
domain/exceptions.py; amended above - ADR 0038 — the tenant exceptions
that stay in
domain/exceptions.py, unaffected by this ADR
Related¶
docs/api/exceptions.md— exception hierarchy reference, updated for the new module splitdocs/guides/error-handling.md,docs/guides/distributed-locks.md,docs/api/locks.md— updated import paths.claude/rules/architecture.md— entities-ring and ports-ring bullets updated to record the split