Core Architecture and Routing Patterns in FastAPI

FastAPI gives you routing, validation and dependency injection out of the box. What it does not give you is an opinion about where the application object comes from, which module owns the database pool, or what a failure looks like to a client. Those are the decisions this area covers, and they are the ones that determine whether a service is still pleasant to change after two years and forty thousand lines.

This is the structural half of the site. It sits alongside Advanced Pydantic Validation and Serialization, which governs the shape of data crossing the boundary, and Async, Background Tasks and Observability, which governs what happens at runtime once the structure is in place. The home page has the full map.

Seven guides sit beneath this one, and they are best read as a sequence rather than a menu. Application Factory Patterns covers where the app comes from. Configuration Management covers what the factory reads. Modular Router Organization covers how endpoints are grouped and composed. Dependency Injection Strategies covers how handlers acquire what they need. Middleware Implementation covers the layer that wraps everything. Error Handling and Global Exceptions covers what clients see when something fails. And The FastAPI Request/Response Lifecycle explains the machinery all six of the others are decorating — read it when a behaviour surprises you and you need the mechanism rather than the recipe.

What happens when, in a FastAPI application Four columns. Import time builds the app object, includes routers and computes the dependency graph, performing no input or output. Startup enters the lifespan context manager, opens pools and clients, and sets application state. Per request, the middleware chain runs, a route is matched, dependencies resolve and the handler produces a response. Shutdown exits lifespan and closes the pools. A band across the bottom notes that failures from every phase converge on one error boundary. Construction is not startup. Startup is not a request. Import time create_app() runs routers included dep graph computed handlers registered no sockets opened Startup lifespan enters pools · clients open caches warmed app.state populated once per process Per request middleware chain route matched dependencies resolve handler · response keep this cheap Shutdown in-flight drains lifespan exits pools closed clients released symmetry matters Failures from any phase converge on one error boundary
Most architectural bugs in FastAPI are a phase error: work placed in the wrong column. A pool opened at import time, settings read per request, a client never closed.

The four columns above are the organising idea for everything below. When a FastAPI application misbehaves structurally — it hangs on boot, leaks connections, behaves differently under Gunicorn than under pytest, or serves a stale configuration — the cause is nearly always that some piece of work is happening in the wrong column.

Assembly: the factory and what it is allowed to do

The default FastAPI tutorial writes app = FastAPI() at module scope, and for a single-file service that is correct. It stops being correct the moment you need two of them. A test suite wants an instance with a stub database; a worker process wants the same routes without the public middleware; a staging deploy wants a different settings object. Module-scope construction gives you exactly one, built as a side effect of importing.

The alternative is a function that builds and returns a wired instance. Application Factory Patterns develops this in full, including how to keep it usable with uvicorn --factory and how to shape pytest fixtures around it. The discipline that matters most is about what the function must not do: it must not open a socket, connect to a database, or read a remote secret. Keep it synchronous and pure and it stays cheap enough to call in every test — FastAPI App Factory Pattern for Testing and Deployment works through the pytest fixtures and the uvicorn --factory invocation that follow from it.

Resource acquisition instead belongs to the lifespan context manager, which runs after construction and before the first request is served, and unwinds in reverse on the way down. Lifespan Events vs Startup and Shutdown in FastAPI covers the migration from the retired @app.on_event decorators and the ordering guarantees you get from the newer API.

What the factory reads is configuration, and configuration deserves the same discipline. A typed settings object, parsed and validated once, turns a missing environment variable into a failure at boot rather than a KeyError inside a request handler three hours later. Configuration Management works through the precedence rules and the injection pattern; Managing Environment Variables with Pydantic Settings covers the field-mapping details that trip people up, Secrets and .env Files Per Environment in FastAPI covers layering real secrets on top, and Pydantic Settings vs Dynaconf vs python-decouple compares the libraries on the only axis that matters operationally — whether bad configuration stops a deploy or ships quietly.

Why this matters at scale. A pool created at import time is created once per import, which under a forking server means it is created in the parent and inherited, broken, by every child. A pool created lazily on first use turns your load balancer's health check into the request that pays the connection cost, and makes every autoscale event visible in your latency percentiles. Putting acquisition in lifespan is not stylistic tidiness; it is the only placement where the resource's lifetime matches the process's.

Routing topology: grouping is a public decision

An APIRouter is where a set of endpoints acquires a prefix, a tag, and a set of shared dependencies. The choice of how to group them leaks directly into your OpenAPI document, and from there into every generated client SDK your consumers use. Group by domain and a Python client reads client.billing.create_invoice(); group by HTTP verb or by team org chart and it reads like nothing at all.

