Towards a Scalable Design: Modularization, Dependency Injection, and Configuration Management with FastAPI’s APIRouter and Depends
✅ First, the Summary (Inverted Pyramid)
- What this article enables you to do
Using FastAPI’s core—APIRouter (modularization) and Depends (dependency injection)—we’ll build an API structure that’s easy to maintain and highly extensible. We’ll treat DB sessions, authentication, and configuration values as “dependencies,” and cover router-level shared dependencies, endpoint-level dependencies, and even dependency overrides in tests, making the patterns practical for real-world use. - Main topics
- Splitting features with APIRouter and best practices for
include_router
- Dependency injection with
Depends
andAnnotated
(DB, auth, settings) - Router-level dependencies, endpoint dependencies, yield dependencies (with cleanup)
- Configuration management based on environment variables with Pydantic (v2) +
pydantic-settings
- Differentiating exception handlers vs middleware, versioning design, dependency overrides in tests
- Splitting features with APIRouter and best practices for
- Benefits
- A loosely coupled design resistant to new features and spec changes
- Reusable shared logic as dependencies, with clean, DRY code
- Dependencies can be swapped in tests for safe and fast validation
🎯 Who Benefits? (Concrete Personas)
- Learner A (University senior, solo dev)
As routes grow,main.py
becomes bloated… You want to split by feature and pass DB/auth cleanly. - Freelance Dev B (Team of 3)
Copy-pasting shared logic like auth, rate limiting, audit logs across APIs makes maintenance tough. You want to centralize this with router-level dependencies. - SaaS Dev C (Startup)
Planning ahead for API v2 and feature expansion, you want to adopt versioning and configuration management, and leverage dependency overrides in tests.
1. Why APIRouter and Depends?
APIRouter modularizes groups of routes by feature (e.g., users
, todos
, auth
).
Depends lets endpoints declaratively receive shared logic (dependencies).
Together, they provide:
- Loose coupling: Just receive DB sessions, authenticated users, settings as arguments
- Reusability: Apply shared dependencies at router level, eliminating copy-paste
- Testability: Swap dependencies with
app.dependency_overrides
- Clarity: Split concerns into folders for easier maintenance
Key takeaway
- APIRouter = modularization, Depends = passing shared logic
- Greatly improves loose coupling, reusability, and testability
2. Minimal Project Structure (Start with Form)
We’ll base everything on the following structure, adding files as needed:
fastapi-arch/
├─ app/
│ ├─ main.py
│ ├─ core/
│ │ ├─ settings.py # Settings (pydantic-settings)
│ │ ├─ security.py # Auth-related dependencies
│ │ └─ exceptions.py # Common exceptions/handlers
│ ├─ db/
│ │ ├─ session.py # DB engine, SessionLocal, dependency
│ │ └─ models.py # SQLAlchemy models
│ ├─ routers/
│ │ ├─ users.py # APIRouter (user features)
│ │ └─ todos.py # APIRouter (ToDo features)
│ └─ schemas/
│ ├─ users.py # Pydantic schemas (v2 assumed)
│ └─ todos.py
└─ tests/
└─ ... (tests with dependency overrides)
Terminology
- Dependency: Anything required for endpoints—functions, classes, settings, DB connections, etc.
- Yield dependency: Uses
yield
to include cleanup logic as well.
Key takeaway
core
= cross-cutting features,db
= database,routers
= feature routers,schemas
= I/O definitions- Separation by role alone makes reviews and updates dramatically easier
3. Configuration Management: Centralizing with pydantic-settings
In Pydantic v2, pydantic-settings
is the standard for managing configurations.
# app/core/settings.py
from pydantic import Field
from pydantic_settings import BaseSettings
class Settings(BaseSettings):
app_name: str = "My FastAPI"
env: str = Field("dev", description="Environment: dev/stg/prod")
database_url: str = "sqlite:///./app.db"
secret_key: str = Field(..., description="Secret key for JWT, etc.")
class Config:
env_file = ".env" # Optionally load .env
extra = "ignore" # Ignore unknown env vars
# Factory for app-wide shared settings
def get_settings() -> Settings:
return Settings()
- Point: By injecting
get_settings
, you can easily override with different configs in tests. - Use required (
...
) values andField
descriptions for self-documentation.
Key takeaway
- Manage settings in one place, inject as dependencies
- Easily switch between test, staging, production
4. DB Session Dependency: Cleanup with yield
Keep it separate from the app, ensuring closure via a yield dependency.
# app/db/session.py
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker, Session
from typing import Generator
from app.core.settings import get_settings
def get_engine():
settings = get_settings()
connect_args = {"check_same_thread": False} if settings.database_url.startswith("sqlite") else {}
return create_engine(settings.database_url, connect_args=connect_args)
Engine = get_engine()
SessionLocal = sessionmaker(bind=Engine, autoflush=False, autocommit=False)
def get_db() -> Generator[Session, None, None]:
db = SessionLocal()
try:
yield db
finally:
db.close()
- Avoid duplication: Session creation/teardown is centralized here.
- For testing: Replace
get_db
inapp.dependency_overrides
to point to a test DB.
Key takeaway
- DB sessions closed safely with yield dependencies
- Clear override point for tests
5. Authentication Dependency: Authenticated User as Argument
Authentication in FastAPI is received as a dependency argument. Here’s a simplified example (production would use JWT, etc.):
# app/core/security.py
from fastapi import Depends, HTTPException, status
from typing import Annotated
from app.core.settings import get_settings
# Dummy token validation
def get_current_user(token: str | None = None, settings = Depends(get_settings)):
if token != "valid":
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Unauthorized")
return {"username": "alice", "role": "user"}
# Role-based check as a higher-order dependency
def require_role(role: str):
def checker(user = Depends(get_current_user)):
if user.get("role") != role:
raise HTTPException(status_code=403, detail="Forbidden")
return user
return checker
User = Annotated[dict, Depends(get_current_user)]
AdminUser = Annotated[dict, Depends(require_role("admin"))]
- Layered dependencies:
require_role
internally usesget_current_user
. - Type hints:
Annotated
improves readability (User
/AdminUser
aliases).
Key takeaway
- Receive auth results as arguments → loose coupling & testability
- Dependency composition makes role checks cleaner
6. Schemas: Pydantic v2 with from_attributes
# app/schemas/todos.py
from datetime import datetime
from pydantic import BaseModel
class TodoBase(BaseModel):
title: str
is_done: bool = False
class TodoCreate(TodoBase):
pass
class Todo(TodoBase):
id: int
created_at: datetime
class Config:
from_attributes = True
Key takeaway
- Separate input (Create/Update) and output (Read) models
from_attributes=True
makes ORM conversion smooth
7. Defining Routers (APIRouter)
7.1 ToDo Router (Router dependencies + Endpoint dependencies)
# app/routers/todos.py
from fastapi import APIRouter, Depends, HTTPException, status
from typing import Annotated, List
from sqlalchemy.orm import Session
from app.db.session import get_db
from app.schemas.todos import Todo, TodoCreate
from app.core.security import User
router = APIRouter(
prefix="/todos",
tags=["todos"],
dependencies=[], # Shared dependencies applied to all endpoints
responses={404: {"description": "Not found"}}
)
DB = Annotated[Session, Depends(get_db)]
@router.post("", response_model=Todo, status_code=201)
def create_todo(payload: TodoCreate, db: DB, user: User):
from app.db.models import Todo as TodoModel
todo = TodoModel(title=payload.title, is_done=payload.is_done)
db.add(todo); db.commit(); db.refresh(todo)
return todo
@router.get("", response_model=List[Todo])
def list_todos(db: DB, user: User):
from app.db.models import Todo as TodoModel
return db.query(TodoModel).order_by(TodoModel.id.asc()).all()
7.2 Users Router (Router-level Shared Dependencies)
# app/routers/users.py
from fastapi import APIRouter, Depends
from typing import Annotated
from app.core.security import AdminUser
router = APIRouter(
prefix="/users",
tags=["users"],
dependencies=[Depends(lambda: True)] # e.g., audit log middleware dependency
)
@router.get("/me")
def read_me(user: Annotated[dict, Depends(...)]):
# In practice, use get_current_user. Simplified here.
return {"username": "alice"}
@router.get("/admin/metrics")
def admin_metrics(_admin: AdminUser):
return {"status": "ok", "metrics": {"active_users": 123}}
Key takeaway
- Centralize shared attributes with router
prefix/tags/responses/dependencies
- Endpoints declare only the minimal dependencies needed
8. main.py: Aggregate Routers and Register Exception Handlers
# app/main.py
from fastapi import FastAPI, Request
from fastapi.responses import JSONResponse
from app.routers import todos, users
from app.core.settings import get_settings
app = FastAPI(title="Modular FastAPI")
# Example: common domain exception
class DomainError(Exception):
def __init__(self, message: str):
self.message = message
@app.exception_handler(DomainError)
def domain_error_handler(_: Request, exc: DomainError):
return JSONResponse(status_code=400, content={"detail": exc.message})
# Include routers (with version prefix)
app.include_router(todos.router, prefix="/api/v1")
app.include_router(users.router, prefix="/api/v1")
# Health check
@app.get("/")
def health(settings = get_settings()):
return {"app": settings.app_name, "env": settings.env}
- Exception handler: Raise domain errors as exceptions, convert consistently in handler
- Versioning: Adding
/api/v1
atinclude_router
simplifies migrations - Settings dependency: Example of injecting
get_settings()
at the root route
Key takeaway
- Exception handler = unify domain error → HTTP response
- Attaching version prefixes at router inclusion makes expansion easier
9. Middleware vs Dependencies: When to Use Which?
- Middleware: Pre/post processing for all requests (e.g., request ID, CORS, timing, logging)
- Dependencies: Pre-processing at router or endpoint level (e.g., auth, DB sessions, settings, permission checks)
- Guidelines:
- “Needed by everyone” → middleware
- “Needed only for this feature” → dependency
- “Shared but with exceptions” → router-level dependency
Key takeaway
- Scope hierarchy: Middleware > Router dependency > Endpoint dependency
- Narrower scope = fewer side effects = easier testing
10. Dependency Design Patterns (Copy-Paste Ready)
10.1 Pagination Dependency
from fastapi import Query
from typing import Annotated
def pagination(
limit: int = Query(20, ge=1, le=100),
offset: int = Query(0, ge=0)
):
return {"limit": limit, "offset": offset}
Pagination = Annotated[dict, Depends(pagination)]
10.2 Transaction Control (Explicit Commit)
from sqlalchemy.orm import Session
from contextlib import contextmanager
@contextmanager
def tx(db: Session):
try:
yield
db.commit()
except:
db.rollback()
raise
10.3 Subset of Settings (Only Required Values)
from typing import TypedDict
class JwtConfig(TypedDict):
secret_key: str
def get_jwt_config(settings = Depends(get_settings)) -> JwtConfig:
return {"secret_key": settings.secret_key}
Key takeaway
- Bundle input validation (Query/Path/Body) into dependencies for neatness
- Slice configs into only required fragments to keep tests light
11. Eliminate Duplication with Router-level Shared Dependencies
Add auditing, rate limiting, permission boundaries at router scope.
# Example: dummy audit dependency
from fastapi import Request
def audit(request: Request):
# In reality, gather async with request ID, etc.
return True
# Apply at router definition
router = APIRouter(
prefix="/reports",
tags=["reports"],
dependencies=[Depends(audit)]
)
Key takeaway
- Cross-cutting concerns go into router dependencies
- Eliminate copy-paste, centralize change points
12. Versioning: Smooth Transition from v1 to v2
- Path method:
/api/v1/...
→ later add/api/v2/...
viainclude_router
- Separate routers: Create
routers_v2/
, run old and new APIs side-by-side for gradual migration - Schema consistency: Explicit
response_model
highlights breaking changes
# main.py (addition)
from app.routers_v2 import todos as todos_v2
app.include_router(todos_v2.router, prefix="/api/v2")
Key takeaway
- Physical separation (folders) + logical separation (prefix) = gradual migration
- Breaking changes isolated to v2, preserving compatibility
13. Shaping Exceptions & Error Responses (Improving DX)
- Define common exception types, unify into consistent responses via handlers
- Use
responses={...}
to declare expected forms in OpenAPI - Pydantic auto-handles input validation, focus only on business errors
# core/exceptions.py (example)
from fastapi import Request
from fastapi.responses import JSONResponse
class NotEnoughPermission(Exception):
def __init__(self, action: str): self.action = action
def register_handlers(app):
@app.exception_handler(NotEnoughPermission)
def _(req: Request, exc: NotEnoughPermission):
return JSONResponse(status_code=403, content={"detail": f"Action '{exc.action}' forbidden"})
Key takeaway
- Centralize exception → response conversion for better client experience
- OpenAPI
responses
keeps docs and implementation in sync
14. Swapping Dependencies in Tests (Regression-proof)
The real power of APIRouter/Depends shines in testing.
# tests/conftest.py (example)
import pytest
from app.main import app
from app.db.session import get_db
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
@pytest.fixture(autouse=True, scope="function")
def override_db():
engine = create_engine("sqlite:///:memory:", connect_args={"check_same_thread": False})
TestingSession = sessionmaker(bind=engine, autoflush=False, autocommit=False)
from app.db.models import Base
Base.metadata.create_all(engine)
def _get_db():
db = TestingSession()
try: yield db
finally: db.close()
app.dependency_overrides[get_db] = _get_db
yield
app.dependency_overrides.clear()
- Point: Test runs on a fresh in-memory DB, never touching production
- Swap auth dependencies as well to safely test role-specific behavior
Key takeaway
- Swappable dependencies = testability
- Freely replace DB, auth, and settings
15. Thin Service Layer (Keep Routers Slim)
Writing too much logic in endpoints makes testing/reuse painful. Extract into service functions, keep routers thin.
# services/todos.py (optional)
from sqlalchemy.orm import Session
from app.db.models import Todo as TodoModel
def create_todo(db: Session, title: str, is_done: bool):
todo = TodoModel(title=title, is_done=is_done)
db.add(todo); db.commit(); db.refresh(todo)
return todo
# routers/todos.py (partially replaced)
from app.services.todos import create_todo as create_todo_service
@router.post("", response_model=Todo, status_code=201)
def create_todo(payload: TodoCreate, db: DB, user: User):
return create_todo_service(db, payload.title, payload.is_done)
Key takeaway
- Router = I/O wiring, logic = service layer
- Enables function-level testing, more resilient to changes
16. Sample: Minimal End-to-End Implementation (Working Skeleton)
Minimal snippet combining article elements. Split into files as needed.
# app/main.py
from fastapi import FastAPI
from app.routers import todos
from app.core.exceptions import register_handlers
app = FastAPI(title="Scalable API")
app.include_router(todos.router, prefix="/api/v1")
register_handlers(app)
# app/routers/todos.py
from fastapi import APIRouter, Depends, HTTPException, status
from typing import List, Annotated
from sqlalchemy.orm import Session
from app.db.session import get_db
from app.schemas.todos import Todo, TodoCreate
from app.core.security import User
router = APIRouter(prefix="/todos", tags=["todos"])
DB = Annotated[Session, Depends(get_db)]
@router.post("", response_model=Todo, status_code=201)
def create_todo(payload: TodoCreate, db: DB, user: User):
from app.db.models import Todo as TodoModel
todo = TodoModel(title=payload.title, is_done=payload.is_done)
db.add(todo); db.commit(); db.refresh(todo)
return todo
@router.get("", response_model=List[Todo])
def list_todos(db: DB, user: User):
from app.db.models import Todo as TodoModel
return db.query(TodoModel).order_by(TodoModel.id.asc()).all()
Key takeaway
- Use
Annotated[T, Depends(...)]
for readability- Routers remain small & slim, shared logic goes into dependencies
17. Quality & Ops Tips (Practical Tricks)
- Naming: Use
get_db
,get_settings
→ consistent verb + noun - Granularity: DB/settings = coarse, param validation = fine (compose them)
- Docs alignment: Always specify
response_model
&responses
- Assume overrides: Always swap dependencies in tests/Sandbox to limit side effects
- API versioning: Apply
include_router(..., prefix="/api/v1")
from the start - Auditing/metrics: Place in router dependencies to collect cross-cutting data
Key takeaway
- Naming, granularity, documentation, overrides, versioning, cross-cutting logic
- Follow these 6 for stable operations
18. Common Pitfalls & Remedies
Symptom | Cause | Remedy |
---|---|---|
Circular dependencies | Modules import each other directly | Introduce abstractions (Protocols/TypedDict) or an interface layer |
DB sessions not closed | Missing yield dependency / missing cleanup on exceptions | Use yield dependency to always close, re-raise exceptions from service layer |
Routers bloat | Writing all logic inline | Extract into service functions, router handles only I/O |
Scattered configs | Reading env vars everywhere | Centralize with pydantic-settings, inject as dependency |
v2 migration chaos | Mixed v1 & v2 without boundaries | Separate folders + /api/v2 prefix for clarity |
Key takeaway
- Dependencies should flow one-way, cleanup with yield, keep routers thin
- Configs centralized, versions separated
19. Wrap-up (Build an Extensible Skeleton Today ♡)
- Use APIRouter to modularize, centralize shared attributes with prefix/tags/responses
- Use Depends + Annotated to inject DB, auth, settings, validation loosely
- Guarantee cleanup with yield dependencies, swap in tests for safety
- Centralize settings with pydantic-settings, improve DX with exception handlers, prepare for growth with versioning
With this skeleton, adding features = one more router, adding shared logic = one more dependency.
Code naturally stays tidy, reviews and tests stay light. Today’s small refactor brings tomorrow’s big peace of mind. I’m cheering you on ♡
Appendix A: Target Readers & Impact
- Solo developers: APIRouter + Depends improve readability and replaceability. Lower sunk cost even for small projects, better learning.
- Small teams: Centralize cross-cutting concerns (auditing, auth) in router dependencies, making impact scope clear. Easier reviews and onboarding.
- Growing SaaS: Unified settings, versioning, and exception handlers enable safe expansion. Dependency overrides in tests directly support CI stability.