How to Structure Large FastAPI Projects for Scale
Scaling a FastAPI application requires transitioning from monolithic entry points to a decoupled, domain-driven architecture. This guide details production-ready directory layouts, configuration isolation, and dependency strategies that prevent technical debt. Establishing a solid Core Architecture & Routing Patterns foundation early ensures your API remains maintainable as engineering teams expand and deployment targets diversify.
Key Architectural Principles
- Adopt domain-driven folder structures over functional grouping to align code with business capabilities.
- Implement lazy-loaded routers to optimize startup performance and reduce memory overhead.
- Centralize configuration using Pydantic
BaseSettingswith strict environment overrides. - Decouple business logic from HTTP transport layers to enable isolated unit testing and framework-agnostic services.
1. Domain-Driven Directory Layout
Group code by business domain (e.g., /users, /billing) rather than technical layers (/models, /views). This prevents import sprawl, simplifies ownership boundaries, and makes horizontal scaling of microservices or module extraction straightforward.
app/
├── __init__.py
├── main.py # Entry point (thin wrapper)
├── config/ # Environment & settings
│ ├── __init__.py
│ └── base.py
├── core/ # Cross-cutting concerns
│ ├── __init__.py
│ ├── database.py # Async engine/session factory
│ ├── middleware.py # CORS, rate limiting, tracing
│ ├── exceptions.py # Global error handlers
│ └── lifespan.py # Startup/shutdown hooks
├── users/ # Domain module
│ ├── __init__.py
│ ├── router.py # FastAPI router definitions
│ ├── schemas.py # Pydantic request/response models
│ ├── models.py # SQLAlchemy/SQLModel table definitions
│ ├── service.py # Business logic (framework-agnostic)
│ └── repository.py # Data access layer
└── billing/ # Additional domain...
└── ...
Production Constraints:
- Use
__init__.pystrategically to expose only public APIs (__all__ = ["router", "service"]). This prevents accidental internal imports across domains. - Keep
models.pystrictly database-focused. Never place validation or HTTP serialization logic here. - Isolate third-party SDK clients (e.g., Stripe, AWS) in a dedicated
integrations/directory to prevent vendor lock-in at the service layer.
2. Application Factory & Environment Configuration
Replace static app = FastAPI() instantiation with a dynamic factory. This enables environment-specific middleware injection, deferred dependency resolution, and predictable cold-start behavior in serverless environments.
# app/config/base.py
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
model_config = SettingsConfigDict(
env_file=".env",
env_file_encoding="utf-8",
case_sensitive=True
)
APP_NAME: str = "scale-api"
VERSION: str = "2.1.0"
ENVIRONMENT: str = "development"
DEBUG: bool = False
DATABASE_URL: str
REDIS_URL: str
def get_settings() -> Settings:
return Settings()
# app/main.py
from fastapi import FastAPI
from app.config.base import get_settings
from app.core.middleware import setup_middleware
from app.core.lifespan import lifespan
def create_app() -> FastAPI:
settings = get_settings()
app = FastAPI(
title=settings.APP_NAME,
version=settings.VERSION,
lifespan=lifespan,
docs_url="/docs" if settings.DEBUG else None,
redoc_url="/redoc" if settings.DEBUG else None
)
setup_middleware(app, settings)
return app
# Production entry point
app = create_app()
Why this matters: Lazy initialization prevents early config loading, which is critical when secrets are injected via Kubernetes Secrets, AWS Parameter Store, or HashiCorp Vault at runtime.
3. Router Registration & Dependency Isolation
Mount independent API routers while strictly avoiding circular imports and shared state leaks. Define routers in isolated modules with explicit prefixes and tags. Follow proven Modular Router Organization practices to keep route handlers stateless and testable.
# app/users/router.py
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession
from app.users.schemas import UserResponse, UserCreate
from app.users.service import UserService
from app.core.database import get_db_session
router = APIRouter(prefix="/users", tags=["users"])
@router.post("/", response_model=UserResponse, status_code=status.HTTP_201_CREATED)
async def create_user(
payload: UserCreate,
db: AsyncSession = Depends(get_db_session),
service: UserService = Depends()
) -> UserResponse:
return await service.create(db, payload)
@router.get("/{user_id}", response_model=UserResponse)
async def get_user(
user_id: int,
db: AsyncSession = Depends(get_db_session),
service: UserService = Depends()
) -> UserResponse:
user = await service.fetch(db, user_id)
if not user:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found")
return user
Dependency Strategy:
- Inject
AsyncSessionviaDepends(get_db_session)rather than importing a global session object. - Use
Depends(UserService)to instantiate domain services per-request. This enablespytestto override dependencies cleanly:
from fastapi.testclient import TestClient
from app.main import create_app
from app.users.service import UserService
def mock_service():
return FakeUserService()
client = TestClient(create_app())
client.app.dependency_overrides[UserService] = mock_service
4. Global Error Handling & Middleware Pipeline
Centralize exception mapping and cross-cutting concerns without polluting individual route handlers. FastAPI's ExceptionHandlers and middleware stack should intercept failures before they reach the client.
Structured Error Mapping:
# app/core/exceptions.py
from fastapi import Request, status
from fastapi.responses import JSONResponse
from pydantic import ValidationError
async def validation_exception_handler(request: Request, exc: ValidationError) -> JSONResponse:
return JSONResponse(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
content={"error": "VALIDATION_FAILED", "details": exc.errors()}
)
async def generic_exception_handler(request: Request, exc: Exception) -> JSONResponse:
# Log to structured logger here
return JSONResponse(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
content={"error": "INTERNAL_SERVER_ERROR", "trace_id": request.state.trace_id}
)
Middleware Pipeline & Lifespan:
- Attach request ID tracking, CORS, and rate limiting in
setup_middleware(). - Use
@asynccontextmanagerforlifespanto manage connection pools, background task schedulers, and cache warmups. Never use@app.on_event("startup")in modern FastAPI; it's deprecated in favor oflifespan.
Production Pitfalls & Anti-Patterns
| Issue | Root Cause | Production Fix |
|---|---|---|
| Circular imports between routers and dependency providers | Routers import services that import routers or DB modules. | Keep service layers strictly import-free from API modules. Resolve dependencies at the call site using Depends(). |
Hardcoding configuration in main.py | Breaks environment parity, complicates CI/CD, leaks secrets. | Externalize all values via validated Pydantic BaseSettings. Use .env only for local dev; rely on OS env vars in prod. |
Overusing app.state for request-scoped data | app.state is shared across the event loop, causing race conditions. | Scope data per-request using Depends(), request.state, or context variables (contextvars). |
| Synchronous DB calls in async routes | Blocks the event loop, degrades throughput under load. | Use SQLAlchemy 2.0+ with AsyncSession and asyncpg/aiosqlite. Never mix sync and async drivers. |
Frequently Asked Questions
Should I use a single main.py file for a production FastAPI app?
No. A single file becomes unmanageable past ~500 lines. Use an application factory pattern with domain-split routers to enable parallel development, isolated testing, and granular deployment scaling.
How do I manage database sessions across multiple modules?
Centralize the SQLAlchemy/SQLModel engine in a core/database.py module. Expose it via a FastAPI dependency (Depends(get_db_session)) and inject it into routers or services. Never pass raw connection strings or global session objects across boundaries.
Does a modular structure negatively impact FastAPI startup time?
Not if implemented correctly. Lazy router imports, deferred dependency resolution, and connection pool pre-warming keep initial overhead minimal. This actually improves cold-start predictability in serverless deployments by isolating heavy initialization to the lifespan context.