A dependency doesn’t have to be a function. Anything callable works — including a class. When we pass a class to Depends(), FastAPI calls ClassName(...) for each request and injects the resulting instance.
In simple language — if our “dependency” is just a few parameters bundled together, or if it needs configuration we set at app startup, a class fits better than a function.
Why classes over functions
Functions are great for stateless dependencies (parse pagination, decode a token). But sometimes:
- We want grouped parameters with attribute access (
page.skipreads nicer thanpage["skip"]). - The dependency needs configuration injected at app startup (DB pool, Redis client, config object).
- We want a clear, mockable interface for tests.
Classes give us all three.
Grouped params — the simplest case
Functions return a dict. Classes return an instance with typed attributes:
from fastapi import Depends, FastAPI
app = FastAPI()
class Pagination:
def __init__(self, skip: int = 0, limit: int = 10):
self.skip = skip
self.limit = min(limit, 100)
@app.get("/users")
def list_users(page: Pagination = Depends(Pagination)):
return {"skip": page.skip, "limit": page.limit}
FastAPI inspects __init__ exactly like it would inspect a function signature — skip and limit become query params, defaults flow through, Swagger picks them up.
The shortcut
When the dependency IS the class itself, repeating it feels silly:
def list_users(page: Pagination = Depends(Pagination)): # noisy
FastAPI lets us write it as:
def list_users(page: Pagination = Depends()): # cleaner
Same behavior, half the typing.
Callable instances — the shared-state pattern
Here’s the killer pattern. We instantiate the class once at startup with config (DB URL, redis client, feature flag), then the instance itself becomes the dependency. Per request, FastAPI calls the instance (via __call__), not the class.
import redis
from fastapi import Depends, HTTPException, Header
class RateLimiter:
def __init__(self, redis_client, max_per_minute: int):
self.r = redis_client
self.max = max_per_minute
def __call__(self, x_api_key: str = Header(...)) -> str:
key = f"ratelimit:{x_api_key}"
count = self.r.incr(key)
if count == 1:
self.r.expire(key, 60)
if count > self.max:
raise HTTPException(429, "too many requests")
return x_api_key
# Build once at startup
rate_limit = RateLimiter(redis.Redis(), max_per_minute=60)
@app.get("/search")
def search(api_key: str = Depends(rate_limit)):
return {"caller": api_key}
Why this rules:
- The Redis client is created once, not per request.
- The
max_per_minuteis config we control at startup. - The class is trivial to swap in tests — instantiate with a fake redis and a different limit.
Testability — the real argument
Compare. With a function:
def get_db() -> Session:
return Session(global_engine) # hardcoded global
Testing this means monkey-patching the global. Ugly.
With a class:
class DBProvider:
def __init__(self, engine):
self.engine = engine
def __call__(self) -> Session:
return Session(self.engine)
db_provider = DBProvider(engine=prod_engine)
# In tests
app.dependency_overrides[db_provider] = DBProvider(engine=test_engine)
We swap the entire provider at the DI boundary. Zero monkey-patching, zero globals.
Sub-deps work the same
A class can declare other Depends() in __init__ (or __call__ for callable instances):
class AuditedDB:
def __init__(
self,
db: Session = Depends(get_db),
user: User = Depends(get_current_user),
):
self.db = db
self.user = user
def log_and_query(self, sql: str):
audit(self.user.id, sql)
return self.db.execute(sql)
The takeaway — function dependencies for simple stateless work, class dependencies (especially callable instances) when we need startup config, grouped state, or clean test seams. The Depends() API is the same; only the callable changes.