s1t-python-backend-templates.
production-ready python backend templates

Navigate by location.

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.

auth/ports/driven/sql_user_repo.py context / layer / side / file  ·  a real, openable file: the Postgres repo, exactly where its name says it is
topology

Two independent services, linked by streams.

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.

litestar_backend
full HTTP backend
Litestar · Postgres
own uv project · own CI
video_uploaded ──▶
◀── video_status
event_microservice
FastStream + SAQ worker
Valkey · no Postgres
own uv project · own CI

contract = stream name + payload shape, redefined on each side · VideoUploadedIntegrationVideoUploadedSchema · 0 shared imports

anatomy of a context

One hexagon, repeated. Dependencies point inward.

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.

domain/
pure stdlib · aggregates, VOs, events · imports nothing
app/
use cases + Protocol interfaces · zero I/O
ports/driving/
facade & DTOs · the public API
ports/driven/
repos & clients that implement app Protocols
adapters/driving/
controllers, consumers
adapters/driven/
engines, clients, codecs · domain-blind
auth/ · JWT + API keys + admin token├─ domain/ imports nothing│ ├─ user.py UserRecord · EmailTakenError│ └─ tokens.py TokenPair · VerifiedToken├─ app/│ ├─ interfaces/ IUserRepo · IDenylist (Protocols)│ ├─ use_cases/ register · login · authenticate│ └─ errors.py JwtDisabledError · UserNotFound├─ ports/│ ├─ driving/ auth_facade · guards · dtos│ └─ driven/│ ├─ sql_user_repo.py Postgres (SQLAlchemy 2.0)│ ├─ valkey_denylist.py Redis · JWT revocation│ ├─ argon2_hasher.py argon2id (pwdlib)│ └─ composite_token_resolver.py jwt·key·static├─ adapters/│ ├─ driving/api/ 3 controllers│ └─ driven/jwt_codec.py joserfc · domain-blind├─ provider.py the only concrete→interface bindings└─ config.py env_prefix="AUTH_"

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

1 · technology  adapter edge, cross-serviceshipping
A adapters/driven relay ──xadd──▶ "video_uploaded" ──▶ B adapters/driving consumer contract = name + payload · zero shared code
2 · ACL  port → port, in-process, cross-contextconvention · none in tree
A ports/driven/acl/ ──imports──▶ B ports/driving facade the only sanctioned cross-context import (worked example removed with the metrics context, ADR 0023)

architecture.md →  auth/ source →

pattern catalog

The patterns are the product.

Each one is implemented end-to-end, tested at four levels, and documented with the reasoning that picked it.

Transactional outbox
INSERT video ─┐ one tx
INSERT outbox ─┘   └─▶ relay ─▶ stream
The row and its event commit atomically; a relay drains committed rows to a Valkey Stream. A failed transaction emits nothing. Generic in shared/, used by two contexts.
Inbox dedup
at-least-once delivery
+ event_id inbox ─▶ exactly-once effect
The consumer records every processed event_id in Valkey; duplicates become no-ops. Delivery guarantees are a documented matrix, not folklore.
Composite auth chain
JWT ─▶ API-key ─▶ static token
first match wins · fail-closed
Registered users (argon2id), service API keys and a break-glass admin token resolve through one middleware chain. Every request gets a Principal · anonymous just means UNKNOWN.
Keyset pagination
WHERE (created_at,id) < (:ts,:id)
ORDER BY created_at DESC, id DESC
Opaque cursors over a composite index · stable pages under concurrent writes, no OFFSET drift. One generic Page[T] envelope for every list endpoint.
Integration-event envelope
{event_id, event_type,
 version, occurred_at, ...payload}
Every event on the wire carries identity, a schema version and a producer timestamp by construction. Consumers define their own inbound schema · producer types never cross the boundary.
end-to-end

One request, every pattern.

The shipped video pipeline exercises the whole catalog · watch a request travel through both services and come back as an SSE event.

POST /videos
202 accepted
─▶
outbox tx
transactional outbox
─▶
relay → stream
event envelope
─▶
3 SAQ jobs
inbox dedup
─▶
join in Valkey
completion set
─▶
video_status
return contract
─▶
state machine
PENDING→DONE
─▶
SSE feed
to the browser

at-least-once everywhere, duplicates absorbed at each edge · full field-by-field contracts in docs/contract/ →

