FastAPI in the Real World: Clean Project Structure, Dependency Injection, and Testable Endpoints

FastAPI in the Real World: Clean Project Structure, Dependency Injection, and Testable Endpoints

FastAPI makes it easy to ship APIs quickly, but the “single main.py with everything inside” approach gets messy as soon as you add a database, auth, configuration, or multiple routes. In this hands-on guide, you’ll build a small but production-shaped FastAPI service using:

  • A clean folder structure (routers, services, models, schemas)

  • Dependency injection (DI) for database sessions and auth

  • Typed settings via environment variables

  • Tests that don’t hit real external services

The goal: your endpoints stay thin, your business logic is reusable, and your code is easy to test.

1) Project layout that won’t collapse at 10 files

Create a new project and install dependencies:

pip install fastapi uvicorn sqlalchemy pydantic-settings pytest httpx

Use a structure like this:

app/ __init__.py main.py settings.py db.py models.py schemas.py deps.py services/ __init__.py projects.py routers/ __init__.py projects.py tests/ test_projects.py

This is intentionally small, but it scales well: routers handle HTTP, services handle business logic, and dependencies wire things together.

2) Typed configuration with environment variables

Put settings in app/settings.py. Use pydantic-settings so your env vars become typed fields.

from pydantic_settings import BaseSettings, SettingsConfigDict class Settings(BaseSettings): model_config = SettingsConfigDict(env_file=".env", extra="ignore") app_name: str = "Projects API" database_url: str = "sqlite:///./dev.db" api_key: str = "dev-key" # demo auth; use proper auth in real apps settings = Settings()

You can override values with environment variables like DATABASE_URL or by adding a .env file.

3) Database wiring with SQLAlchemy (session per request)

Create app/db.py. The key idea: create a session per request and close it reliably.

from sqlalchemy import create_engine from sqlalchemy.orm import sessionmaker, Session from .settings import settings engine = create_engine( settings.database_url, connect_args={"check_same_thread": False} if settings.database_url.startswith("sqlite") else {}, ) SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) def get_db() -> Session: db = SessionLocal() try: yield db finally: db.close()

That yield pattern is important: FastAPI treats it as a dependency with teardown logic.

4) Define a simple model and schemas

Create a minimal “Project” entity. In app/models.py:

from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column from sqlalchemy import String, Integer class Base(DeclarativeBase): pass class Project(Base): __tablename__ = "projects" id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True) name: Mapped[str] = mapped_column(String(120), nullable=False) description: Mapped[str] = mapped_column(String(500), nullable=False, default="")

Now Pydantic schemas in app/schemas.py:

from pydantic import BaseModel, Field class ProjectCreate(BaseModel): name: str = Field(min_length=2, max_length=120) description: str = Field(default="", max_length=500) class ProjectOut(BaseModel): id: int name: str description: str class Config: from_attributes = True

from_attributes = True lets Pydantic read SQLAlchemy objects cleanly.

5) Dependencies: database + “good enough” API key auth

Dependencies are functions that FastAPI can “inject” into your endpoints. Put them in app/deps.py:

from fastapi import Depends, Header, HTTPException from sqlalchemy.orm import Session from .db import get_db from .settings import settings def db_session(db: Session = Depends(get_db)) -> Session: return db def require_api_key(x_api_key: str | None = Header(default=None)) -> None: if x_api_key != settings.api_key: raise HTTPException(status_code=401, detail="Invalid or missing API key")

This is intentionally simple auth (header-based). The important part is the pattern: endpoints can declare what they need, and FastAPI supplies it.

6) Business logic in a service layer

Move “create/list” logic into app/services/projects.py. This keeps your routers thin and your logic reusable (e.g., later from a CLI or background job).

from sqlalchemy.orm import Session from sqlalchemy import select from ..models import Project from ..schemas import ProjectCreate def create_project(db: Session, data: ProjectCreate) -> Project: project = Project(name=data.name, description=data.description) db.add(project) db.commit() db.refresh(project) return project def list_projects(db: Session, limit: int = 50, offset: int = 0) -> list[Project]: stmt = select(Project).limit(limit).offset(offset).order_by(Project.id.desc()) return list(db.scalars(stmt).all())

