FastAPI Development in Practice: Clean Project Structure, Dependency Injection, and a Real “Service Layer”
FastAPI makes it easy to ship endpoints fast—but many projects turn into a single main.py file with tangled logic, inconsistent error handling, and copy-pasted database code. This guide shows a practical structure you can reuse on real teams: clear folders, dependency injection (DI) for database sessions and auth, and a simple “service layer” so your routes stay thin and testable.
We’ll build a tiny “Tasks” API:
- Create a task
- List tasks (with pagination)
- Mark a task as done
The examples use SQLAlchemy 2.0 async with asyncpg (PostgreSQL). You can adapt to SQLite for local dev, but async Postgres is a common production setup.
1) Dependencies and project layout
Install dependencies:
pip install fastapi uvicorn[standard] sqlalchemy[asyncio] asyncpg pydantic-settings python-dotenv pip install pytest pytest-asyncio httpx
Recommended layout:
app/ main.py core/ config.py db.py tasks/ models.py schemas.py repository.py service.py router.py tests/ test_tasks.py .env
This separation keeps responsibilities clear:
router.py: request/response + HTTP concernsservice.py: business logic + decisionsrepository.py: database accesscore/*: shared infrastructure (settings, DB session)
2) Configuration with pydantic-settings
Create app/core/config.py:
from pydantic_settings import BaseSettings, SettingsConfigDict class Settings(BaseSettings): model_config = SettingsConfigDict(env_file=".env", env_ignore_empty=True) DATABASE_URL: str = "postgresql+asyncpg://postgres:postgres@localhost:5432/postgres" settings = Settings()
Create a .env file:
DATABASE_URL=postgresql+asyncpg://postgres:postgres@localhost:5432/postgres
3) Async database session dependency
Create app/core/db.py:
from typing import AsyncIterator from sqlalchemy.ext.asyncio import ( AsyncSession, async_sessionmaker, create_async_engine, ) from sqlalchemy.orm import DeclarativeBase from app.core.config import settings engine = create_async_engine(settings.DATABASE_URL, echo=False, pool_pre_ping=True) SessionLocal = async_sessionmaker(engine, expire_on_commit=False, class_=AsyncSession) class Base(DeclarativeBase): pass async def get_db() -> AsyncIterator[AsyncSession]: async with SessionLocal() as session: yield session
Key idea: get_db() is a dependency. FastAPI injects it per-request and closes it safely.
4) Model and schemas
Create app/tasks/models.py:
from sqlalchemy import Boolean, String from sqlalchemy.orm import Mapped, mapped_column from app.core.db import Base class Task(Base): __tablename__ = "tasks" id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True) title: Mapped[str] = mapped_column(String(200), nullable=False) done: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
Create app/tasks/schemas.py:
from pydantic import BaseModel, Field class TaskCreate(BaseModel): title: str = Field(min_length=1, max_length=200) class TaskRead(BaseModel): id: int title: str done: bool model_config = {"from_attributes": True}
5) Repository: DB code lives here
Create app/tasks/repository.py:
from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from app.tasks.models import Task class TaskRepository: def __init__(self, db: AsyncSession): self.db = db async def create(self, title: str) -> Task: task = Task(title=title) self.db.add(task) await self.db.commit() await self.db.refresh(task) return task async def list(self, limit: int, offset: int) -> list[Task]: stmt = select(Task).order_by(Task.id.desc()).limit(limit).offset(offset) res = await self.db.execute(stmt) return list(res.scalars().all()) async def get(self, task_id: int) -> Task | None: return await self.db.get(Task, task_id) async def mark_done(self, task: Task) -> Task: task.done = True await self.db.commit() await self.db.refresh(task) return task
This is intentionally boring—good. Keeping DB calls centralized makes the rest of your code easier to change and test.
6) Service layer: business rules and errors
Create app/tasks/service.py:
from fastapi import HTTPException, status from app.tasks.repository import TaskRepository from app.tasks.models import Task class TaskService: def __init__(self, repo: TaskRepository): self.repo = repo async def create_task(self, title: str) -> Task: # Business rule example: trim and reject empty titles title = title.strip() if not title: raise HTTPException( status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="Title cannot be empty.", ) return await self.repo.create(title=title) async def list_tasks(self, limit: int, offset: int) -> list[Task]: limit = min(max(limit, 1), 100) # clamp 1..100 offset = max(offset, 0) return await self.repo.list(limit=limit, offset=offset) async def complete_task(self, task_id: int) -> Task: task = await self.repo.get(task_id) if not task: raise HTTPException(status_code=404, detail="Task not found") if task.done: return task return await self.repo.mark_done(task)
Notice what we gained:
- Routes stay thin (no database code in endpoints)
- Rules (clamping, validation, 404s) live in one place
- Service logic can be unit-tested with a fake repository
7) Router: small endpoints, clean dependencies
Create app/tasks/router.py:
from fastapi import APIRouter, Depends, Query from sqlalchemy.ext.asyncio import AsyncSession from app.core.db import get_db from app.tasks.repository import TaskRepository from app.tasks.service import TaskService from app.tasks.schemas import TaskCreate, TaskRead router = APIRouter(prefix="/tasks", tags=["tasks"]) def get_task_service(db: AsyncSession = Depends(get_db)) -> TaskService: repo = TaskRepository(db) return TaskService(repo) @router.post("", response_model=TaskRead, status_code=201) async def create_task( payload: TaskCreate, service: TaskService = Depends(get_task_service), ): task = await service.create_task(payload.title) return task @router.get("", response_model=list[TaskRead]) async def list_tasks( limit: int = Query(20, ge=1, le=100), offset: int = Query(0, ge=0), service: TaskService = Depends(get_task_service), ): return await service.list_tasks(limit=limit, offset=offset) @router.post("/{task_id}/complete", response_model=TaskRead) async def complete_task( task_id: int, service: TaskService = Depends(get_task_service), ): return await service.complete_task(task_id)
Two DI layers are happening:
get_dbcreates a per-requestAsyncSessionget_task_servicewires repo + service using that session
8) App entrypoint + table creation
Create app/main.py:
from fastapi import FastAPI from app.core.db import Base, engine from app.tasks.router import router as tasks_router app = FastAPI(title="Tasks API") @app.on_event("startup") async def on_startup() -> None: async with engine.begin() as conn: await conn.run_sync(Base.metadata.create_all) app.include_router(tasks_router)
Run it:
uvicorn app.main:app --reload
Try requests:
curl -X POST http://127.0.0.1:8000/tasks \ -H "Content-Type: application/json" \ -d '{"title":"Buy milk"}'
curl "http://127.0.0.1:8000/tasks?limit=10&offset=0
"
curl -X POST http://127.0.0.1:8000/tasks/1/complete
9) Testing the API without spinning up a server
For junior/mid devs, the biggest test win is: use httpx against the FastAPI app object. Here’s a simple test that mocks the service dependency so you can validate HTTP behavior without touching a real database.
Create tests/test_tasks.py:
import pytest from httpx import AsyncClient from fastapi import Depends from app.main import app from app.tasks.router import get_task_service from app.tasks.schemas import TaskRead class FakeService: def __init__(self): self.items = [TaskRead(id=1, title="Test", done=False)] async def list_tasks(self, limit: int, offset: int): return self.items[offset: offset + limit] @pytest.mark.asyncio async def test_list_tasks_returns_items(): fake = FakeService() app.dependency_overrides[get_task_service] = lambda: fake async with AsyncClient(app=app, base_url="http://test") as ac: res = await ac.get("/tasks?limit=20&offset=0") assert res.status_code == 200 data = res.json() assert data[0]["title"] == "Test" app.dependency_overrides.clear()
Run tests:
pytest -q
This pattern is powerful: override dependencies to test routers quickly, then separately test the service and repository with integration tests when you’re ready.
10) Common pitfalls and small upgrades
-
Don’t leak sessions into global state. Always pass
AsyncSessionvia dependencies. -
Keep “policy” in the service layer. Clamping limits, default sorting, and error mapping belong there.
-
Return schemas, not ORM models. We used
response_modelandfrom_attributesso FastAPI can serialize safely. -
Add migrations later.
create_allis fine for a demo; production should use Alembic migrations. -
Prefer one responsibility per module. When files get big, split further (e.g., separate
dependencies.py).
Wrap-up
The goal isn’t “enterprise architecture”—it’s keeping FastAPI apps maintainable as soon as they grow past a few endpoints. With a small structure (router/repo/service) and DI done right, you get cleaner code, easier tests, and fewer “where does this logic live?” moments.
If you want to extend this next, add:
- API key or JWT auth as a dependency (e.g.,
get_current_user) - Alembic migrations
- Structured logging + request IDs
Leave a Reply