Building for scale from day one
By day eighteen of backend development, you've written a handful of FastAPI endpoints and they work. But as your application grows to fifty endpoints—or five hundred—that single main.py file becomes a maintenance nightmare. Organizing a FastAPI project: what developers should know starts with recognizing that structure isn't optional; it's what lets your team ship features without constant refactoring. We'll walk through the patterns that have survived in production codebases across e-commerce, CRM, and SaaS platforms.
Why structure matters before you need it
Most developers reach for structure only when pain forces them to. By then, imports are tangled, tests are brittle, and onboarding new team members involves thirty minutes of archaeology. The good news: the structure that works at fifty endpoints is nearly identical to the structure that works at five hundred. Building it early costs a few extra minutes now and saves weeks of refactoring later.
A well-organized FastAPI project separates concerns vertically by feature, not horizontally by technical layer. Instead of one routes/ folder, one models/ folder, and confusion about where business logic lives, you create self-contained feature modules. Each module owns its own routes, schemas, and data access logic. This pattern scales because adding a new feature means adding a new module, not scattering files across the codebase.
APIRouter per feature module
The foundation of a scalable FastAPI project is the APIRouter. Rather than defining all routes in main.py or cramming them into one massive file, each feature gets its own router that you import and include in the main application.
Consider an e-commerce platform with orders, payments, and inventory features. Instead of one routes.py, you'd structure it like this:
project/
├── app/
│ ├── __init__.py
│ ├── main.py
│ ├── features/
│ │ ├── orders/
│ │ │ ├── __init__.py
│ │ │ ├── router.py
│ │ │ ├── schemas.py
│ │ │ ├── service.py
│ │ │ └── repository.py
│ │ ├── payments/
│ │ │ ├── __init__.py
│ │ │ ├── router.py
│ │ │ ├── schemas.py
│ │ │ ├── service.py
│ │ │ └── repository.py
│ │ └── inventory/
│ │ ├── __init__.py
│ │ ├── router.py
│ │ ├── schemas.py
│ │ ├── service.py
│ │ └── repository.py
│ └── core/
│ ├── __init__.py
│ ├── settings.py
│ ├── database.py
│ └── security.pyEach feature's router.py defines its endpoints using APIRouter:
# app/features/orders/router.py
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session
from . import schemas, service
from app.core.database import get_db
router = APIRouter(prefix="/orders", tags=["orders"])
@router.get("/{order_id}", response_model=schemas.OrderResponse)
async def get_order(order_id: int, db: Session = Depends(get_db)):
order = service.get_order(db, order_id)
if not order:
raise HTTPException(status_code=404, detail="Order not found")
return order
@router.post("/", response_model=schemas.OrderResponse)
async def create_order(order_data: schemas.OrderCreate, db: Session = Depends(get_db)):
return service.create_order(db, order_data)Then in main.py, you include all routers in one place:
# app/main.py
from fastapi import FastAPI
from app.features.orders.router import router as orders_router
from app.features.payments.router import router as payments_router
from app.features.inventory.router import router as inventory_router
app = FastAPI(title="E-commerce API")
app.include_router(orders_router)
app.include_router(payments_router)
app.include_router(inventory_router)This approach scales because each team member can work on their own feature module without merge conflicts on the main router file. New features are isolated; old features don't break when you add a new one.
Schemas, services, and repositories per feature
Within each feature module, separation of concerns matters just as much as module isolation. Schemas handle validation and serialization, services contain business logic, and repositories handle database queries. This layering makes testing straightforward and keeps logic testable without a database.
Schemas use Pydantic to validate incoming data and shape outgoing responses:
# app/features/orders/schemas.py
from pydantic import BaseModel, Field
from typing import Optional
from datetime import datetime
class OrderCreate(BaseModel):
customer_id: int
total_amount: float = Field(gt=0)
items: list[dict]
class OrderResponse(BaseModel):
id: int
customer_id: int
total_amount: float
status: str
created_at: datetime
class Config:
from_attributes = TrueServices orchestrate business logic. They're where order validation, price calculations, and payment integration happen:
# app/features/orders/service.py
from sqlalchemy.orm import Session
from . import schemas, repository
from app.features.payments.service import charge_payment
def create_order(db: Session, order_data: schemas.OrderCreate):
# Validate inventory availability
for item in order_data.items:
if not inventory_available(item):
raise ValueError(f"Item {item['id']} not available")
# Create order record
order = repository.create_order(db, order_data)
# Attempt payment
charge_payment(order.id, order_data.total_amount)
return order
def get_order(db: Session, order_id: int):
return repository.get_order_by_id(db, order_id)Repositories handle all database queries. This isolation lets you swap SQLAlchemy for another ORM without touching service logic:
# app/features/orders/repository.py
from sqlalchemy.orm import Session
from sqlalchemy import select
from app.models import Order # Your SQLAlchemy model
from . import schemas
def get_order_by_id(db: Session, order_id: int):
return db.query(Order).filter(Order.id == order_id).first()
def create_order(db: Session, order_data: schemas.OrderCreate):
db_order = Order(
customer_id=order_data.customer_id,
total_amount=order_data.total_amount,
)
db.add(db_order)
db.commit()
db.refresh(db_order)
return db_orderThis three-layer structure makes testing fast. You can test service logic by mocking the repository, and you can test repositories in isolation with a test database. When a bug appears in production, you know exactly which layer to investigate.
Settings with pydantic-settings
Configuration belongs in environment variables, not hardcoded in your application. Pydantic Settings provides a clean, type-safe way to manage this across different environments (local, staging, production).
Create a centralized settings file:
# app/core/settings.py
from pydantic_settings import BaseSettings
from pydantic import Field
class Settings(BaseSettings):
app_name: str = "E-commerce API"
debug: bool = False
# Database
database_url: str = Field(default="sqlite:///./test.db")
db_pool_size: int = 10
# Security
secret_key: str = Field(default="dev-key-change-in-production")
algorithm: str = "HS256"
access_token_expire_minutes: int = 30
# External services
payment_gateway_url: str
payment_gateway_key: str
class Config:
env_file = ".env"
case_sensitive = True
settings = Settings()Then load it once and inject it where needed:
# app/features/payments/service.py
from app.core.settings import settings
def charge_payment(order_id: int, amount: float):
# Use settings.payment_gateway_url and settings.payment_gateway_key
passFor local development, create a .env file (never commit to version control). For production, set environment variables directly in your deployment platform. This single pattern works across all environments without code changes.
Keep main.py to app creation only
Your main.py should be boring. It creates the FastAPI app, includes routers, and maybe adds middleware. Nothing more.
# app/main.py
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from app.core.settings import settings
from app.features.orders.router import router as orders_router
from app.features.payments.router import router as payments_router
from app.features.inventory.router import router as inventory_router
def create_app() -> FastAPI:
app = FastAPI(
title=settings.app_name,
debug=settings.debug,
)
# Middleware
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# Routers
app.include_router(orders_router)
app.include_router(payments_router)
app.include_router(inventory_router)
@app.get("/health")
async def health():
return {"status": "ok"}
return app
app = create_app()This makes testing easier (you can call create_app() with custom settings) and keeps the entry point clean. When someone opens your project, they should understand the entire structure in thirty seconds by reading main.py.
Common mistakes
Mixing layers. Putting database queries directly in route handlers feels faster but breaks testing and makes refactoring painful. Resist the shortcut.
Shared services across features. If orders and payments both need "calculate tax," the temptation is to create a shared/ folder. Usually, this couples features unnecessarily. Keep features independent; call across feature boundaries through clean interfaces only when truly necessary.
Configuration scattered across the codebase. Hardcoded values in services, magic strings in routes, environment checks inside functions. These snowball. Use settings centrally and everywhere.
Feature folders that are too large. If your orders module has ten Python files, consider splitting it. A good heuristic: if a new developer can't understand a feature module in ten minutes, it's too complex.
Key takeaways
- Structure FastAPI projects by feature module, not by technical layer. Each module owns its routes, schemas, services, and repositories.
- Use
APIRouterto define routes per feature and include them inmain.py. This keeps the entry point clean and scales to hundreds of endpoints. - Separate schemas (validation), services (business logic), and repositories (data access). This structure makes testing fast and changes isolated.
- Centralize configuration with Pydantic Settings and environment variables. Never hardcode values that change between environments.
- Keep
main.pyfocused on app creation and router inclusion. The file should be so simple that new team members understand your entire architecture in seconds.






Comments
Loading comments…