Modular Router Organization is the guide for this, and it takes the composition rules seriously: how routers nest, what a nested prefix produces, and the genuinely different case of mounting a sub-application rather than including a router, which APIRouter Prefix vs Sub-Application Mounting in FastAPI pulls apart in terms of what the mounted app actually inherits.

Two newer guides handle the surface a growing API exposes. Versioning APIs with FastAPI Routers shows /v1 and /v2 running side by side with only the changed handlers duplicated, plus how deprecation is expressed in the generated document. Router Tags and OpenAPI Grouping in FastAPI covers tags, openapi_tags metadata and stable operation IDs — the last of which is what stops a regenerated SDK from renaming half its methods. For the filesystem layout underneath all of it, How to Structure Large FastAPI Projects for Scale proposes a domain-package arrangement and, usefully, an import-boundary test that fails the build when a layer reaches somewhere it should not.

Why this matters at scale. Router boundaries are the cheapest form of blast-radius control available. Two teams working in one module share a merge surface and a review queue; the same two teams working in two routers rarely conflict. And because the boundary is also the OpenAPI grouping, the structural decision and the documentation decision are the same decision — which means you only have to get it right once.

Dependency injection and the three lifetimes

Dependency injection is the mechanism that lets a handler declare what it needs and receive it already built. The subtlety that causes real production incidents is not the syntax; it is lifetime. FastAPI hands you three distinct ones and does not label them, so it is worth seeing all three in a single response.

"""Three lifetimes in one app: process-wide state, per-request yield deps, per-request cache."""
import itertools
from typing import Annotated

from fastapi import Depends, FastAPI, Request

_pool_ids = itertools.count(1)
_session_ids = itertools.count(1)
_settings_reads = itertools.count(1)


class Pool:
    """Stands in for a connection pool: expensive, built once, shared by every request."""

    def __init__(self) -> None:
        self.pool_id = f"pool-{next(_pool_ids)}"


async def get_pool(request: Request) -> Pool:
    return request.app.state.pool


async def get_session(pool: Annotated[Pool, Depends(get_pool)]):
    """A yield dependency: a fresh handle per request, closed on the way out."""
    session_id = f"session-{next(_session_ids)}"
    try:
        yield {"session_id": session_id, "from": pool.pool_id}
    finally:
        pass


async def get_settings() -> str:
    """Declared twice below. FastAPI caches it per request by default (use_cache=True)."""
    return f"settings-read-{next(_settings_reads)}"


async def audit_log(settings: Annotated[str, Depends(get_settings)]) -> str:
    return settings


def create_app() -> FastAPI:
    application = FastAPI()
    # Built once when the application object is constructed, not per request.
    application.state.pool = Pool()

    @application.get("/scopes")
    async def scopes(
        session: Annotated[dict, Depends(get_session)],
        settings_direct: Annotated[str, Depends(get_settings)],
        settings_via_audit: Annotated[str, Depends(audit_log)],
    ) -> dict[str, str]:
        return {
            "pool": session["from"],
            "session": session["session_id"],
            "settings_direct": settings_direct,
            "settings_via_audit": settings_via_audit,
        }

    return application


app = create_app()

Two requests were sent to this app through the verification harness. The transcript below is its real output, not a description of it:

$ GET /scopes
200 OK
{
  "pool": "pool-1",
  "session": "session-1",
  "settings_direct": "settings-read-1",
  "settings_via_audit": "settings-read-1"
}

$ GET /scopes
200 OK
{
  "pool": "pool-1",
  "session": "session-2",
  "settings_direct": "settings-read-2",
  "settings_via_audit": "settings-read-2"
}

Read the three columns of that output. pool is identical across both requests — it was built once, during construction, and every request borrows the same object. session increments — the yield dependency produced a fresh handle for each request and disposed of it afterwards. And settings increments per request but is identical within a request, even though it was reached by two different routes through the graph: once declared directly on the handler, once reached indirectly through audit_log. That is the per-request cache, and it is why decomposing a dependency into six small ones costs almost nothing.

The counter-intuitive corollary is that the cache is keyed on the callable object. Two functions that do the same thing are two entries; the same function wrapped twice is two entries. Dependency Caching and use_cache in FastAPI covers both the mechanism and the cases where you deliberately want to defeat it. The teardown half — what order finally blocks run in, and what happens when the handler raises — is Yield Dependencies and Cleanup Order in FastAPI. For the broader question of where to declare a dependency and which thread its provider runs on, Best Practices for FastAPI Dependency Injection is the reference, and the whole area is introduced in Dependency Injection Strategies.

