woman wearings coop neck floral top using her apple brand macbook
Photo by JÉSHOOTS on Pexels.com

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
    1. Splitting features with APIRouter and best practices for include_router
    2. Dependency injection with Depends and Annotated (DB, auth, settings)
    3. Router-level dependencies, endpoint dependencies, yield dependencies (with cleanup)
    4. Configuration management based on environment variables with Pydantic (v2) + pydantic-settings
    5. Differentiating exception handlers vs middleware, versioning design, dependency overrides in tests
  • 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 and Field 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 in app.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 uses get_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 at include_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/... via include_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)

  1. Naming: Use get_db, get_settings → consistent verb + noun
  2. Granularity: DB/settings = coarse, param validation = fine (compose them)
  3. Docs alignment: Always specify response_model & responses
  4. Assume overrides: Always swap dependencies in tests/Sandbox to limit side effects
  5. API versioning: Apply include_router(..., prefix="/api/v1") from the start
  6. 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.

By greeden

Leave a Reply

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

日本語が含まれない投稿は無視されますのでご注意ください。(スパム対策)