Every bounded context is the same six-layer hexagon, so a class's folder is its job and its name is its role. The payoff: maximally explicit dependencies, and contracts that live on the boundary instead of in your head.
A full HTTP backend and a standalone worker in one monorepo. Two Valkey streams cross the boundary and nothing else -- video_uploaded out, video_status back -- each one a name plus a payload shape that both sides declare for themselves.
contract = stream name + payload shape, redefined on each side · VideoUploadedIntegration ✕ VideoUploadedSchema · 0 shared imports
Requests climb the driving side into the core; effects fall down the driven side out of it. domain/ sits on top and imports nothing; frameworks touch only the two adapter columns. Hover a layer to find it in the tree -- you'll meet this exact map again in errors and logging.
★ the four real driven adapters media_example can't show -- it has no adapters/driven at all. Layer direction is an import-linter contract. enforced
contexts connect exactly two ways
Each one is implemented end-to-end, tested at four levels, and documented with the reasoning that picked it.
INSERT video ─┐ one tx INSERT outbox ─┘ └─▶ relay ─▶ stream
at-least-once delivery + event_id inbox ─▶ exactly-once effect
JWT ─▶ API-key ─▶ static token first match wins · fail-closed
WHERE (created_at,id) < (:ts,:id) ORDER BY created_at DESC, id DESC
{event_id, event_type,
version, occurred_at, ...payload}
The shipped video pipeline exercises the whole catalog · watch a request travel through both services and come back as an SSE event.
at-least-once everywhere, duplicates absorbed at each edge · full field-by-field contracts in docs/contract/ →
Every bounded context ships its own Dishka provider -- its own sub-graph. One build_container() call composes them into a single container, resolved lazily on the first request. Hover a provider to see what it puts into the graph.
hover a provider to see what it binds
Each context owns a config.py -- a Pydantic-Settings class with its own env_prefix and typed, self-validating fields. Values flow one way: env → Settings → provider → constructor; domain and app never read os.environ. Hover a field to light up the variable it reads.
# auth/config.py class AuthConfig(BaseAppConfig): model_config = SettingsConfigDict( env_prefix="AUTH_", ) admin_token: SecretStr | None = None jwt_secret: SecretStr | None = None jwt_issuer: str = Field("litestar-base") jwt_access_ttl_seconds: int = Field(900, ge=1) jwt_refresh_ttl_seconds: int = Field(1_209_600, ge=1) pool_size: int = Field(5, ge=1) # validated here
# the full contract · every var, annotated AUTH_ADMIN_TOKEN= AUTH_JWT_SECRET= AUTH_JWT_ISSUER=litestar-base AUTH_JWT_ACCESS_TTL_SECONDS=900 AUTH_JWT_REFRESH_TTL_SECONDS=1209600 AUTH_POOL_SIZE=5 # same shape per context: # MEDIA_* LOG_* METRICS_* POSTGRES_* VALKEY_*
One env_prefix per context: MEDIA_ · AUTH_ · LOG_ · METRICS_ · POSTGRES_ · VALKEY_ · DB_EXAMPLE_LITESTAR_ -- two contexts can both hold a POOL_SIZE without collision, and each class validates only its own slice of the environment.
Every layer raises its own subtype of LayerError; the driving edge is the single place that catches and maps. Pick a layer.
trace_id is created once, at the driving-adapter edge; below it nobody passes it along -- merge_contextvars merges it from a contextvar. Pick a layer to see the line it really writes.
infra/structlog.md → observability.md → · layer is an explicit bind via layer_logger, never auto-derived convention
The folder is the level -- a path-reading collection hook tags the marker, so -m unit selects a tier across every context with zero hand-kept markers.
371 total · backend 340 + worker 31 · tiers rank by isolation, not head-count (a template proves itself end-to-end, so e2e runs heavy)
standalone for the fast loop, together for the proof
run just your context for a tight loop; e2e boots the whole composition to prove they compose -- no mocks. path = level, enforced by the collection hook
One canonical gate -- ruff · mypy · lint-imports · pytest -- lives in the Docker tester image, and that is what CI runs.
| surface | ruff | fmt | mypy | import-linter | gitleaks | pytest |
|---|---|---|---|---|---|---|
| pre-commit | ✓ | ✓ | ✓ | ✓ | ✓ | · |
| Taskfile | ✓ | ✓ | ✓ | ✓ | · | ✓ |
| CI · static | ✓ | ✓ | ✓ | ✓ | · | · |
| Docker tester | ✓ | · | ✓ | ✓ | · | ✓ |
| ruff check . && mypy && lint-imports && pytest -q · identical in both services | ||||||
Contracts guard context independence, an un-importable root, a dependency-free shared kernel and inward layering. A cross-layer import fails the build. enforced ci.yml → contracts →
Every page has one job, one audience, and a line budget -- brief is a rule, not a hope. Every fact has one authoritative home; wire truth lives only in docs/contract/. Behaviour ships in the same change as the doc that describes it.
docs/ platform · cross-service ├ architecture.md ≤300 ├ contract/ single wire truth ├ adr/ ≤40 each ├ features/ ≤100 · zero wire facts └ infra/ ≤150 src/<service>/docs/ service-internal ├ contexts/ ≤250 · ownership · invariants ├ subsystems/ ≤200 · errors · jwt · metrics ├ infra/ ≤150 · dishka · structlog └ adr/ service ADR tree
| Doc kind | Budget | Home |
|---|---|---|
| README | 200 | service root |
| architecture | 300 | docs/ |
| context | 250 | svc/docs/contexts |
| subsystem | 200 | svc/docs/subsystems |
| infrastructure | 150 | svc/docs/infra |
| feature map | 100 | docs/features |
| ADR | 40 | nearest of 4 trees |
26 decisions across 4 independently-numbered MADR trees -- ≤40 lines, never renumbered, superseded-not-edited. convention
Design comes from the documentation, the plan comes from the code, and the docs close the loop. A written convention, not a linter.
architecture.md, the owning context page, the relevant contract/ pages, any constraining ADR. Decide how it plugs into the layers from documented knowledge -- the docs are complete enough that you do not need the code yet. A new decision means a new ADR (≤40 lines).unit → flow → integration → e2e.development.md → feature workflow convention
“A template, not a framework: fork it, rename it, delete what you don’t need.”