Architecture Best Practices
Standing rules for Python services that stay testable, deployable, and understandable as frameworks and teams change.
How to Use This List
- Treat unchecked items as review findings in PRs and design docs.
- Prefer enforceable rules (CI, lint, ADR) over hallway agreements.
- Revisit when you add a new entry point (API, worker, CLI) or data store.
A - Boundaries
- Keep domain logic free of FastAPI, Django, Flask, boto3, and pandas imports. Business rules should compile and test without IO frameworks.
- Map HTTP/ORM shapes to domain types at the edge. Pydantic 2 DTOs belong in adapters, not entity constructors used by workers.
- Define ports with
Protocolfor outward dependencies. Repositories, clocks, and notifiers get explicit interfaces. - Use one composition root per deployable.
main,lifespan, orbuild_container()wires concrete adapters - not scattered globals.
B - Packaging & Imports
- Adopt src layout for installable services. CI runs
uv syncorpip install -e .before tests. - Ban
PYTHONPATH=.hacks in documented dev workflows. If imports need it, packaging metadata is wrong. - Break import cycles by extracting leaf type modules. Lazy imports are a temporary bridge, not architecture.
- Expose a narrow public API via
__all__or documented package exports. Internal modules stay refactorable.
C - Configuration & Security
- Load settings through typed pydantic-settings once per process. No raw
os.getenvin business modules. - Store secrets in environment or secret manager only.
.env.examplelists names, never values. - Validate dangerous defaults at startup. Placeholder
SECRET_KEYvalues must fail fast in production settings. - Set HTTP client timeouts and retries in adapter modules. Unbounded network calls are operational debt.
D - Data & Domain Modeling
- Model money, quantities, and IDs as value objects. Avoid parallel primitives that drift apart.
- Guard entity transitions with methods (
place(),cancel()). Public attribute mutation bypasses invariants. - Keep pandas 2.2+ and Polars 1.x in ETL adapters. Domain functions accept plain Python collections.
- Isolate PyTorch 2.6+ load/inference behind ports. Importing torch at domain import time slows tests and workers.
E - Quality Gates
- Run ruff 0.9+ format and lint in CI. Style debates belong in config, not review threads.
- Ratchet static types on changed files. New modules meet stricter mypy/pyright rules than legacy.
- Unit-test use cases with fake adapters. Integration tests are a thin separate tier.
- Document irreversible decisions in ADRs. Layout, framework, and datastore choices need a paper trail.
F - Operations & Evolution
- Emit structured logs with correlation IDs at HTTP middleware.
printdebugging does not scale to production. - Separate liveness and readiness health checks. Orchestrators need distinct signals.
- Run database migrations as an automated deploy step. Manual SQL is a rollback nightmare.
- Prefer strangler migrations over big-bang rewrites. Ship vertical slices with characterization tests.
FAQs
How strict should boundary rules be for scripts?
Scripts under a few hundred lines may colocate logic and IO. Promote to packages with boundaries when a second entry point appears or the script survives a quarter.
Do all services need hexagonal folders?
No. You need clear dependency direction. Folder names matter less than "domain does not import FastAPI."
When is a global settings singleton acceptable?
When accessed only from composition roots and adapters. Use cases receive values or protocols, not from app.settings import settings.
Should every PR update this checklist?
Use it in design review and quarterly audits. Daily PRs reference specific rules ("domain import added - rejected") instead of rerunning the full list.
How do best practices interact with Django?
Django projects still benefit from ports for external IO and typed settings. ORM models may stay fat for admin-heavy CRUD; add mapping when rules multiply.
What is the minimum test fake set?
In-memory repositories and logging mailers/notifiers cover most use cases. Add contract tests when adapter SQL is complex.
How do I enforce import boundaries automatically?
Tools like import-linter or custom ruff rules can forbid domain importing adapters. Wire into CI after conventions stabilize.
Are microservices required for good architecture?
No. A well-bounded monolith with clear modules often outperforms distributed sprawl. Split on team or scale triggers, not fashion.
How often should ADRs be written?
For decisions costly to reverse: datastore, auth model, event bus, major framework. Skip ADRs for obvious tool picks like ruff.
Can Pydantic settings double as domain validation?
Settings validate environment. Domain validates business rules. Overlap is rare - keep layers separate.
What about notebooks exploring architecture?
Notebooks are throwaway or promotion candidates. Do not let experimental imports become de facto production boundaries without packaging.
How does uv fit architecture practice?
uv 0.6+ lockfiles make CI and Docker reproduce the same dependency graph - a foundation for evolvable design.
Related
- Project Architecture Decision Checklist - initial decisions
- Clean / Hexagonal Architecture - boundary pattern detail
- Codebase Audit Checklist - verify practices hold
- Refactoring Decisions - fix violations
- Configuration & Settings - config best practices deep dive
Stack versions: This page was written for Python 3.14.0 (stable 3.14, maintenance 3.13), FastAPI 0.115+, Django 5.2, Flask 3.1, Pydantic 2, PyTorch 2.6+, pandas 2.2+, Polars 1.x, ruff 0.9+, and uv 0.6+.