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 core package.
  • Let the application factory compose domain routers under a version prefix.
  • Keep request/response models in each domain's schemas module 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'