FastAPI's Architecture: How It Extends Starlette Without Fighting It
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:
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__.pyso 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#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
AsyncExitStackMiddlewareinsideExceptionMiddlewareis 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.