Architecture style#
The service is built as a hexagonal application (ports & adapters), with four packages enforced by import-linter contracts in pyproject.toml.
Why hexagonal here#
Four things tipped the decision:
Multiple distinct external worlds. The core talks to an LLM provider (via LiteLLM), a PII detection engine (Presidio), and a usage-tracking database (Postgres). Each is independent and changes at a different cadence — exactly the shape hexagonal was designed for.
Parallel work along port boundaries. Once a port is named, dev A can build the use case against a typed fake while dev B implements the real adapter — the port type is the contract that lets the two streams converge cleanly at the end. The same shape unblocks investigation work: try a second LLM provider behind the existing port without touching the orchestrator.
Type-checked fakes beat monkey-patches. Tests inject
FakeLLMPort(and analogous fakes forAnonymizationPort,UsageRepositoryPort) throughcreate_app(llm_factory=…). Because every fake inherits explicitly from its port, the type-checker catches drift the moment the contract shifts — there’s no staleunittest.mock.patchchain silently passing a test against a long-changed interface.Well-known and battle-tested. Hexagonal is recognisable enough that experienced developers and AI coding agents both navigate the layout without the architecture being explained first — a real onboarding-cost saving when both kinds of contributor are in the loop.
See ADR-001 through ADR-011 for individual decisions; ADR-002 and ADR-011 are the load-bearing ones.
Layer diagram#
block-beta
columns 2
api["api/<br/>FastAPI routes, middleware,<br/>schemas, composition root"]
adapters["adapters/<br/>LiteLLM client, Presidio,<br/>SQLAlchemy repo,<br/>tracking decorator"]
services["services/<br/>Orchestrator, CallContext"]:2
domain["domain/<br/>models, ports, errors"]:2
Each row may import from any row below it, never from a row above. api/ and adapters/ are siblings on the top row: neither imports from the other; both are wired together by the composition root in api/app.py.
Allowed import directions (enforced by import-linter):
qfa.domain— imports nothing from the project, and none ofopenai,litellm,presidio_*,fastapi,starlette,tenacity.qfa.services— importsqfa.domainonly; same third-party prohibitions minustenacity.qfa.adapters— sibling ofapi; may import fromservicesanddomain. Each adapter class explicitly inherits from its port (see the project guidelines).qfa.api— sibling ofadapters; the composition root inapp.pyis the only place that wires concrete adapters into ports.
What’s not hexagonal here#
Hexagonal tells us “services depend only on ports” — it doesn’t say “one orchestrator class with N methods” versus “N orchestrator classes.” The current Orchestrator is one class with four operations (analyze, summarize, summarize_aggregate, assign_codes), per ADR-011. Extracting individual use cases into their own services is anticipated when any one grows enough to warrant it.
Further reading#
System context — what surrounds the app
Components — ports, adapters, the orchestrator
Cross-cutting concerns — concerns that span layers
Data model — domain models and persistence