How to Structure Large FastAPI Projects for Scale
Key takeaways:
- Organize by domain, not by layer — keep each feature's router, schemas, service, and dependencies together.
- Put cross-cutting code (config, database, shared dependencies) in a
corepackage. - Let the application factory compose domain routers under a version prefix.
- Keep request/response models in each domain's
schemasmodule to keep OpenAPI lean. - Enforce a one-way import direction so the module graph never cycles.
This is the project-layout companion to Modular Router Organization. It assumes you have adopted an application factory to assemble the pieces.
The Problem This Solves
A FastAPI project that starts as one main.py grows into an unnavigable file, then into a layer-first tree where every feature is smeared across routers/, services/, and models/. Both make change expensive: you cannot see a feature in one place, and import cycles creep in. A domain-driven layout keeps each feature cohesive and the import graph acyclic.
Prerequisites
- A factory-based app (
create_app). - Typed configuration and a database session exposed as a dependency.
Step-by-Step Implementation
1. Lay out packages by domain
app/
├── main.py # create_app(): composes routers, registers handlers
├── core/ # cross-cutting, imported by every domain
│ ├── config.py # typed Settings
│ ├── database.py # engine + session factory + get_db_session
│ └── errors.py # DomainError + handler registration
├── users/
│ ├── router.py # APIRouter(prefix="/users", tags=["users"])
│ ├── schemas.py # UserCreate, UserResponse
│ ├── service.py # business logic, no HTTP concerns
│ └── dependencies.py # current_user, etc.
├── orders/
│ └── ... # same shape as users/
└── billing/
└── ...
2. Keep handlers thin and schemas local
# app/users/router.py
from typing import Annotated
from fastapi import APIRouter, Depends
from app.users.schemas import UserResponse
from app.users.service import UserService, get_user_service
router = APIRouter(prefix="/users", tags=["users"])
@router.get("/{user_id}", response_model=UserResponse)
async def get_user(
user_id: int,
service: Annotated[UserService, Depends(get_user_service)],
) -> UserResponse:
# The handler delegates; business logic lives in the service.
return await service.get(user_id)
3. Compose in the factory
# app/main.py
from fastapi import FastAPI
from app.core.errors import register_error_handlers
from app.users.router import router as users_router
from app.orders.router import router as orders_router
def create_app() -> FastAPI:
app = FastAPI()
register_error_handlers(app)
for r in (users_router, orders_router):
app.include_router(r, prefix="/v1") # Versioned composition in one place.
return app
4. Enforce import direction
Domains import from core; the factory imports domains; domains never import each other. When orders needs users data, depend on a UserService protocol bound in the factory rather than importing app.users.service directly.
Edge Cases and Gotchas
- Shared schemas. A model used by two domains belongs in
core/schemas, not in one domain that the other imports. - God-service drift. A service that grows to touch every domain is a sign a new domain or a shared module is needed.
- Test layout. Mirror the domain structure under
tests/so a feature's tests sit beside nothing but that feature.
Verification
A quick import-cycle check keeps the architecture honest in CI:
# Fails if any domain imports another domain directly.
python -c "import app.main; print('import graph OK')"
grep -rE 'from app\.(users|orders|billing)' app/users app/orders app/billing \
&& echo 'cross-domain import found' || echo 'no cross-domain imports'
Related Reading
- Up to the topic: Modular Router Organization.
- Related patterns: Application Factory Patterns and Dependency Injection Strategies for wiring domains without coupling them.