Tutorial

Clean Code Python: Dependency Injection Without Magic

FastAPI's Depends() is the most underused tool in the framework. Here is how to use it to wire repositories, services, and authentication into your route handlers with no global state and no service locator pattern.

Tin Dang avatar
Tin Dang
Electrical circuit board with clean organized connectors and wiring paths

Most Python dependency injection tutorials show you a framework with decorators and a container. Flask-Injector, punq, lagom — they all ask you to register dependencies somewhere, decorate your functions with @inject, and trust the container to resolve the graph at runtime.

FastAPI already has this built in. Depends() is not just for database sessions — it is a full dependency injection system that resolves per-request, handles async and sync mixing transparently, manages cleanup via yield, and composes arbitrarily deep dependency graphs. The only thing it lacks is a confusing abstraction layer.

The Problem: Global State and Service Locators

Before looking at the right way, it is worth understanding exactly what goes wrong with the most common shortcut: a global service instance created at module import time.

# src/api/v1/books.py — anti-pattern, do not do this
from src.db.session import engine
from src.repositories.book_repository import BookRepository
from src.services.book_service import BookService
# Created once at import time, shared across all requests
book_repo = BookRepository(engine)
book_service = BookService(book_repo)
@router.get("/books/{book_id}")
async def get_book(book_id: int):
return await book_service.get_book(book_id)

Three immediate problems:

  1. Not testable. book_service is a module-level singleton. Tests that need to swap a fake repository must monkeypatch the module, which is fragile and leaks between test cases.
  2. Not request-scoped. SQLAlchemy async sessions are designed to live for one request. A session held for the lifetime of the process accumulates state, leaks transactions, and eventually deadlocks.
  3. Implicit coupling. Any file that imports book_service now depends on the engine being initialized, which depends on the database URL being present. Import the module in a test and the database connection fires.

FastAPI’s Depends() System

Depends() creates a dependency graph. FastAPI resolves it per-request: it calls each dependency function, passes results to the functions that depend on them, and — for yield dependencies — calls cleanup after the response is sent.

The dependency graph for the BookStore API has three layers. Each layer knows only about the layer directly below it.

src/api/deps.py
from typing import Annotated, AsyncGenerator
from fastapi import Depends
from sqlalchemy.ext.asyncio import AsyncSession
from src.db.session import AsyncSessionLocal
from src.repositories.book_repository import BookRepository
from src.services.book_service import BookService
# Layer 1: Database session — request-scoped, cleaned up after response
async def get_db() -> AsyncGenerator[AsyncSession, None]:
async with AsyncSessionLocal() as session:
yield session
# AsyncSessionLocal.__aexit__ handles commit/rollback/close
# Layer 2: Repository — receives session from layer 1
async def get_book_repo(
session: Annotated[AsyncSession, Depends(get_db)],
) -> BookRepository:
return BookRepository(session)
# Layer 3: Service — receives repository from layer 2
async def get_book_service(
repo: Annotated[BookRepository, Depends(get_book_repo)],
) -> BookService:
return BookService(repo)
# Type alias — route handlers use this, not the raw Depends() call
BookServiceDep = Annotated[BookService, Depends(get_book_service)]

The Annotated[T, Depends(fn)] pattern is the idiomatic FastAPI approach. It moves the dependency declaration to a type alias, keeping route function signatures clean and importable by name.

Visualizing the Dependency Graph

FastAPI resolves the graph automatically each time a route is called:

FastAPI handles deduplication automatically. If two dependencies in the same request both call Depends(get_db), FastAPI calls get_db only once and shares the result. The session is created once per request regardless of how many downstream dependencies need it.

Route Handlers Become Thin

With BookServiceDep wiring the service, route handlers are 3-5 lines. All business logic is already in the service layer; the route’s only job is HTTP translation.

src/api/v1/books.py
from fastapi import APIRouter, HTTPException
from src.api.deps import BookServiceDep
from src.schemas.book import BookCreate, BookResponse
from src.core.exceptions import BookNotFoundError, InvalidISBNError
router = APIRouter(prefix="/books", tags=["books"])
@router.get("/{book_id}", response_model=BookResponse)
async def get_book(book_id: int, service: BookServiceDep) -> BookResponse:
try:
return await service.get_book(book_id)
except BookNotFoundError:
raise HTTPException(status_code=404, detail=f"Book {book_id} not found")
@router.post("/", response_model=BookResponse, status_code=201)
async def create_book(data: BookCreate, service: BookServiceDep) -> BookResponse:
try:
return await service.add_book(data)
except InvalidISBNError as e:
raise HTTPException(status_code=422, detail=str(e))
@router.get("/", response_model=list[BookResponse])
async def list_books(
q: str | None = None,
service: BookServiceDep = ..., # FastAPI resolves this
) -> list[BookResponse]:
return await service.list_books(query=q)