The property that pays for all of this is substitutability. Because the graph is declarative, any node in it can be replaced wholesale in a test — the database, the payment gateway, the clock — without the handler knowing. Overriding Dependencies in FastAPI Tests covers the override mechanism including its most frustrating failure, where a wrong dictionary key produces a silent no-op rather than an error. And when the wiring itself will not import, Fixing FastAPI Dependency Injection Circular Imports reproduces the cycle and breaks it with a Protocol.

Why this matters at scale. Lifetime errors do not fail loudly. A session held at process scope works perfectly in development and corrupts data under concurrency. A pool rebuilt per request works perfectly until traffic arrives and the database refuses connections. Both are invisible to unit tests and obvious in the table above, which is why it is worth internalising the three lifetimes as three lifetimes rather than as "things you pass to Depends".

The middleware boundary

Middleware wraps the application rather than the route, which means it runs on requests that never match a route at all — including the ones that 404. That single fact settles most of the "should this be middleware or a dependency?" arguments. Request ID stamping, CORS negotiation, and anything you want applied to unmatched paths has to be middleware. Anything that produces a typed value for a handler to consume, or that should vary by route, is a dependency.

Middleware Implementation covers the layer as a whole, including the choice between BaseHTTPMiddleware and writing pure ASGI, which matters more than it looks: the former runs your dispatch inside a task group and can materialise response bodies, the latter does neither. Middleware vs Dependencies: When to Use Which is the decision guide, and Middleware Execution Order in FastAPI covers nesting and the frequently surprising placement of exception handlers relative to the chain.

Two specific implementations get their own guides. Implementing Custom Middleware for Request Tracing is the correlation ID pattern that the observability guides downstream depend on. CORS Middleware Configuration in FastAPI covers preflight handling and the wildcard-plus-credentials combination that browsers reject and developers keep writing.

Why this matters at scale. Middleware is the only place where a rule can be applied uniformly without relying on every developer remembering to apply it. That makes it the right home for security and observability invariants, and the wrong home for business logic — because the same uniformity that guarantees coverage also means you cannot make an exception without an if statement inspecting the path, which is where middleware starts to rot.

The failure contract

FastAPI ships three different error shapes by default: the {"detail": "..."} string from HTTPException, the {"detail": [...]} array from a validation failure, and an unhandled exception that becomes a 500 with a traceback in your logs and nothing useful for the client. A public API should emit one shape, and the place to enforce that is a small set of registered handlers.

The example below assembles a complete miniature of everything above: a factory, a versioned router, a dependency reading middleware-set state, a domain exception that knows nothing about HTTP, and handlers converting every failure into one structure.

"""A factory assembling routers, dependencies, middleware and one error boundary."""
from typing import Annotated

from fastapi import APIRouter, Depends, FastAPI, Request
from fastapi.exceptions import RequestValidationError
from fastapi.responses import JSONResponse
from pydantic import BaseModel, Field


class AccountFrozen(Exception):
    """A domain failure, raised by the service layer with no knowledge of HTTP."""

    def __init__(self, account_id: str) -> None:
        self.account_id = account_id


class Transfer(BaseModel):
    account_id: str = Field(min_length=3)
    amount_cents: int = Field(gt=0)


class RequestId:
    """Pure-ASGI middleware: stamps an id every layer below can be correlated by."""

    def __init__(self, app) -> None:
        self.app = app
        self.counter = 0

    async def __call__(self, scope, receive, send) -> None:
        if scope["type"] != "http":
            await self.app(scope, receive, send)
            return
        self.counter += 1
        request_id = f"req-{self.counter:03d}"
        scope["state"] = {"request_id": request_id}

        async def send_wrapper(message) -> None:
            if message["type"] == "http.response.start":
                message.setdefault("headers", []).append(
                    (b"x-request-id", request_id.encode())
                )
            await send(message)

        await self.app(scope, receive, send_wrapper)


async def current_request_id(request: Request) -> str:
    return request.scope.get("state", {}).get("request_id", "none")


def build_router() -> APIRouter:
    router = APIRouter(prefix="/v1/transfers", tags=["transfers"])

    @router.post("/")
    async def create_transfer(
        transfer: Transfer,
        request_id: Annotated[str, Depends(current_request_id)],
    ) -> dict[str, str | int]:
        if transfer.account_id == "frozen-acct":
            raise AccountFrozen(transfer.account_id)
        return {
            "accepted": transfer.amount_cents,
            "account_id": transfer.account_id,
            "request_id": request_id,
        }

    return router


def envelope(request: Request, code: str, message: str, status: int) -> JSONResponse:
    """One shape for every failure, whatever raised it."""
    return JSONResponse(
        status_code=status,
        content={
            "error": {"code": code, "message": message},
            "request_id": request.scope.get("state", {}).get("request_id", "none"),
        },
    )


