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 any application service.
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/>use-case services, 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.” ADR-011 started from one Orchestrator god class holding every operation and anticipated extracting individual use cases once one grew enough to warrant it; ADR-017 took that escape valve and split the class into one service per use case, sharing behaviour by composition rather than a base class. SensitivityService (detect-sensitive), CodingService (assign-codes), AnalyzeService (analyze) and SummarizeService (summarize) are the four extracted services; #267 deleted the emptied-out Orchestrator class once nothing referenced it any more.
Further reading#
System context — what surrounds the app
Components — ports, adapters, the application services
Cross-cutting concerns — concerns that span layers
Data model — domain models and persistence