The Architecture Mental Model for Python Services
Architecture, once you set aside framework choice and folder-naming debates, is just the set of decisions in a Python codebase that are expensive to reverse - which module can import which other module, where a Protocol boundary sits, whether the domain logic knows an ORM model exists.
This section's other pages each cover one piece of that in depth - src Layout & Package Structure, Clean / Hexagonal Architecture, Domain Modeling, Dependency Injection & Wiring, Configuration & Settings.
This page is the layer underneath all of them: what actually makes a Python codebase easy or hard to change, expressed in terms Python itself makes literal - the import graph - rather than in abstract diagrams borrowed from another language's architecture culture.
Summary
- In Python, the import graph is the architecture - every
importis a dependency arrow, and which way those arrows point determines what can change independently of what. - Insight: Python's flexible import system makes weak boundaries invisible until they surface as circular imports, god-modules, or "why does this one-line change touch twelve files."
- Key Concepts: coupling, cohesion, dependency direction, dependency inversion, composition root, import graph.
- When to Use: Deciding where a package boundary belongs, whether a
Protocolport is worth the indirection, how to wire concrete adapters to abstract dependencies at startup, or whether domain code should ever import a framework or ORM type. - Limitations/Trade-offs: Every boundary that reduces coupling - a
Protocol, an extra package, a composition root - adds a file you have to read to understand what actually happens, and Python's dynamic typing means these boundaries are only as strong as your tooling and discipline enforce them. - Related Topics: src layout, hexagonal architecture, dependency injection, domain modeling, circular imports.
Foundations
Two properties determine almost everything about whether a Python codebase stays easy to change: coupling - how much one module depends on another module's internals rather than its stated interface - and cohesion - how much the things inside one module actually belong to the same responsibility.
The goal is the same one architecture chases in any language: high cohesion within a module, low coupling across modules - but Python makes the coupling side unusually visible, because every import statement is a literal, inspectable dependency arrow, not an implied relationship you have to infer from a diagram.
A useful analogy: think of packages as apartments in a building, each with its own utilities and exactly one front door - the module's public API, whatever it exposes through __init__.py and __all__. A well-bounded package lets you renovate its interior without a neighbor noticing, because nothing outside reaches through the walls; a poorly bounded one has residents constantly reaching into each other's apartments for a spare fuse, so nobody can change anything without checking three other units first.
# domain/orders.py - reaching for a spare fuse through the wall
from adapters.sqlite_orders import SqliteOrderRepository # domain now knows sqlite exists
def place_order(order_id: str, total_cents: int) -> None:
repo = SqliteOrderRepository() # can't test without a real database file
repo.save(order_id, total_cents)Mechanics & Interactions
Dependency direction is where this becomes a concrete, checkable rule rather than a preference: if domain/orders.py imports adapters/sqlite_orders.py, the arrow points from domain to infrastructure, and swapping SQLite for Postgres now means touching business logic, not just the adapter.
This is exactly the mechanical cause behind one of Python's most common structural bugs. A circular import - ImportError: cannot import name X from partially initialized module Y - is not really an interpreter quirk to route around; it's the import graph telling you, at runtime, that two modules each think they need something the other one owns, which is coupling made visible as a crash instead of staying hidden as slow-motion friction. Circular Import Scenarios covers the mechanics of fixing the symptom - this page is about why the symptom exists at all: two modules on the wrong side of a dependency arrow.
Dependency inversion is the technique that fixes the direction rather than just the symptom: instead of the domain importing a concrete adapter, the domain defines a typing.Protocol describing what it needs, and the adapter implements that shape.
from typing import Protocol
class OrderRepository(Protocol):
def save(self, order_id: str, total_cents: int) -> None: ...
def place_order(order_id: str, total_cents: int, repo: OrderRepository) -> None:
repo.save(order_id, total_cents) # domain never imports sqlite, fastapi, or anything concretePython's structural typing makes this cheaper than in languages that require an explicit class hierarchy: any object with a matching save method satisfies OrderRepository, so a test can hand place_order an in-memory fake with zero inheritance and zero mocking framework.
Someone still has to connect the concrete adapter to the abstract port, and that happens at exactly one place: the composition root - a main(), a FastAPI lifespan block, a Django app-ready hook - where concrete objects get built and handed to the functions and classes that only know about Protocols. Everywhere else in the codebase should be receiving dependencies, not constructing them.
Advanced Considerations & Applications
src Layout & Package Structure is where architecture stops being a code-review convention and becomes something CI actually enforces: a flat layout lets the repository root satisfy imports during local development even when the packaging metadata is broken, while src/ forces an install (pip install -e . or uv sync) before anything can import the package at all - so a missing dependency or a wrong pyproject.toml entry fails in CI, not three weeks later in a Docker image.
Configuration deserves the same dependency-inversion treatment as any other infrastructure concern: code that calls os.environ.get(...) scattered across domain modules is exactly as coupled to "the environment" as code that imports SQLite directly is coupled to a database - Configuration & Settings covers building one typed settings object at the composition root and injecting it, rather than letting every module reach for its own environment variable.
Not every Python codebase should pay for full hexagonal separation, and that's a real trade-off, not a compromise. Django's "fat model" style - business logic living directly on ORM models - is a legitimate, productive choice for admin-heavy CRUD applications where the database schema and the domain model are genuinely the same shape; hexagonal separation earns its indirection specifically when domain rules are complex enough, or expected to outlive the current framework or database long enough, that testing them without infrastructure becomes valuable in its own right.
The progression from a flat script, to a layered-by-convention app, to enforced module boundaries, to full hexagonal ports-and-adapters, maps directly onto cost of change: each step reduces the cost of changing one part in isolation and increases the fixed cost of the boundary mechanism itself, so a team paying for Protocol ports on a 200-line internal script is paying pure overhead, and a team without them on a domain-heavy service with three delivery mechanisms is paying pure overhead the other way.
| Approach | Strength | Weakness | Best Fit |
|---|---|---|---|
| Flat script / single module | Fastest to write, zero indirection | Coupling grows invisibly as the file grows | Prototypes, one-off scripts, notebooks |
Layered by convention (domain/, api/, adapters/ folders, no enforcement) | Clear intent, still simple tooling | Nothing stops a domain module from importing an adapter directly | Small teams, early-stage services |
src layout + enforced import boundaries (ruff/import-linter rules) | Bad packaging and cross-layer imports fail in CI, not on a laptop | Requires install step and linter config to actually enforce | Any installable service or library shipped via CI/Docker |
Hexagonal (Protocol ports + adapters) | Domain testable without a database, framework-agnostic core | An extra interface to read even for the simple cases | Complex domain rules, multiple delivery mechanisms (API + CLI + worker) |
Common Misconceptions
- "src layout is just an extra folder for no reason." It's an enforcement mechanism - it forces an editable install before anything can import the package, which is what surfaces broken packaging metadata in CI instead of only in a Docker build weeks later.
- "Protocol-based ports are Java-style over-engineering for a dynamic language." Python's structural typing makes a
Protocolcheaper than an interface in most statically-typed languages - no inheritance, no hierarchy, just a shape a fake or a real adapter can satisfy for tests. - "Circular imports are a Python quirk you fix with a local import inside the function." A local import can silence the crash, but the underlying cause - two modules each depending on the other's responsibility - is a coupling problem the local import hides rather than resolves.
- "Django's ORM-centric fat models are always bad architecture." For CRUD-heavy admin applications where the schema and the domain model are the same shape, fat models are a reasonable, productive choice - hexagonal separation is a trade-off that pays off specifically when domain complexity or infrastructure churn justifies the extra indirection.
- "You need a dependency injection framework to do DI in Python." Constructor injection plus one composition root (a
main(), a FastAPI lifespan block) covers most Python services; a DI container adds real value mainly once wiring graphs get large enough that manual construction becomes its own maintenance burden.
FAQs
What does "architecture" concretely mean for a Python codebase?
The set of import-direction and boundary decisions that are expensive to reverse - which module can import which, where a Protocol port sits, whether domain logic knows a framework or ORM exists. Everything else is ordinary code that's cheap to change.
Why is the import graph described as "the architecture" instead of just a detail of it?
Because in Python, every import statement is a real, inspectable dependency arrow - there's no separate architecture diagram needed to see coupling, since python -c "import ast; ..." or any import-graph tool can show you the actual dependency structure directly from the code.
Is a circular import always a sign of bad architecture?
Almost always, yes - it means two modules each think they need something the other one owns, which is coupling made visible as a runtime error. A local, deferred import can silence the crash, but the underlying direction problem is still there until one module stops depending on the other's internals.
Why use typing.Protocol instead of an abstract base class for ports?
Protocol gives structural typing - any object with matching methods satisfies it, with no inheritance required - which is cheaper for tests (a plain fake object works) and doesn't force a class hierarchy on adapters that may already inherit from something else, like an ORM base class.
What is a composition root, concretely?
The one place in a service - a main() function, a FastAPI lifespan block, a Django app-ready hook - where concrete objects (a database connection, a real repository) get built and handed to code that only knows about abstract Protocol types. Everywhere else should receive dependencies, not construct them.
Does src layout actually change how the code runs, or just where files live?
Both, in effect - the physical location changes (src/mypackage/ instead of a top-level package), but the real change is behavioral: it forces pip install -e . or uv sync before imports resolve, which is what catches missing packaging metadata that a flat layout would silently hide during local development.
When is a Django "fat model" a reasonable architecture, not a shortcut?
When the application is genuinely CRUD-centric and the database schema already matches the domain model closely - admin tools, internal dashboards - putting business rules directly on ORM models avoids indirection that wouldn't be paying for anything in that context.
How does configuration fit into dependency inversion?
The same way a database or an HTTP client does - code that calls os.environ.get(...) throughout domain modules is coupled to "the environment" the same way code that imports SQLite directly is coupled to a database. Building one typed settings object at the composition root and injecting it keeps that dependency explicit and swappable.
Is it always worth the extra indirection to add a Protocol port?
No - for a small script or a throwaway tool, a direct concrete dependency is simpler and the "extra" interface is pure overhead. It earns its cost specifically when the domain logic behind it is complex enough, or expected to outlive the current infrastructure choice long enough, that testing it in isolation matters.
How do I know if my package boundaries are enforced or just conventional?
Ask whether anything actually stops a violation - a linter rule blocking deep cross-package imports, a src layout requiring an install, an import-graph check in CI - versus nothing but a shared understanding of "we don't do that here," which erodes under deadline pressure.
Do I need a DI framework/container in Python?
Usually not for typical service sizes - manual constructor injection plus one composition root handles most wiring graphs. A container starts paying for itself once the number of dependencies and their lifetimes (singleton vs per-request) becomes large enough that manual wiring is itself hard to read.
Why does dependency direction matter more as a team grows?
Because a module's enforced boundary is usually also an ownership boundary - when a package's public API is narrow and enforced, one team can change its internals without coordinating with every other team touching the codebase, while weak boundaries force coordination even when the underlying responsibilities don't actually overlap.
Related
- src Layout & Package Structure - the physical enforcement mechanism behind package boundaries
- Clean / Hexagonal Architecture -
Protocolports and adapters in full depth - Domain Modeling - what belongs inside the boundary this page describes
- Dependency Injection & Wiring - the composition root pattern in practice
- Configuration & Settings - inverting the environment as a dependency
- Circular Import Scenarios - the runtime symptom of a bad dependency direction
Stack versions: This page is conceptual and not tied to a specific stack version.