def create_app() -> FastAPI:
    application = FastAPI()
    application.include_router(build_router())

    @application.exception_handler(AccountFrozen)
    async def handle_frozen(request: Request, exc: AccountFrozen) -> JSONResponse:
        return envelope(request, "account_frozen", f"account {exc.account_id} is frozen", 409)

    @application.exception_handler(RequestValidationError)
    async def handle_validation(request: Request, exc: RequestValidationError) -> JSONResponse:
        first = exc.errors()[0]
        field = ".".join(str(p) for p in first["loc"][1:])
        return envelope(request, "invalid_request", f"{field}: {first['msg']}", 422)

    application.add_middleware(RequestId)
    return application


app = create_app()

Three requests — one valid, one hitting the domain rule, one malformed — produce this, which is the harness's recorded output verbatim:

$ POST /v1/transfers/  {"account_id": "acct-9001", "amount_cents": 2500}
200 OK
{
  "accepted": 2500,
  "account_id": "acct-9001",
  "request_id": "req-001"
}

$ POST /v1/transfers/  {"account_id": "frozen-acct", "amount_cents": 2500}
409 Conflict
{
  "error": {
    "code": "account_frozen",
    "message": "account frozen-acct is frozen"
  },
  "request_id": "req-002"
}

$ POST /v1/transfers/  {"account_id": "ab", "amount_cents": 0}
422 Unprocessable Entity
{
  "error": {
    "code": "invalid_request",
    "message": "account_id: String should have at least 3 characters"
  },
  "request_id": "req-003"
}

Two details in that transcript are worth pausing on. The 409 and the 422 have the same top-level keys as each other — a client can parse error.code without first working out which subsystem rejected it. And every response, success or failure, carries the same request_id that the middleware stamped and returned in a header, so a support ticket quoting the ID leads straight to the request in your logs.

The third detail is what the transcript does not show. The malformed request set two fields wrong — a short account_id and a zero amount_cents — but the handler reported only the first, because it reads exc.errors()[0]. That is a deliberate simplification for one page of code and a bad default for a real API; clients that get one error at a time make one round trip per mistake. Customising Validation Error Responses in FastAPI shows how to reshape the full list instead of truncating it.

For the wider contract, Error Handling and Global Exceptions covers handler registration and resolution order, Global Exception Handlers for Consistent API Responses covers collapsing FastAPI's three default shapes into one, and HTTPException vs Custom Exception Classes in FastAPI argues the case for keeping status codes out of your service layer entirely — which is what makes AccountFrozen above importable by a background worker that has no HTTP context at all.

Why this matters at scale. The error contract is consumed by code you do not control and cannot deploy. Changing it is as breaking as changing a response model, and inconsistency in it is paid for by every client team writing per-endpoint special cases. One shape, registered once, is the only version of this that survives a hundred endpoints.

Underneath: the lifecycle itself

Everything above describes layers you add. The FastAPI Request/Response Lifecycle describes what those layers are added to — the Starlette machinery that receives the connection, matches the path, invokes the dependency graph, and turns your return value into bytes. It is the newest guide in this area and the one to reach for when behaviour contradicts your model of it.

Three specific stages have their own guides. How a Request Flows Through FastAPI traces a single real request through every stage in order, which resolves most "why did this run twice / why did this run too early" questions immediately. Response Model and Serialization Order covers the step where your returned object becomes JSON — including the re-validation that response_model performs and the reason fields sometimes vanish from responses that clearly set them. And Streaming and File Responses in FastAPI covers the case where you deliberately bypass that step, which has consequences for response models, cleanup and middleware alike.

Why this matters at scale. Recipes stop working when your situation is slightly different from the one they were written for. The mechanism keeps working. A team that understands where the dependency graph is built — at import time, not per request — will correctly predict that a dependency reading a module-level global at definition time captures the value once, and will not spend an afternoon on it.

Cross-cutting trade-offs

Every row below is a real decision with a real cost. The column that matters is the last one, because that is the one people discover after committing.

DecisionSimpler formScales better asWhat it costs
App constructionapp = FastAPI() at module scopecreate_app() factoryOne layer of indirection; --factory in your run command
Resource acquisitionOpen on first useOpen in lifespanStartup can now fail, and should
Route groupingOne router, flat prefixesDomain routers composed in the factoryMore files; a naming convention to agree on
VersioningOne unversioned surfaceVersion-prefixed parent routersTwo contracts to keep alive at once
Cross-cutting rulesRepeat in each handlerMiddleware, or a router-level dependencyExceptions become awkward to express
Dependency granularityFew large dependenciesMany small cached onesMore names; the cache keying to understand
Failure responsesRaise HTTPException inlineDomain exceptions plus registered handlersAn exception taxonomy to maintain
ConfigurationRead os.environ where neededTyped settings injected as a dependencyBoilerplate up front; loud failures at boot

