FastAPI dependency injection patterns that stay readable
FastAPI dependency injection patterns can keep config, auth, and database access clear. Learn simple structures that still make sense months later.

Why readable dependencies get messy fast
A FastAPI route often starts with one argument and one clear job. You accept request data, call a service, and return a response. A month later, the same route also needs settings, the current user, a database session, a plan lookup, and an extra permission check.
@router.post("/subscriptions")
async def create_subscription(payload: SubscriptionIn):
return await service.create(payload)
Then it turns into this:
@router.post("/subscriptions")
async def create_subscription(
payload: SubscriptionIn,
settings: Settings = Depends(get_settings),
user: User = Depends(require_user),
db: Session = Depends(get_db),
plan: Plan = Depends(load_plan),
):
return await service.create(payload, settings, user, db, plan)
The issue is not the number of arguments alone. The route gets hard to read when different kinds of inputs all look equal. Request data sits next to app services and request context, even though they answer different questions.
payload came from the caller. settings and db came from the app. user came from the request after an auth check. When all of them pile up in one function signature, a new teammate has to stop and sort out what is real business input and what is plumbing.
Hidden side effects make it worse. A dependency named require_user might also check billing status, write audit logs, and reject suspended accounts. A dependency named get_db might open a transaction or commit work on the way out. The route looks short, but the actual behavior lives in several places.
Good FastAPI dependency injection patterns keep the route readable from top to bottom. A reader should spot three things fast:
- what the caller sent
- what the app injected
- what checks happen before the action runs
That does not mean every route must stay tiny. It means each dependency should do one obvious job, and the route should still read like a small story. If a subscription request fails, the team should understand why without opening five files first.
Choose three dependency buckets
When a FastAPI project grows, the problem is rarely the number of dependencies. It is that each dependency starts doing a different job. One checks a token, another reads settings and opens a client, and a third quietly decides whether the user can upgrade a plan. Months later, the route still works, but reading it feels like guessing.
A simple split keeps the code readable:
- Settings: app config, feature flags, environment values, shared options
- Request context: auth checks, current user, tenant, request-scoped identity data
- Resources: database sessions, cache clients, message brokers, third-party API clients
This split gives each dependency one clear job. Settings answer "how is this app configured?" Request context answers "who is calling?" Resources answer "what can this route talk to?" If a dependency does not fit one of those buckets, it often belongs in a service or plain function instead.
Keep business rules out of dependency functions. A dependency can fetch the current user, but it should not decide whether that user can cancel a subscription, apply a refund, or access a beta feature. Put that logic in a service the route calls on purpose. The route stays easy to scan, and the rule stays easy to test.
This is where many FastAPI dependency injection patterns become hard to follow. People start using dependencies as a hiding place for every decision. That saves a few lines today, then costs an hour during a bug fix because nobody can tell what runs before the handler body.
A small example makes the split clear. If a subscription endpoint needs a plan limit, the logged-in user, and a database session, those are three separate inputs. The dependency layer should hand the route those inputs and stop there. The route, or a service it calls, should decide whether the user can change the plan.
That boundary looks almost boring, which is a good sign. Boring code ages well.
Pass settings in one consistent way
Settings get messy when each module reads its own environment variables. One route checks os.getenv, another imports a global, and a third builds defaults inline. Six months later, nobody knows which value the app actually uses.
A cleaner pattern is simple: build one settings object when the app starts, then pass that object through one small dependency. That gives every route the same source of truth and keeps tests predictable.
from fastapi import FastAPI, Request, Depends
from pydantic_settings import BaseSettings
class Settings(BaseSettings):
app_name: str = "My API"
jwt_public_key: str
database_url: str
billing_api_base_url: str
def create_app() -> FastAPI:
app = FastAPI()
app.state.settings = Settings()
return app
app = create_app()
def get_settings(request: Request) -> Settings:
return request.app.state.settings
This works well because the environment gets read in one place only: Settings(). Routes do not care where values came from. They just ask for Settings and use what they need.
Clear names matter more than people think. database_url is fine. db is vague. billing_api_base_url tells you exactly what it points to. If an endpoint sends email, email_sender_address is easier to trust than sender.
Keep the dependency small. get_settings() should return the object and nothing else. Do not hide logic in it, do not patch values, and do not make network calls there. If settings need validation, do it when the app starts so errors show up early.
A route stays readable when the settings parameter matches the route's job:
@app.get("/subscriptions/{user_id}")
def get_subscription(user_id: str, settings: Settings = Depends(get_settings)):
return {
"user_id": user_id,
"billing_base_url": settings.billing_api_base_url,
}
That style scales well. You can pass config, auth, and database handles the same way, but each one still has one clear path into the route.
Keep auth checks close to the route
Auth gets hard to read when one route pulls in token parsing, user lookup, role checks, and tenant checks as separate dependencies. Six months later, nobody remembers which dependency blocks which action.
A better pattern is to let auth return one user context object, then keep the permission rule next to the code that needs it. The route stays short, and the rule stays visible.
from dataclasses import dataclass
from fastapi import Depends, HTTPException
@dataclass
class UserContext:
user_id: str
account_id: str
roles: set[str]
async def get_current_user(...) -> UserContext:
# parse token, load user, build context
return UserContext(user_id="u_123", account_id="a_456", roles={"billing_admin"})
That dependency should answer one question: "Who is calling this route?" It should not also decide every business rule. Once you mix identity and permission logic, simple routes start to feel like a puzzle.
@router.post("/accounts/{account_id}/subscription")
async def update_subscription(
account_id: str,
payload: SubscriptionUpdate,
user: UserContext = Depends(get_current_user),
db: Session = Depends(get_db),
):
ensure_can_manage_subscription(user, account_id)
return subscription_service.update(db, account_id, payload)
This is easier to read than stacking auth helpers in the signature. If you write a route like this, readers have to chase too much code before they understand it:
user = Depends(get_current_user)account = Depends(get_account_member)permission = Depends(require_billing_admin)
The route already has the facts it needs. A small check near the service call is usually clearer.
Name auth helpers after the rule they check. ensure_can_manage_subscription() is clear. require_admin() is not, because "admin" often means different things in different parts of the app. Good names save time during code review and bug fixes.
This is one of the most useful FastAPI dependency injection patterns for keeping routes readable: one dependency for identity, one explicit permission check where the action happens. Readers can scan the route and understand it without opening four files.
Handle database sessions without hiding work
A database session should feel boring. If people have to guess who opened it, who commits it, or where it rolls back, the route gets hard to trust.
Use one session per request and pass that same session down. Do not let a repository create its own session behind the scenes. That looks neat for a week, then nobody knows which query lives in which transaction.
from collections.abc import Generator
from fastapi import Depends
from sqlalchemy.orm import Session
def get_db() -> Generator[Session, None, None]:
db = SessionLocal()
try:
yield db
finally:
db.close()
def get_user_repo(db: Session = Depends(get_db)) -> UserRepo:
return UserRepo(db)
That pattern keeps ownership clear. FastAPI opens the request, your dependency creates one session, and every repo or service uses the same handle.
Passing a repository or service makes sense when it removes noise, not when it hides work. A repo is fine for read queries like get_user_by_id() or list_active_subscriptions(). A service is fine when one action touches several tables. But neither should quietly commit. Hidden commits are where bugs get weird.
Pick one layer for writes and stick to it. Many teams put commit() and rollback() in the service layer. That works well because the service already knows the full action. It can create the record, update related rows, and either save all of it or none of it.
Read routes and write routes should still look alike from the outside. The route injects the dependency, calls one method, and returns a result. The difference is simple: read methods fetch data, write methods finish the transaction in the same known place.
A clean route stays short:
@router.post("/users")
def create_user(payload: CreateUserIn, service: UserService = Depends(get_user_service)):
user = service.create_user(payload)
return UserOut.model_validate(user)
If one route injects Session, another injects UserRepo, and a third injects BillingService that opens its own session, the codebase starts to feel random. One session per request, one clear write layer, and one visible pattern for both reads and writes keeps the database part easy to follow months later.
Build a route step by step
A route stays readable when you add one dependency at a time. Good FastAPI dependency injection patterns do not start with Depends(...) everywhere. They start with the request body, then add the few outside inputs the handler truly needs.
@router.post("/subscriptions")
async def create_subscription(payload: SubscriptionCreate):
return {"plan": payload.plan}
That first version looks almost too simple, and that is the point. You can see the business action right away: a client sends subscription data, and the route handles it.
Add settings next. Config is usually stable and easy to understand, so it makes a good first dependency. If every route pulls config from the same get_settings function, nobody has to hunt for environment values or hidden globals.
@router.post("/subscriptions")
async def create_subscription(
payload: SubscriptionCreate,
settings: Settings = Depends(get_settings),
):
return {"plan": payload.plan, "trial_days": settings.default_trial_days}
Now add the user. Auth changes what the route is allowed to do, so keep it close to the route instead of burying it deep in helper code. A reader should not have to inspect three files just to learn who can call this endpoint.
Add the database session last. It is plumbing, not the main idea. By this point, the route has four clear inputs: request data, settings, user, and db.
class SubscriptionService:
def __init__(self, settings: Settings, db: Session):
self.settings = settings
self.db = db
def create_for_user(self, user: User, payload: SubscriptionCreate):
trial_days = self.settings.default_trial_days
subscription = Subscription(
user_id=user.id,
plan=payload.plan,
trial_days=trial_days,
)
self.db.add(subscription)
self.db.commit()
self.db.refresh(subscription)
return subscription
@router.post("/subscriptions")
async def create_subscription(
payload: SubscriptionCreate,
settings: Settings = Depends(get_settings),
user: User = Depends(get_current_user),
db: Session = Depends(get_db),
):
service = SubscriptionService(settings=settings, db=db)
return service.create_for_user(user=user, payload=payload)
This is a good place to stop. The constructor is small, the route still fits on one screen, and the service holds the repeated logic. If the signature grows past that, trim it before you add more. Six months later, four inputs still feel clear. Eight usually mean the route is doing too much.
A subscription endpoint that needs all three
A billing change is a good stress test for readable FastAPI dependency injection patterns. One request needs body data, app settings, a permission check, and a database write. If you hide too much, the route turns into a guessing game.
Keep each part obvious. The request body carries the new plan ID. Settings hold the allowed plans and prices. Auth decides whether this user can touch billing. One service writes the change.
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel
router = APIRouter()
class ChangePlanBody(BaseModel):
plan_id: str
@router.post("/subscription/change")
def change_subscription(
body: ChangePlanBody,
user: CurrentUser = Depends(require_billing_access),
settings: Settings = Depends(get_settings),
billing: BillingService = Depends(get_billing_service),
):
plan = settings.pricing.plans.get(body.plan_id)
if plan is None:
raise HTTPException(status_code=404, detail="Unknown plan")
updated = billing.change_plan(
account_id=user.account_id,
plan_id=body.plan_id,
price_cents=plan.price_cents,
changed_by=user.id,
)
return {
"subscription_id": updated.id,
"plan_id": updated.plan_id,
"price_cents": updated.price_cents,
}
This route stays readable because each dependency has one job. require_billing_access answers a simple question: can this user change billing? get_settings gives the route the pricing rules it needs right now. get_billing_service gives the route one place to send the write.
A small mistake shows up often here. Teams load pricing inside the service, check permissions in middleware, and pass a raw database session into the route anyway. The endpoint still works, but nobody can tell where the decision happens.
The better split is plain:
- The route reads the request and picks the plan from settings.
- The auth dependency rejects users who lack billing rights.
- The service makes the database change and keeps write logic in one place.
Think about a real case. A team adds a new "pro" plan six months later. With this setup, they update pricing in settings, keep the permission rule where the route declares it, and leave the write path alone. That is the sort of code you can scan in 20 seconds and trust.
Mistakes that turn dependencies into a puzzle
Most routes become hard to read because the messy part lives just outside the route. A clean function signature can still hide surprising behavior, and that is what causes trouble later.
One common mistake is hiding database writes inside auth helpers. A function named get_current_user() sounds harmless, but some teams also update last_seen, write an audit row, or refresh a token inside it. Now a route that looks read-only changes data every time it runs. When a bug shows up, nobody checks the auth dependency first.
Another problem is returning raw dictionaries from every dependency. A dict is quick at the start, but it gets vague fast. One route expects user["id"], another expects user["account_id"], and a third assumes user["role"] always exists. Small typed objects are easier to read and harder to misuse. They also make editor hints and tests much better.
Where people usually go too far
A bigger trap is putting Depends inside utility functions and service methods. That ties plain Python code to FastAPI itself. The moment you want to call the same service from a background job, a script, or a test, the code feels awkward. Service functions should accept real arguments like db, settings, or current_user. Let the route resolve dependencies, then pass them in.
Teams also create one giant object that mixes config, user state, and business rules. It often starts as something like RequestContext, then grows until it contains settings, the database session, the current user, feature flags, and half the app. That saves a few parameters, but it hides ownership. Any function can reach into that object and touch anything.
A few red flags show up early:
- auth dependencies write to the database
- dependencies return loose dicts instead of typed data
- services import and use
Depends - one context object carries unrelated state
A subscription endpoint makes this easy to spot. If the route checks auth, reads settings, and writes a plan change, those three actions should stay visible. The auth dependency should identify the user. The settings dependency should return settings. The database session should handle writes where the route or service calls them on purpose.
Quick review checklist before you merge
A route can look clean on day one and still confuse people six weeks later. Before you merge, read the function signature like a teammate who did not write it. If they need to jump across five files to learn what each argument means, the route is already too clever.
Use four quick checks.
- Each parameter should have a one-line job.
settingsgives app config.current_usergives the signed-in user.dbgives a session. If you need two sentences and a caveat, split the dependency or rename it. - Each dependency should return one thing, not a grab bag. A function like
get_context()that hands back config, user, flags, and a database handle saves typing once and costs clarity every day after that. - Names should line up across layers. If the route calls it
current_user, the auth dependency and the service should not call the same objectaccount,principal, oractor. Matching names cut down on mental overhead. - Route tests should stay small. If you need to patch settings, auth, the database, logging, and two helpers just to hit one endpoint, the route depends on too much hidden work.
This is where many FastAPI dependency injection patterns either stay clean or turn into a puzzle. The route should tell the truth about what it needs. A reader should not have to guess whether a dependency checks permissions, opens a transaction, loads feature flags, or calls another service on the side.
I like a blunt standard: a new teammate should explain every parameter in under 30 seconds. If they cannot, fix the name, trim the dependency, or move extra logic into a plain Python function before you merge.
What to clean up next
Start small. A full rewrite usually makes a FastAPI codebase worse before it gets better. Pick one busy router and rewrite two routes with the same dependency shape.
If one route takes settings, auth, and a database session, the second route should use the same order and the same naming unless you have a clear reason not to. That kind of repetition helps more than clever abstraction after month six.
Write a team rule that fits on half a page:
- settings come from one app-level dependency
- auth stays at the route layer, or in one thin route-specific dependency
- database access uses one session pattern for reads and writes
- dependency names stay plain and obvious
This rule looks almost too simple, but it stops drift. After a few weeks, people stop guessing whether they should import settings directly, hide auth inside a service, or open a session in a helper that sits five files away.
Then review three things together: constructors, dependency names, and test setup. They affect each other. If a service constructor needs six inputs, the route will probably feel crowded too. If dependency names are vague, tests will copy that confusion. If tests need heavy setup for a basic route, your boundaries are probably in the wrong place.
Readable FastAPI dependency injection patterns feel a little boring, and that is usually a good sign. A new developer should scan a route and know where config comes from, who checked the user, and when the database opens.
If your FastAPI code already feels tangled, Oleg Sotnikov can help with a focused architecture review or Fractional CTO support. A short outside review often finds the two or three habits that keep turning clean routes into a puzzle.