ADR-0009: Declarative import-layering contracts¶
Status¶
Accepted. Implemented in EPIC #2046 PR 4 (issue #2050), building on the module-size policy (ADR-0006), the feature-manifest substrate (ADR-0007), and the composition-root finalisation (ADR-0008).
Context¶
Three custom AST gates already enforce specific import invariants:
scripts/check_persistence_boundary.py-- onlypersistence/may importsqlite/psycopgor emit raw SQL.scripts/check_no_api_dto_in_persistence_or_service.py-- API DTO types must not leak into the persistence or service layers.scripts/check_dependency_inversion.py-- callers depend on protocols, not concrete classes, across the marked boundaries.
Each gate answers one narrow question. None expresses the layering of the
package graph as a whole: which subsystems are foundational and may not
import upward, which app-boundary edges are forbidden. That rule lived only
in CLAUDE.md prose and reviewer judgement, so a new upward import (for
example core reaching into api, or persistence importing a worker)
would pass every mechanical gate.
A naive fix -- a strict total-order layers contract -- does not fit the
real graph. The codebase routes deliberate cross-subsystem references
through shared hubs: the per-domain .state slices import
api.state_slices; config.schema transitively reaches most subsystems;
the construction-wiring seam and api.auth.system_user are imported by
persistence on purpose. A total order cannot express those back-edges
without a sprawling ignore_imports list that would itself drift.
Decision¶
Add import-linter (pinned import-linter==2.11) with a committed,
reality-grounded .importlinter at the repo root. Every contract passes
against the current import graph; the file exists to prevent regressions,
not to describe an aspirational architecture.
Contracts (all type = forbidden, direct imports only)¶
- core-is-foundation --
synthorg.coremust not importapi,persistence,engine,workers, ormeta. - persistence-app-boundary --
synthorg.persistencemust not directly importapiorworkers. - observability-below-api --
synthorg.observabilitymust not directly importapi.
All three set allow_indirect_imports = true (they check DIRECT edges; the
transitive graph legitimately converges again through config.schema and the
.state hubs) and bless the handful of intentional direct back-edges in
ignore_imports: the persistence._construction wiring seam, the per-domain
.state slices (-> api.state_slices), api.auth.system_user (imported by
the user repositories), and the three observability -> api feature/startup
edges. A strict total-order layers contract is deliberately NOT used.
The three custom AST gates are retained, not replaced¶
import-linter reasons about module-to-module edges. It cannot see raw SQL
strings, DTO type leakage, or concrete-vs-protocol dependency direction.
Those remain the job of the three custom gates. .importlinter and the
custom gates are complementary; docs/reference/import-layering.md records
the split.
Enforcement¶
uv run lint-imports --config .importlinter runs as a pre-push hook
(pass_filenames: false, stages: [pre-push], listed in ci: skip:) and as
a step in the lint job of CI.
Consequences¶
- A new upward or cross-boundary direct import fails mechanically at push, closing the gap that previously relied on review.
- The deliberate back-edges are documented as code in
ignore_imports; a new back-edge must be added explicitly and justified, rather than appearing silently. - Because the contracts are regression-preventing (not aspirational), the
file stays green day-to-day and does not become a source of busywork. When
a future refactor removes a back-edge, its
ignore_importsline is deleted in the same change (no legacy aliases). import-lintercomplements rather than duplicates the custom AST gates; removing any one of them would reopen a distinct hole.