Read OSS

FastAPI's Architecture: How It Extends Starlette Without Fighting It

Intermediate

Prerequisites

  • Basic ASGI protocol knowledge
  • Python class inheritance and method resolution order
  • Familiarity with using FastAPI to build APIs

FastAPI's Architecture: How It Extends Starlette Without Fighting It

FastAPI is often described as "Flask for the async world" or "Django REST Framework but faster," but these comparisons miss the most interesting thing about its architecture. FastAPI is a thin layer on top of Starlette, the ASGI toolkit. It doesn't replace HTTP handling, routing, or middleware — it extends them. Understanding this relationship is the key to understanding everything else about FastAPI, from how decorators work to how OpenAPI schemas get generated.

In this first article, we'll trace the class hierarchy from Starlette up to FastAPI, examine how the middleware stack is assembled, and see how the docs routes get wired up. This foundation will be essential for the rest of the series.

The Public API Surface

FastAPI's public interface is remarkably compact. The entire __init__.py is just 25 lines of reexports:

fastapi/__init__.py#L1-L26

Everything a user needs — FastAPI, APIRouter, Depends, Query, Path, Body, Request, Response, HTTPException — is reexported from internal modules. This is a deliberate design choice: users never need to import from fastapi.routing or fastapi.dependencies.utils. The complexity is hidden behind a clean surface.

Notice that status comes directly from Starlette — FastAPI doesn't even bother wrapping it. This philosophy of "use Starlette when Starlette is enough" runs throughout the codebase.

Tip: If you're building a library on top of FastAPI, follow the same pattern. Re-export your public symbols from __init__.py so users have a single import path.

Class Hierarchy: Starlette → FastAPI

The core of FastAPI's architecture is three inheritance chains that extend Starlette's routing classes:

classDiagram
    class Starlette {
        +middleware_stack
        +routes
        +build_middleware_stack()
        +add_route()
    }
    class FastAPI {
        +router: APIRouter
        +openapi_schema
        +dependency_overrides
        +build_middleware_stack()
        +openapi()
        +setup()
    }
    class StarletteRouter["starlette.routing.Router"] {
        +routes
        +add_route()
        +include_router()
    }
    class APIRouter {
        +dependencies
        +responses
        +callbacks
        +add_api_route()
        +include_router()
    }
    class StarletteRoute["starlette.routing.Route"] {
        +path
        +endpoint
        +methods
        +app
    }
    class APIRoute {
        +dependant: Dependant
        +response_model
        +body_field
        +response_field
    }
    Starlette <|-- FastAPI
    StarletteRouter <|-- APIRouter
    StarletteRoute <|-- APIRoute

The FastAPI class declaration is straightforward — it inherits from Starlette:

fastapi/applications.py#L41-L55

Similarly, APIRouter extends starlette.routing.Router, and APIRoute extends starlette.routing.Route:

fastapi/routing.py#L811-L843

fastapi/routing.py#L1005-L1030

This is extension, not wrapping. FastAPI doesn't contain a Starlette app; it is a Starlette app. This means every Starlette middleware, every ASGI utility, and every deployment strategy that works with Starlette works with FastAPI out of the box.

The critical addition at each layer is type-driven intelligence. APIRoute doesn't change how HTTP matching works — it adds the Dependant tree, response model validation, and body field extraction. APIRouter doesn't change how routes are collected — it adds dependency merging, tag propagation, and response aggregation.

The Internal APIRouter and Route Delegation

When you create a FastAPI() instance, the constructor creates an internal APIRouter and delegates all routing operations to it:

fastapi/applications.py#L982-L997

This is a facade pattern. When you call app.get("/items"), that method is defined on APIRouter and inherited through the chain. The FastAPI class itself is primarily concerned with three things: middleware assembly, OpenAPI schema generation, and docs route registration.

flowchart LR
    A["app.get('/items')"] --> B["APIRouter.add_api_route()"]
    B --> C["Creates APIRoute"]
    C --> D["APIRoute.__init__ builds Dependant tree"]
    D --> E["Route added to self.routes"]

