0044. Migration Error Module Decomposition¶
application/migration/exceptions.py had grown to 1533 lines and was an
exceptions module in name only — it held a circuit breaker, an error
handler, a retry-config system, and a classification vocabulary alongside
the MigrationError taxonomy. It splits into four single-responsibility
modules, layered as a one-way DAG so that the mutual dependency between the
taxonomy and the vocabulary — each needing the other — resolves without a
cycle.
Status¶
Accepted. Implemented across the four tasks of the migration-error decomposition wave.
ADR Impact
| ADR | Status |
|---|---|
| 0034 | Amended — the fourteen-module list that settled application/migration/'s contents (Decision Table) gains three new modules from this split; the ring placement and layers contract ADR 0034 decided are unaffected. |
| 0041 | Stands — this ADR's placement calls (CircuitBreakerOpenError staying with the taxonomy) follow ADR 0041's one-taxonomy-per-area convention rather than revising it. |
ADR 0034's Status section carries an "Amended by ADR 0044" pointer.
Context¶
application/migration/exceptions.py had grown to 1533 lines and was an
exceptions module in name only: alongside MigrationError and its
subclasses it held CircuitBreaker and its supporting types, ErrorHandler
and classify_exception(), a RetryConfig system, and the
ErrorSeverity/ErrorRecoverability/ErrorClassification vocabulary those
retry configs and the handler both depend on. The 2026-08-01
DDD/dogfooding review flagged it as the largest remaining structural defect
in the codebase after the domain-ring hardening work — a single module
mixing a taxonomy, a runtime state machine, a bridge function, and a value
vocabulary, each with a different reason to change.
Splitting it naively deadlocks. MigrationError._default_classification
needs ErrorClassification, ErrorSeverity, ErrorRecoverability, and
RetryConfig to compute a default classification for an unclassified
error. classify_exception() needs MigrationError — it is the function
that maps an arbitrary exception onto the migration error taxonomy. Put the
vocabulary and the taxonomy in the same module and it is still one
oversized file; put them in two modules that import each other and the
Python import system deadlocks the first time either module is imported
directly.
Decision¶
The module decomposes into four modules forming a one-way DAG: vocabulary → taxonomy → circuit breaker → handling. Each module may import only the modules to its left; none may import a module to its right, and none may import a sibling out of order.
| Module | Contents | Lines | Imports (siblings) |
|---|---|---|---|
error_classification.py |
ErrorSeverity, ErrorRecoverability, ErrorClassification, RetryConfig, TRANSIENT_RETRY_CONFIG, CONNECTIVITY_RETRY_CONFIG, CUTOVER_RETRY_CONFIG |
334 | none |
exceptions.py |
MigrationError + 13 subclasses, including CircuitBreakerOpenError |
710 (down from 1533) | error_classification.py |
circuit_breaker.py |
CircuitState, CircuitBreakerConfig, CircuitBreaker, CircuitBreakerContext |
254 | exceptions.py |
error_handling.py |
ErrorHandler, classify_exception() |
308 | error_classification.py, exceptions.py, circuit_breaker.py |
The vocabulary carries no sibling imports at all, which is what breaks the
cycle: error_classification.py sits underneath the taxonomy rather than
beside it, so exceptions.py can depend downward on the vocabulary without
the vocabulary needing anything back from the taxonomy.
Two placement calls were made explicitly, not by default:
CircuitBreakerOpenError stays with the taxonomy, not the circuit
breaker. It is a MigrationError subclass — a member of the exception
hierarchy the taxonomy owns — even though the circuit breaker is the only
thing that raises it. Splitting it into circuit_breaker.py would put half
of one taxonomy in each of two modules, one taxonomy per area rather than
one taxonomy per module — the same convention ADR 0041 established when it
moved infrastructure-meaning exceptions out of domain/exceptions.py as
complete groups, not individually by raiser.
classify_exception() goes to error_handling.py, not
exceptions.py. It depends on both the vocabulary (to build a
classification) and the taxonomy (to match exception types), which places
it downstream of both by construction. It is also, in nature, a runtime
bridge function — mapping an arbitrary caught exception onto the migration
error taxonomy at the moment of handling — not a member of the taxonomy
itself, so it belongs beside ErrorHandler, the other piece of runtime
error-handling logic, rather than inside the type hierarchy it classifies
against.
The cycle¶
The mutual dependency is real, not incidental: MigrationError's default
classification needs the vocabulary layer, and classify_exception() needs
the taxonomy layer. The decomposition above resolves it by making the
dependency one-directional at the module level — the vocabulary never
imports the taxonomy, only the taxonomy imports the vocabulary — while both
directions of the conceptual relationship (a MigrationError self-reports
a classification; a bridge function classifies an arbitrary exception
against the taxonomy) remain expressible, just from different modules.
The layering is enforced by
tests/unit/application/migration/test_module_layering.py, which AST-parses
each module's sibling imports and fails on any import that violates the
DAG. A TYPE_CHECKING-guarded import was explicitly considered and
rejected as a way to satisfy the test while keeping a cycle at the type
level: the test does not exempt TYPE_CHECKING blocks, on the reasoning
that a cycle hidden behind a type-checking guard is still a cycle in the
design, invisible to the layering contract only because it never executes.
This forced a real decision mid-wave — see Consequences.
Consequences¶
Positive¶
exceptions.pyreturns to being what its name says: theMigrationErrortaxonomy alone, at 710 lines, down from 1533.- Each of the four modules has one reason to change: the vocabulary changes when classification semantics change, the taxonomy when a new migration failure mode is named, the circuit breaker when its state machine changes, and the handler when the bridge or dispatch logic changes.
- The layering test fails loudly, at commit time, if a future change reintroduces a cycle — the DAG is enforced mechanically, not just by convention.
Negative¶
- Mid-wave episode, not a regression: while
ErrorHandlerstill lived inexceptions.py(before the handler moved to its own module), itscircuit_breakerconstructor parameter had to be temporarily widened fromCircuitBreaker | NonetoAny | None, because at that point in the sequenceexceptions.pywas not permitted to importcircuit_breaker.py— importing it would have violated the DAG one step early. OnceErrorHandlerrelocated toerror_handling.py, which is downstream ofcircuit_breaker.py, the parameter was restored to its real type with a genuine import. No type regression shipped in the final state; the episode is recorded here because it demonstrates the layering test actively constraining design mid-implementation rather than being a decorative check added after the fact. - Import sites inside the package that named
exceptions.pyfor something now living elsewhere (CircuitBreaker,ErrorHandler,classify_exception, the vocabulary types) need updating to the specific new module. This is confined to internal call sites — see Non-breaking below.
Non-breaking¶
application/migration/__init__.py's __all__ is byte-identical to its
pre-decomposition state across all four implementation commits. Every name
this ADR relocates is still exported from eventsource.application.migration
at the same top-level path; only a caller using a direct submodule import
(from eventsource.application.migration.exceptions import CircuitBreaker)
needs to retarget it to the new module.
Alternatives Considered¶
Keep the vocabulary and taxonomy in one module, split only the circuit breaker and handler out. Rejected: this halves the line count but leaves the taxonomy module mixing a value vocabulary with an exception hierarchy — two things with different reasons to change, the same complaint the DDD review made about the original file, just at smaller scale.
Use a TYPE_CHECKING-guarded import to let error_classification.py
reference MigrationError for type hints without a runtime cycle.
Rejected: the layering test does not exempt TYPE_CHECKING blocks, by
design — a cycle that only fails to execute because of a type-checking
guard is still a cycle in the dependency graph, and permitting it would
let a future change reintroduce a real runtime cycle behind the same guard
with nothing to catch it.
References¶
src/eventsource/application/migration/error_classification.py— the vocabulary layersrc/eventsource/application/migration/exceptions.py— theMigrationErrortaxonomysrc/eventsource/application/migration/circuit_breaker.py— the circuit breakersrc/eventsource/application/migration/error_handling.py—ErrorHandlerandclassify_exception()tests/unit/application/migration/test_module_layering.py— the AST-based layering contract enforcing the DAGtests/unit/application/migration/test_circuit_breaker.py,test_error_handling.py,test_exceptions.py,test_error_classification.py— realigned test modules; collected test count (819) unchanged throughout the wave- ADR 0034 — settled the
fourteen-module
application/migration/layout this ADR adds three modules to - ADR 0041 — the
one-taxonomy-per-area convention this ADR's
CircuitBreakerOpenErrorplacement follows
Related¶
CHANGELOG.md—[Unreleased]entry for this decomposition.claude/rules/architecture.md— the migration-package module list, updated for this ADR