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 thisfrom src.db.session import enginefrom src.repositories.book_repository import BookRepositoryfrom src.services.book_service import BookService
# Created once at import time, shared across all requestsbook_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:
- Not testable.
book_serviceis a module-level singleton. Tests that need to swap a fake repository must monkeypatch the module, which is fragile and leaks between test cases. - 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.
- Implicit coupling. Any file that imports
book_servicenow 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.
from typing import Annotated, AsyncGeneratorfrom fastapi import Dependsfrom sqlalchemy.ext.asyncio import AsyncSessionfrom src.db.session import AsyncSessionLocalfrom src.repositories.book_repository import BookRepositoryfrom src.services.book_service import BookService
# Layer 1: Database session — request-scoped, cleaned up after responseasync 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 1async def get_book_repo( session: Annotated[AsyncSession, Depends(get_db)],) -> BookRepository: return BookRepository(session)
# Layer 3: Service — receives repository from layer 2async 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() callBookServiceDep = 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.
from fastapi import APIRouter, HTTPExceptionfrom src.api.deps import BookServiceDepfrom src.schemas.book import BookCreate, BookResponsefrom 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, HTTPAuthorizationCredentialsfrom src.models.user import Userfrom 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:
from src.api.deps import BookServiceDep, CurrentUserDepfrom 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
import pytestfrom fastapi.testclient import TestClientfrom src.main import appfrom src.api.deps import get_book_servicefrom tests.fakes import FakeBookService
@pytest.fixturedef 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 == 201dependency_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.
import pytestfrom decimal import Decimalfrom src.services.book_service import BookServicefrom src.core.exceptions import BookNotFoundErrorfrom tests.fakes import FakeBookRepository
@pytest.fixturedef service() -> BookService: # No HTTP layer, no FastAPI, no TestClient return BookService(repo=FakeBookRepository())
@pytest.mark.asyncioasync 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 state —
Depends()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_dbonce per request regardless. yieldfor cleanup — database sessions, file handles, and external connections useyield; FastAPI runs cleanup after the response is sent even on errors.- Type aliases keep signatures clean —
BookServiceDep = 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.