The route handlers contain no business logic, no repository access, no session management. They receive a service, call one method, handle the exception translation, and return the result. Testing the business logic happens at the service layer — testing the HTTP behavior happens at the integration test level with TestClient.

Authentication as a Dependency

The same pattern applies to authentication. A get_current_user dependency validates a JWT, fetches the user, and returns a typed User object. Route handlers that need authentication add CurrentUserDep to their signature.

# src/api/deps.py (continued)
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
from src.models.user import User
from src.core.exceptions import AuthenticationError
security = HTTPBearer()
async def get_current_user(
credentials: Annotated[HTTPAuthorizationCredentials, Depends(security)],
session: Annotated[AsyncSession, Depends(get_db)],
) -> User:
token = credentials.credentials
try:
payload = verify_jwt(token) # raises AuthenticationError if invalid
except AuthenticationError:
raise HTTPException(
status_code=401,
detail="Invalid or expired token",
headers={"WWW-Authenticate": "Bearer"},
)
user = await get_user_by_id(session, user_id=payload["sub"])
if user is None:
raise HTTPException(status_code=401, detail="User not found")
return user
CurrentUserDep = Annotated[User, Depends(get_current_user)]

A route that requires both a service and an authenticated user composes both dependencies in its signature with no boilerplate:

src/api/v1/books.py
from src.api.deps import BookServiceDep, CurrentUserDep
from src.schemas.order import OrderResponse
@router.post("/{book_id}/purchase")
async def purchase_book(
book_id: int,
service: BookServiceDep,
current_user: CurrentUserDep,
) -> OrderResponse:
return await service.place_order(current_user.id, [book_id])

FastAPI resolves get_db only once even though both get_book_service and get_current_user depend on it. The session is shared, the transaction is unified, and the handler has no idea this is happening.

Testing with Dependency Override

tests/test_books_api.py
import pytest
from fastapi.testclient import TestClient
from src.main import app
from src.api.deps import get_book_service
from tests.fakes import FakeBookService
@pytest.fixture
def client() -> TestClient:
app.dependency_overrides[get_book_service] = lambda: FakeBookService()
yield TestClient(app)
app.dependency_overrides.clear()
def test_get_book_returns_404(client: TestClient) -> None:
response = client.get("/books/999")
assert response.status_code == 404
def test_create_book_returns_201(client: TestClient) -> None:
payload = {"title": "Clean Code Python", "isbn": "9780134685991", "price": "29.99"}
response = client.post("/books/", json=payload)
assert response.status_code == 201

dependency_overrides swaps the entire dependency graph from get_book_service downward. The fake service replaces the real service, repository, and database session in one line.

tests/test_book_service.py
import pytest
from decimal import Decimal
from src.services.book_service import BookService
from src.core.exceptions import BookNotFoundError
from tests.fakes import FakeBookRepository
@pytest.fixture
def service() -> BookService:
# No HTTP layer, no FastAPI, no TestClient
return BookService(repo=FakeBookRepository())
@pytest.mark.asyncio
async def test_raises_not_found(service: BookService) -> None:
with pytest.raises(BookNotFoundError):
await service.get_book(book_id=9999)

Direct service tests are faster than TestClient tests and test more precisely. Use TestClient for integration tests (does the route return the right status code?) and direct service tests for logic (does the discount calculation work?).

Key Takeaways

  • No global stateDepends() creates a new instance of every dependency for every request, scoped correctly and cleaned up automatically.
  • Composable dependencies — auth, database sessions, repositories, and services all compose via the same Annotated[T, Depends(fn)] pattern.
  • Deduplication is automatic — if two dependencies need the same session, FastAPI calls get_db once per request regardless.
  • yield for cleanup — database sessions, file handles, and external connections use yield; FastAPI runs cleanup after the response is sent even on errors.
  • Type aliases keep signatures cleanBookServiceDep = Annotated[BookService, Depends(get_book_service)] is the pattern; route signatures stay readable.

Next: Part 6 builds on the exception hierarchy from Part 4 and adds FastAPI exception handlers so every domain error becomes a consistent, well-structured HTTP response.

0

Next in this series

Clean Code Python: Structured Error Handling Across the Stack

Continue reading