7) Router: HTTP in, typed objects out

Create app/routers/projects.py:

from fastapi import APIRouter, Depends, Query from sqlalchemy.orm import Session from ..schemas import ProjectCreate, ProjectOut from ..deps import db_session, require_api_key from ..services import projects as service router = APIRouter(prefix="/projects", tags=["projects"]) @router.post( "", response_model=ProjectOut, dependencies=[Depends(require_api_key)], status_code=201, ) def create_project(payload: ProjectCreate, db: Session = Depends(db_session)): return service.create_project(db, payload) @router.get( "", response_model=list[ProjectOut], dependencies=[Depends(require_api_key)], ) def get_projects( db: Session = Depends(db_session), limit: int = Query(default=50, ge=1, le=200), offset: int = Query(default=0, ge=0), ): return service.list_projects(db, limit=limit, offset=offset)

Notice what’s not here: raw SQLAlchemy details beyond passing db, and no manual JSON building—FastAPI + Pydantic handles it.

8) App entrypoint and table creation

In app/main.py, wire everything together and create tables on startup (fine for demos; for production use migrations like Alembic).

from fastapi import FastAPI from .settings import settings from .models import Base from .db import engine from .routers.projects import router as projects_router def create_app() -> FastAPI: app = FastAPI(title=settings.app_name) @app.on_event("startup") def on_startup(): Base.metadata.create_all(bind=engine) app.include_router(projects_router) return app app = create_app()

Run it:

uvicorn app.main:app --reload

Try it with curl (replace the key if you set a different one):

curl -X POST "http://127.0.0.1:8000/projects" \ -H "Content-Type: application/json" \ -H "X-API-Key: dev-key" \ -d '{"name":"Website Redesign","description":"Landing page + CMS updates"}'
curl "http://127.0.0.1:8000/projects?limit=10&offset=0" \ -H "X-API-Key: dev-key"

9) Testing: override dependencies so tests are fast and isolated

The superpower of dependency injection is testability. You can replace the real DB session and auth dependency with test versions.

Create tests/test_projects.py:

import pytest from fastapi.testclient import TestClient from sqlalchemy import create_engine from sqlalchemy.orm import sessionmaker from app.main import create_app from app.models import Base from app.deps import db_session, require_api_key @pytest.fixture() def client(): # In-memory SQLite for tests engine = create_engine("sqlite+pysqlite:///:memory:", connect_args={"check_same_thread": False}) TestingSessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) Base.metadata.create_all(bind=engine) app = create_app() def override_db(): db = TestingSessionLocal() try: yield db finally: db.close() def override_auth(): return None # bypass API key in tests app.dependency_overrides[db_session] = override_db app.dependency_overrides[require_api_key] = override_auth return TestClient(app) def test_create_and_list_projects(client: TestClient): r = client.post("/projects", json={"name": "API Cleanup", "description": "Refactor routes"}) assert r.status_code == 201 data = r.json() assert data["id"] > 0 assert data["name"] == "API Cleanup" r2 = client.get("/projects") assert r2.status_code == 200 items = r2.json() assert len(items) == 1 assert items[0]["name"] == "API Cleanup"

Run tests:

pytest -q

10) Practical habits to keep your FastAPI codebase healthy

  • Keep routers thin: routers should validate input and call services, not contain business rules.

  • Use dependencies for cross-cutting concerns: auth, rate limiting, DB sessions, feature flags—DI keeps these consistent.

  • Prefer explicit typing: typed schemas + query constraints (ge, le) prevent many production bugs.

  • Override dependencies in tests: it’s the cleanest way to avoid hitting real databases or external services.

With this pattern, you can add new modules (users, tasks, billing) without turning your API into a tangled knot. Your future self—and your teammates—will thank you.


Leave a Reply

Your email address will not be published. Required fields are marked *