How to Structure Large FastAPI Projects for Scale
Key takeaways:
- Group by domain, not by layer: one package per feature holding its router, schemas, and service.
- Keep cross-cutting code in a
corepackage that every domain may import and that imports no domain. - Let the application factory be the only module that knows every domain exists.
- Let domains collaborate through a
Protocolbound ontoapp.state, never through a direct import. - Enforce the import direction with a test that parses the source — a convention nobody can run is not a rule.
This is the project-layout guide under Modular Router Organization. It assumes routers are composed by an application factory rather than assembled at module scope.
The Problem This Solves
Every FastAPI project starts as one main.py, and the first refactor is nearly always the wrong one. The file gets long, someone splits it into routers/, schemas/, and services/, and the codebase looks organised for about two months. Then the costs arrive. Adding a field to an order touches four directories. Nobody can say which team owns schemas/. And because every layer package imports every other layer package, the import graph quietly becomes a mesh in which any two modules can reach each other — which is how the circular import that stops the app booting eventually appears.
The layout below fixes the cohesion problem, but the layout alone is not the solution. Directory structure is a suggestion; without something that fails the build, the mesh reappears within a quarter.
Why It Happens
A layer-first tree is not arbitrary — it mirrors how the framework's own documentation introduces concepts, one at a time. The trouble is that it optimises for finding all the routers, which nobody needs to do, at the cost of changing one feature, which everybody does constantly.
There is also a mechanical reason the mesh forms. Python import cycles are only prevented by the shape of the dependency graph, and a layer-first tree gives every layer a legitimate-looking reason to import every other: the router needs the service, the service needs the schema, the schema module grows a validator that needs the service. Nothing in the layout says which direction is allowed, so all directions are tried, and the first cycle appears the day two features need each other.
A domain-package tree makes the legal directions expressible in one sentence: domains may import core; the factory may import domains; nothing else. That sentence is short enough to enforce automatically, which is the property that actually matters.
The Fix
1. One package per domain, all of it together
shop/
├── main.py # the factory: the only module that imports every domain
├── core/ # cross-cutting; imports no domain
│ ├── config.py # typed Settings
│ └── registry.py # Protocols domains use to reach one another
├── users/
│ ├── router.py # APIRouter(prefix="/users", tags=["users"])
│ ├── schemas.py # UserOut and friends
│ └── service.py # business logic, no HTTP concerns
├── orders/
│ └── ... # same shape
└── billing/
└── ... # same shape
Each domain router declares only its own prefix. The version prefix is applied by the factory, so the same router can move between versions without the domain package changing:
def create_app() -> FastAPI:
settings = Settings()
app = FastAPI(title=settings.service_name)
app.state.user_directory = UserService() # binds the Protocol to an implementation
for router in (users_router, orders_router, billing_router):
app.include_router(router, prefix='/v1')
return app
Building that tree for real and reading the composed schema back out of the application object confirms the routers land where the factory put them:
$ GET /composed
200 OK
{
"title": "shop",
"routes_in_the_composed_schema": [
"/v1/billing/invoices",
"/v1/orders/{order_id}",
"/v1/users/{user_id}"
],
"cross_domain_call_without_a_cross_domain_import": {
"request": "GET /v1/orders/3",
"status": 200,
"body": {
"id": 3,
"placed_by": "user-7"
}
}
}
2. Let domains collaborate without importing each other
The interesting line in that transcript is the last one. GET /v1/orders/3 returned "placed_by": "user-7" — a value that belongs to the users domain — and the orders package contains no import of users. The capability is declared in core as a Protocol:
"""Where domains reach each other, instead of importing each other."""
from typing import Protocol
class UserDirectory(Protocol):
async def display_name(self, user_id: int) -> str: ...
The consumer reads it off app.state, typed as the protocol:
@router.get('/{order_id}', response_model=OrderOut)
async def read_order(order_id: int, request: Request) -> OrderOut:
# orders needs a user's name but never imports the users package: it asks the
# UserDirectory the factory bound onto app.state.
directory: UserDirectory = request.app.state.user_directory
return OrderOut(id=order_id, placed_by=await directory.display_name(7))
Only main.py imports both sides. That is the whole trick, and it buys three things beyond a tidy graph: orders can be tested with a five-line fake directory, users can be replaced by a remote call without orders noticing, and neither package can drag the other's transitive dependencies into its own test suite.
3. Make the rule executable
A directory layout that is only documented in a README is a layout that decays. The rule is mechanical, so write it as a test. Parse each module, look at its ImportFrom nodes, and flag any that crosses from one domain package into another:
def check_import_direction() -> list[dict[str, str]]:
"""Parse every module and flag any import that crosses from one domain into another."""
violations: list[dict[str, str]] = []
for path in sorted(PKG.rglob("*.py")):
parts = path.relative_to(PKG).parts
owner = parts[0] if parts[0] in DOMAINS else None
if owner is None:
continue # core and the factory are allowed to import anything
tree = ast.parse(path.read_text())
for node in ast.walk(tree):
module = node.module if isinstance(node, ast.ImportFrom) else None
if module is None:
continue
target = module.split(".")
if len(target) > 1 and target[1] in DOMAINS and target[1] != owner:
violations.append(
{
"module": str(path.relative_to(PKG.parent)),
"imports": module,
"rule": f"domain {owner!r} must not import domain {target[1]!r}",
}
)
return violations
The example project has a deliberate violation planted in billing/service.py. Running the check against the real tree finds it:
$ GET /architecture-test
200 OK
{
"rule": "a domain package may import shop.core, never another domain",
"violations": [
{
"module": "shop/billing/service.py",
"imports": "shop.users.service",
"rule": "domain 'billing' must not import domain 'users'"
}
],
"verdict": "FAIL"
}
That output is worth more than any amount of documentation, because it names the file, the import, and the rule in a form a reviewer can act on in seconds. Parsing with ast rather than grepping matters: it will not be fooled by the string shop.users in a docstring, and it ignores imports inside if TYPE_CHECKING blocks only if you choose to, which is a policy decision you can now make explicitly.
Verification
Wire the check into the test suite so it runs on every commit:
def test_no_cross_domain_imports():
violations = check_import_direction()
assert violations == [], f"cross-domain imports found: {violations}"
def test_the_app_still_imports_cleanly():
# A cycle introduced anywhere in the tree fails here first, with a readable traceback.
from shop.main import create_app
assert create_app().title
def test_every_router_is_versioned():
app = create_app()
assert all(p.startswith("/v1/") for p in app.openapi()["paths"])
The third test is the cheap guard against the most common composition slip: a router that declared its own version prefix internally and now serves at /v1/v1/orders.
For projects already large enough that a hand-written check is not enough, import-linter expresses the same rule declaratively in a config file, including layered contracts where core sits below domains which sit below the factory. The value is identical; the twenty lines above simply have no dependency and can be pasted into a project today.
Trade-offs and When Not To
Domain packages are the wrong choice for a genuinely small service. Under about three features, a layer-first tree or even a single module is easier to read, and the ceremony of a core package plus protocols buys nothing. Restructure when the pain is real: when a one-line feature change touches four directories, or when two features have begun importing each other.
The layout also has a failure mode of its own. core is where cross-cutting code goes, and if nobody polices it, core becomes the mesh you were escaping — a package that imports domains, holds business logic, and gets edited by every team. Keep the constraint blunt: core may not import any domain, and that is checkable with the same parser.
Be honest, too, about what protocol-plus-app.state costs. You give up jump-to-definition on the call site, and app.state is untyped, so a typo in the attribute name is an AttributeError at request time rather than an import error at boot. Use it where domains genuinely must not couple, not as the default way to call a function two directories away — and see best practices for dependency injection for how to wrap that lookup in a typed dependency so the untyped access happens exactly once.
FAQ
Should I organize by layer (routers/, services/, models/) or by domain?
By domain once the project passes roughly three features. A layer-first tree spreads one feature across routers/users.py, services/users.py and schemas/users.py, so every change is a four-file diff and ownership is impossible to express. A domain-first tree keeps users/ together and makes the blast radius of a change visible.
How do I stop one domain from importing another?
Write it as a test rather than a convention. Parse each module with the ast module, find the ImportFrom nodes, and fail if a module under one domain package imports another domain package. A verified run of that check reports the offending file, the import, and the rule it broke.
How should two domains collaborate if they cannot import each other?
Define the capability one needs as a Protocol in the shared core package, have the consumer read it from app.state through a dependency, and let the application factory bind the concrete implementation. Only the factory ends up knowing both sides.
Where should the version prefix live?
In the factory, on the include_router call, not baked into each domain router. Domain routers should declare only their own prefix and tags, so the same router can be mounted under a different version without editing the domain package.
Does a domain package need its own schemas module?
Yes, once more than one route in that domain shares a model. Keeping request and response models beside the router that uses them stops a shared models.py from becoming a file every team edits, and makes it obvious which models are part of a domain's public contract.
Related Reading
- Up to the topic: Modular Router Organization, for how routers compose once the packages exist.
- for the boot failure this layout is designed to prevent: Fixing FastAPI Dependency Injection Circular Imports.
- for where the factory itself should live and what belongs in it: Application Factory Patterns.
- for applying the version prefix the factory owns: Versioning APIs with FastAPI Routers.
- for the typed configuration that lives in
core: Managing Environment Variables with Pydantic Settings.