runtime what the app does while a request is in flight.
composition & lifecycle

One DI graph. Swap anything in one line.

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.

SharedProvider
AuthProvider
MediaInfraProvider
MediaWebProvider
AdminProvider
AdminLogWebProvider
DbExampleLitestarProvider
build_container() ─────▶
AsyncContainer
APP scope · lazy
resolves on the 1st request
7 backend · 2 worker

hover a provider to see what it binds

start
build the graph · warm the auth facade · each manager starts its background tasks
serve
APP-scope singletons resolved once · a child scope per request
stop
cancel() then await every task · container.close() · engine.dispose() · redis.aclose()
uvicorn drains ≤25s < 30s k8s grace · in-flight work finishes before SIGKILL

infra/dishka.md →  container.py →

configuration

Config is one file per context.

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.

auth/config.py →  .env.full.example →

error model

One base error. Each layer derives its own.

Every layer raises its own subtype of LayerError; the driving edge is the single place that catches and maps. Pick a layer.

domain/
DomainError · 409
app/
AppError 422 · NotFound 404
ports/driving/
propagates · pydantic 400
ports/driven/
PortError · 503
adapters/driving/
catches + maps, once
adapters/driven/
raw exc → PortError
adapters/driving

contract/errors.md →  error_hierarchy.md →

observability on the layers

Bind the trace once. Each layer signs its own line.

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.

domain/
never logs
app/
video_id · layer=app
ports/driving/
silent
ports/driven/
layer=ports_driven
adapters/driving/
binds trace_id
adapters/driven/
no trace_id
adapters/driving

infra/structlog.md →  observability.md →  ·  layer is an explicit bind via layer_logger, never auto-derived convention

discipline how a change earns its way into main.
test pyramid

Four levels. Every context alone, then all together.

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.

unit114 · domain only · no DB · mocks forbidden
flow76 · app over fakes · no DB
integration87 · one port+adapter · real Postgres 18 + Valkey 8
e2e94 · full app · AsyncTestClient · no mocks

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

tests/<ctx>/
unit
flow
integration
e2e
auth/
media_example/
db_example_litestar/
·
·

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

tests/ →  development.md →

quality gates

Architecture is a test, not a diagram.

One canonical gate -- ruff · mypy · lint-imports · pytest -- lives in the Docker tester image, and that is what CI runs.

surfacerufffmtmypyimport-lintergitleakspytest
pre-commit·
Taskfile·
CI · static··
Docker tester··
ruff check . && mypy && lint-imports && pytest -q · identical in both services
10 import-linter contracts 7 backend 3 worker ruff ==0.15.22 mypy ==2.3.0

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 →

documentation

Brief enough to read. Complete enough to trust.

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         ≤300contract/               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 kindBudgetHome
README200service root
architecture300docs/
context250svc/docs/contexts
subsystem200svc/docs/subsystems
infrastructure150svc/docs/infra
feature map100docs/features
ADR40nearest of 4 trees

26 decisions across 4 independently-numbered MADR trees -- ≤40 lines, never renumbered, superseded-not-edited. convention

ADR index →  adopting.md →

feature workflow

Every change walks the same path -- docs at both ends.

Design comes from the documentation, the plan comes from the code, and the docs close the loop. A written convention, not a linter.

1
Frame the feature
Goal and boundary: which context owns it, what crosses the wire, what is out of scope.
2
Design from the docs
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).
3
Plan against the code
Now open the owning context end-to-end plus the nearest analog, and turn the design into a concrete plan: which files, which layers, which tests.
4
Implement, ideally TDD
Domain first, then app, ports, adapters -- a failing test before each slice: unit → flow → integration → e2e.
5
Update the documentation
Context page, contract on any wire change, ADR status, env templates for new vars. A stale doc is a bug.

development.md → feature workflow  convention

quick start

Reading it is running it.

$ git clone https://github.com/Sense1Tapo4ek/s1t-python-backend-templates
$ cp .env.example .env
$ openssl rand -hex 32 # paste into AUTH_ADMIN_TOKEN=
$ docker compose up --build
# API :8000 · admin :8000/admin · SAQ panel :8081
# full test gate: docker compose run --rm litestar_backend_test

“A template, not a framework: fork it, rename it, delete what you don’t need.”