The consistent shape: the simpler form is faster to write and slower to change, and the crossover point arrives when a second developer, a second environment or a second version enters the picture. That is earlier than it feels.

Named anti-patterns

The import-time connection. A module that creates an engine, an HTTP client or a pool at module scope. Root cause: treating import as initialisation. Symptom: the test suite opens a real connection, and under a forking server every worker inherits a socket created before the fork. Fix: build nothing in the module body; acquire in lifespan, expose through a dependency.

The god router. One APIRouter accumulating every endpoint because splitting it "can wait". Root cause: the cost of splitting rises with size, so deferring it is self-reinforcing. Symptom: merge conflicts on one file, an OpenAPI document with a single tag, and a generated SDK with a hundred methods on one class. Fix: split by domain at the first sign of two owners, following Modular Router Organization.

Mutable state on app.state. Attaching a dictionary or counter to app.state and mutating it during requests. Root cause: app.state looks like a convenient place for globals. Symptom: values that are correct under a single worker and wrong under several, or races that only appear under load. Fix: reserve app.state for immutable handles to shared resources; route anything per-request through dependencies, and anything genuinely shared through a real store.

HTTP leaking into the domain. Service functions raising HTTPException with a status code. Root cause: it is the shortest path from "this is invalid" to a correct response. Symptom: the service cannot be called from a worker, a CLI or a test without importing FastAPI, and the same rule returns different codes depending on which handler called it. Fix: domain exceptions, translated once at the boundary — see HTTPException vs Custom Exception Classes in FastAPI.

The duplicated dependency. Two nearly identical provider functions — get_db and get_session — both yielding a session. Root cause: the per-request cache is keyed on the callable, and nobody checked. Symptom: two sessions per request, two transactions, and updates that do not see each other. Fix: one canonical provider imported everywhere, as covered in Dependency Caching and use_cache in FastAPI.

Blocking work on the request path. A synchronous driver or a CPU-bound loop inside an async def handler. Root cause: the code is correct and the failure is invisible at low concurrency. Symptom: latency that degrades for every request on the worker, not just the slow one. Fix: this is a runtime concern rather than a structural one — Async Correctness and Concurrency has the diagnosis and the remedies.

FAQ

What is the single decision that most shapes a FastAPI codebase? Where the application object is built. Constructing it inside a create_app() function instead of at module scope decides whether tests can build isolated instances, whether configuration can vary per environment, and whether importing a module has side effects. Almost every other pattern in this area composes through that function.

How do I know whether something belongs in middleware or in a dependency? Ask whether it must run for requests that never reach a route. Authentication that should apply to unmatched paths, request ID stamping, and CORS belong in middleware because middleware runs before routing. Anything that produces a typed value the handler consumes, or that needs to differ per route, belongs in a dependency.

Where should connection pools and HTTP clients be created? In the lifespan context manager, then attached to app.state and handed to handlers through a dependency. The factory should stay synchronous and free of I/O so tests can call it thousands of times cheaply, while lifespan owns anything that opens a socket.

Does the same dependency run twice if two of my dependencies both require it? No. FastAPI caches a dependency's result for the duration of one request when use_cache is left at its default of True and the dependency is the same callable object. Two different callables that do the same thing are two cache entries, which is the usual reason people see duplicate work.

How do I add API versioning without duplicating every route? Keep one set of domain routers and include them into version-prefixed parent routers, overriding only the handlers whose contract actually changed. Prefixes compose during include_router, so the shared routes are declared once and appear under each version you publish.

Why does my application hang or raise ImportError at startup? Almost always a circular import between a router module and the service module it imports at module scope. Depend on an abstract interface, import inside the function, or move the wiring into the factory so the two modules never import each other while loading.

Work through this area in order if you are designing a service from scratch: Application Factory Patterns and Configuration Management to decide how the app comes into existence, then Modular Router Organization and Dependency Injection Strategies for the shape of the code, then Middleware Implementation and Error Handling and Global Exceptions for the behaviour clients observe.

Read The FastAPI Request/Response Lifecycle when you need the mechanism rather than the pattern.

The two sibling areas continue from here. Advanced Pydantic Validation and Serialization takes over at the boundary where a request body becomes typed data, and Async, Background Tasks and Observability takes over at the point where the structure above has to survive real concurrency and a real incident.