Skip to content
ExponaBLOG
PYTHON BACKEND DEVELOPMENT · DAY 018 / 100

Organizing a FastAPI project: what developers should know

Structure that survives fifty endpoints.

Expona Admin8 min read

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:

python
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.py

Each feature's router.py defines its endpoints using APIRouter:

python
# 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:

python
# 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:

python
# 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 = True

Services orchestrate business logic. They're where order validation, price calculations, and payment integration happen:

python
# 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:

python
# 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_order

This 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:

python
# 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:

python
# 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
    pass

For 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.

python
# 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 APIRouter to define routes per feature and include them in main.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.py focused on app creation and router inclusion. The file should be so simple that new team members understand your entire architecture in seconds.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles