7. Hexagonal architecture (ports & adapters)¶
- Status: Accepted
- Date: 2026-09-17
- Supersedes: ADR-0001
Context¶
The layered contracts of ADR-0001 kept packages from importing "upwards", but
they could not express the properties that actually matter for this code base:
the domain must not know FastAPI or asyncpg; the analysis use case must not
construct database readers, an LLM client and a file store itself; and the
wiring must exist in exactly one place. In practice composition was duplicated
across main.py, cli.py and integration/pipeline.py (with a bug in two of
them), the catalog file store mixed persistence with the review workflow, an
HTTP handler built the whole enrichment pipeline inline, and module-level
singletons (AppState, _settings, _auth_manager) made the app hard to test.
Decision¶
Four layers with a strict inward dependency direction, enforced by
import-linter ([tool.importlinter] in pyproject.toml):
| Layer | Package | Rules |
|---|---|---|
| Entry points | warp.main, warp.cli |
may import anything; own the process (uvicorn, console script) |
| Composition root | warp.infrastructure |
builds outbound adapters and the Container; loads config; sets up logging |
| Adapters | warp.adapters.inbound.{http,cli}, warp.adapters.outbound.{db,llm,catalog_store,export} |
may import application and domain only; inbound and outbound are independent of each other, as are http and cli |
| Application | warp.application |
use-case services, ports (typing.Protocol), configuration models, the Container type; imports domain only; no frameworks/drivers |
| Domain | warp.domain |
entities and pure rules (stdlib + pydantic); imports nothing else |
Key mechanics:
- Ports (
warp.application.ports):DatabaseGateway/SqlReader/DatabaseGatewayFactory,CommentSource/SampleSource,CatalogRepository,TextGenerator,CatalogExporter. Adapters satisfy them structurally; tests substitute fakes without patching. - Composition root:
infrastructure.bootstrap.build_container(settings, *, repository=, gateway_factory=, text_generator_factory=, analysis_factory=)returns anapplication.container.Container. The HTTP app takes a container factory (create_app(container_factory, env)), the CLI group receives one throughcontext_settings["obj"]; neither adapter importsinfrastructure. - Runtime state lives on
app.state.runtime(RuntimeContext) and inRuntimeEnv, not in module globals. - Review workflow transitions are pure functions in
domain.catalog_review;CatalogReviewServiceapplies them over the repository;CatalogFileStoreonly persists. - No compatibility shims: 0.9.0 changes import paths outright.
Consequences¶
- Import paths changed (breaking); the test tree mirrors the layers.
- Any adapter can be swapped in tests or by embedders (in-memory repository,
fake gateway, recorded LLM) by passing it to
build_container. - The full startup sequence is exercised in tests over a fake gateway.
- Adding a database or LLM provider means adding an outbound adapter that satisfies the port; no application code changes.
Addendum (1.0.0)¶
These packages now live under truejoin.* (ADR-0010): warp.main → truejoin.main,
warp.cli → truejoin.adapters.inbound.cli.main, warp.infrastructure and its
bootstrap.build_container → truejoin.bootstrap.build_container, warp.adapters.* →
truejoin.adapters.*, warp.application.ports → the flat truejoin.ports, and
domain.catalog_review → truejoin.domain.catalog.catalog_review. RuntimeEnv is in
truejoin.application.settings. The import-linter contracts mentioned above were replaced by
tests/fusion/architecture/test_dependency_rules.py.