The dependency_overrides_provider=self argument is particularly important — it passes the FastAPI instance itself as the authority for dependency overrides, which is how app.dependency_overrides works during testing. We'll explore that mechanism in detail in Part 7.

Middleware Stack Assembly

FastAPI overrides Starlette's build_middleware_stack() to insert one critical middleware: AsyncExitStackMiddleware. The resulting stack is assembled in a specific order:

fastapi/applications.py#L1018-L1066

sequenceDiagram
    participant Client
    participant SEM as ServerErrorMiddleware
    participant UM as User Middleware
    participant EM as ExceptionMiddleware
    participant AES as AsyncExitStackMiddleware
    participant R as Router

    Client->>SEM: Request
    SEM->>UM: Forward (catches 500s)
    UM->>EM: Forward
    EM->>AES: Forward (catches HTTPException)
    AES->>R: Forward (creates middleware exit stack)
    R->>AES: Response
    AES->>EM: Response (stack cleanup)
    EM->>UM: Response
    UM->>SEM: Response
    SEM->>Client: Response

The middleware list is constructed as a Python list that gets reversed and applied as a wrapping chain:

middleware = (
    [Middleware(ServerErrorMiddleware, ...)]
    + self.user_middleware
    + [Middleware(ExceptionMiddleware, ...),
       Middleware(AsyncExitStackMiddleware)]
)

The AsyncExitStackMiddleware itself is remarkably simple — just 18 lines:

fastapi/middleware/asyncexitstack.py#L1-L19

It creates an AsyncExitStack and stores it in the ASGI scope as fastapi_middleware_astack. This stack is used primarily for file cleanup (closing uploaded files after the request completes). The detailed comment in build_middleware_stack() explains why this middleware must live inside all user middlewares — it's about preserving contextvars context across AnyIO task group boundaries.

Tip: The placement of AsyncExitStackMiddleware inside ExceptionMiddleware is deliberate. If it lived outside user middlewares, contextvars set in dependencies would be invisible in the exit stack's cleanup scope.

The setup() Method: Wiring Docs Routes

The setup() method registers the OpenAPI JSON endpoint, Swagger UI, and ReDoc routes:

fastapi/applications.py#L1101-L1155

flowchart TD
    A["setup()"] --> B{openapi_url set?}
    B -->|Yes| C["Register /openapi.json"]
    B -->|No| Z["Done"]
    C --> D{docs_url set?}
    D -->|Yes| E["Register /docs (Swagger UI)"]
    E --> F{oauth2_redirect_url set?}
    F -->|Yes| G["Register /docs/oauth2-redirect"]
    D -->|No| H{redoc_url set?}
    F -->|No| H
    G --> H
    H -->|Yes| I["Register /redoc"]
    H -->|No| Z
    I --> Z

Note that setup() is called at the end of FastAPI.__init__() — not lazily on first request. The routes are registered eagerly, but the schema itself is generated lazily. When a request hits /openapi.json, the openapi() closure calls self.openapi(), which generates and caches the schema on first access.

The openapi endpoint handler also handles root_path — a critical ASGI feature for apps behind reverse proxies. If a root path is set in the ASGI scope and it's not already in the schema's servers list, it gets prepended.

All docs routes are registered with include_in_schema=False, which keeps them out of the generated OpenAPI spec. You wouldn't want /docs showing up as an API endpoint in your API documentation.

Directory Map

File Purpose
fastapi/__init__.py Public API surface — 25-line re-export file
fastapi/applications.py FastAPI(Starlette) class — middleware, OpenAPI, setup
fastapi/routing.py APIRoute, APIRouter, request handling pipeline
fastapi/middleware/asyncexitstack.py 18-line middleware for file cleanup exit stack
fastapi/openapi/docs.py Swagger UI and ReDoc HTML generation
fastapi/openapi/utils.py OpenAPI schema generation from routes

What Comes Next

Now that you understand how FastAPI layers on top of Starlette, the natural question is: what happens inside APIRoute.__init__() when you decorate a function with @app.get()? That's where the real magic begins — FastAPI introspects your function's signature, analyzes every parameter, and builds a dependency tree at registration time, not request time. In Part 2, we'll trace that entire flow.