Modular Router Organization in FastAPI
Modular router organization is the practice of declaring endpoints on focused APIRouter objects and composing them at one assembly point, rather than attaching every operation to a single application object. It is what keeps the URL structure, the generated documentation and the ownership boundaries of a growing codebase in agreement.
This is the routing half of Core Architecture and Routing Patterns. Composition happens in the application factory, router-level gates are attached through dependency injection, and the shape of what you compose is what a client sees in the published schema. This page covers the composition rules themselves — which are less uniform than they look — and the structural decisions that follow from them. Each specific manoeuvre has its own guide.
Prerequisites
You need a service with more than one APIRouter and some familiarity with the generated OpenAPI document. Everything below was executed on FastAPI 0.139.2 and Python 3.12. Note that internal routing structures changed in recent releases, so behaviour asserted here is stated for that version rather than in general.
Core mechanics: three attributes, three combination rules
An APIRouter carries a prefix, a list of tags, and a list of dependencies, and every one of those can also be supplied at the include_router call and on the individual operation. It is natural to assume they all behave alike. They do not, and the differences show up in the generated schema rather than in your source, which is why they are easy to get wrong.
This example nests a domain router inside a version router inside an application, with every level contributing all three attributes. The route reports what routing actually resolved, read from request.scope["route"], and the published document is read back afterwards:
invoices = APIRouter(prefix="/invoices", tags=["billing"], dependencies=[Depends(domain_dep)])
@invoices.get("/{invoice_id}", tags=["invoices"], dependencies=[Depends(route_dep)])
async def get_invoice(invoice_id: int, request: Request) -> dict[str, Any]:
route = request.scope["route"]
return {
"composed_path": route.path,
"composed_tags": list(route.tags),
"operation_id": route.operation_id or route.unique_id,
"dependencies_that_ran_in_order": list(RAN),
}
v1 = APIRouter(prefix="/v1", tags=["v1"], dependencies=[Depends(version_dep)])
v1.include_router(invoices)
service = FastAPI(title="Billing API", dependencies=[Depends(app_dep)])
service.include_router(v1)
The recorded run:
$ GET /composed
200 OK
{
"resolved_at_request_time": {
"composed_path": "/invoices/{invoice_id}",
"composed_tags": [
"billing",
"invoices"
],
"operation_id": "get_invoice_invoices__invoice_id__get",
"dependencies_that_ran_in_order": [
"app_dep",
"version_dep",
"domain_dep",
"route_dep"
]
},
"paths_in_published_schema": [
"/v1/invoices/{invoice_id}"
],
"tags_in_published_schema": [
"v1",
"billing",
"invoices"
]
}
Read that carefully, because four separate rules are visible.
Prefixes concatenate into one path. The published path is /v1/invoices/{invoice_id} — the outer prefix, then the inner prefix, then the operation's own path. This is why a version prefix supplied at the composition point costs a domain router nothing.
Tags merge rather than replace. All three levels survive into ["v1", "billing", "invoices"], outermost first. This is the mechanism behind a service whose documentation sprouts far more headings than anyone intended: every level that helpfully adds a tag adds it to every operation beneath. Choosing one level and holding to it is the practical fix, and what those tags then do to generated client SDKs is covered in router tags and OpenAPI grouping.
Dependencies accumulate and run outermost-first. Application, then version, then domain, then operation. A gate attached at the version level covers every domain beneath it without appearing in any handler signature.
The route object's own path is local, not composed. route.path reports /invoices/{invoice_id} — without the /v1 that the enclosing router supplies. This matters far beyond curiosity: if you label metrics or structured log fields with route.path, two versions of the same operation report under one identical label and their measurements silently merge. Derive route labels from the published schema, or from scope["root_path"] combined with the route path, rather than from the route object alone.
Production implementation: registration order decides reachability
The second mechanic worth internalising is that path matching is a linear scan in registration order, with the first match winning. Path templates are not scored for specificity, so an overlap is resolved by whichever router you included first.
@me_router.get("/users/me")
async def read_me() -> dict[str, str]:
return {"handler": "read_me", "user": "the caller"}
@by_id_router.get("/users/{user_id}")
async def read_user(user_id: str) -> dict[str, str]:
return {"handler": "read_user", "user_id": user_id}
literal_first = build(me_router, by_id_router)
parameterised_first = build(by_id_router, me_router)
The same two operations, included in opposite orders, behave differently:
$ GET /literal-registered-first
200 OK
{
"registration_order": [],
"GET /users/me": {
"handler": "read_me",
"user": "the caller"
},
"GET /users/u-99": {
"handler": "read_user",
"user_id": "u-99"
}
}
$ GET /parameterised-registered-first
200 OK
{
"registration_order": [],
"GET /users/me": {
"handler": "read_user",
"user_id": "me"
},
"GET /users/u-99": {
"handler": "read_user",
"user_id": "u-99"
}
}
In the second arrangement /users/me never reaches its handler; it is absorbed as a user identifier of "me". Nothing warns you. The operation is still in the documentation, still passes an import check, and returns a plausible 200 — often a 404 from a lookup for a user called me, which sends people hunting through the database rather than the route table.
Two habits prevent it. Register routers carrying literal segments before routers carrying parameterised ones at the same depth, and add a test that requests each literal path and asserts the responding handler is the intended one. Where the overlap is between whole subtrees rather than single paths, that is usually a sign the two concerns want different prefixes.
Deciding what becomes a router
The composition rules tell you how routers combine; they do not tell you how many to have. That decision is worth making deliberately, because it is expensive to revisit once client URLs exist.
The productive unit is a bounded area of the business that one group of people can own end to end — users, orders, billing, notifications. A router drawn on that line has a natural prefix, a natural tag, a natural place for its schemas and service functions to live beside it, and a natural answer to who reviews a change to it. Crucially it also has a plausible reason to import nothing from its siblings, which is what keeps the import graph a tree rather than a web.
Two other splits look tidy and are not. Splitting by HTTP method, so that reads live in one module and writes in another, separates code that changes together and forces every feature change into two files while making the URL space unreadable. Splitting by file length is not a split at all — it produces module names like routes_2 and moves the problem rather than solving it. If a domain router has genuinely become unwieldy, the signal is that it contains more than one domain, and the fix is to find the seam rather than to cut at an arbitrary line count.
Depth deserves restraint. Nesting routers three or four levels deep to mirror a directory tree makes the final path of any operation impossible to determine from its declaration, since you must trace every enclosing include to know it. Two levels — a version router composing domain routers — covers almost every service, with a third level only where a domain genuinely contains sub-resources that clients treat as separate.
Finally, remember that the router boundary and the trust boundary should coincide wherever you can arrange it. Because router-level dependencies cover everything beneath them, a router that mixes authenticated and unauthenticated operations forces you to gate at the operation level and remember to do it every time. Two routers with different gates need no vigilance at all.
Structuring the composition point
Everything above argues for one assembly point. A domain module should declare its router with the prefix and tags that describe the domain itself, and nothing about where it will live in a URL space. The factory then decides versions, mounts and gates.
That discipline is what makes the import graph tractable. Domains import shared infrastructure; the factory imports domains; nothing else imports across. Once that direction holds, a circular import between a router and a service becomes structurally impossible rather than merely discouraged — and where it has already happened, the repair is in fixing FastAPI dependency injection circular imports. The full package layout, and a check that fails the build when one domain reaches into another, is worked through in how to structure large FastAPI projects for scale.
Versioning falls out of the same arrangement. Because prefixes concatenate at the composition point, running two versions side by side is a matter of including the same domain routers under two version routers, or including separate ones where the contract diverged. The part that needs real care is not routing but response models, since a shared base model edited for a new version silently changes the old one — the discipline for that is in versioning APIs with routers.
Mounting is the option to reach for last. A mounted application is a separate ASGI application that the parent delegates to, which means it publishes its own OpenAPI document and resolves exceptions through its own registry — so your standard error envelope is absent there unless you register the handlers again. Note that middleware does not work the way the folklore claims: parent middleware is registered outside the router and therefore still wraps a mount, so tracing and CORS keep functioning across the boundary. The measured details of what does and does not cross are in APIRouter prefix vs sub-application mounting.
One more thing the composition point owns is the URL space itself, and it is worth designing rather than accumulating. Paths should name resources rather than actions, use plural collection segments consistently, and put identifiers where a client can construct them mechanically. The reason this belongs here rather than in individual domain modules is that consistency is only visible from the assembly point: each domain author will make a locally reasonable choice, and it is only when the routers are composed that /users/{id}/orders sitting beside /order-list/{userId} becomes obvious. Reviewing the composed path list — which the generated document gives you for free — catches that before clients depend on it.
Async and performance notes
Composition is a startup cost, not a request cost. Routers are assembled once, dependency trees are built once, and the OpenAPI document is generated on first request and then cached on the application, so adding routers does not slow individual requests in any way you will measure.
Matching is a linear scan over compiled patterns, so path count affects match time in principle. In practice a service would need an implausible number of operations before this registered against handler work, and the correct response would be reorganising URL space rather than micro-optimising. The genuine per-request cost introduced by router structure is dependencies: a gate attached at the version level runs for everything beneath it, so an expensive provider placed high in the composition is multiplied across the whole subtree.
Schema generation is the one place structure has a measurable cost, and it is paid on first request unless you warm it. Deeply nested response models rebuilt per version make that first request noticeably slower than the rest; calling app.openapi() during startup moves the cost off the critical path.
Testing strategy
Test a router in isolation by including it into a bare application. The surface under test stays small, and the test does not break when a sibling domain changes:
def test_users_router_in_isolation():
app = FastAPI()
app.include_router(users.router, prefix="/v1")
client = TestClient(app)
assert client.get("/v1/users/1").status_code == 200
Beyond per-router tests, three assertions about the composed application repay their cost. Assert that every registered operation carries the version prefix you expect, which catches both a router included without one and a router included twice under different versions. Assert that literal paths resolve to their own handlers, which is the shadowing guard from the previous section. And snapshot the set of paths and operation identifiers in the generated document, comparing it in continuous integration, so that a rename that would break every generated client shows up as a failing test rather than as a support ticket.
Failure modes and diagnosis
A literal route returns data for a resource named after it. Registration order put a parameterised path first. Reorder the includes and add the guard test.
Paths acquire a doubled version segment. The version prefix exists both on the domain router and on the include_router call. Remove it from the domain router; that level should not know about versions.
Documentation shows far more groups than you have domains. Tags are merging from several levels. Pick one level — the router constructor is the usual choice — and remove tags elsewhere.
Two routes collide in the schema with the same operation identifier. Two operations share a function name and path shape. Set an explicit operation_id on public routes so the value is chosen rather than derived.
Metrics from two versions merged into one series. The label came from the route object's local path, which excludes enclosing prefixes. Build the label from the composed path instead.
A mounted application returns raw errors. Exception handlers were registered only on the parent. Register them on the sub-application too, or reconsider whether that section needed to be mounted at all.
Startup hangs or raises about a partially initialized module. Composition is happening at import time across domains. Move assembly into the factory so the import direction runs one way.
Composition versus mounting
include_router | app.mount | |
|---|---|---|
| Published OpenAPI document | Shared with the parent | Its own, separate |
| Exception handlers | Parent's apply | Must be registered again |
| Parent middleware | Applies | Still applies |
| Lifespan | The parent's runs | Not run for the mounted app |
| Path prefixes | Concatenate | Rewritten into the scope |
| Router-level dependencies from the parent | Apply | Do not apply |
| Right for | Anything in one cohesive API | Separate documents, WSGI or third-party apps |
FAQ
How should I decide what becomes its own router? Split by domain, not by HTTP verb or file length. One router per bounded area that a team can own end to end, with its own prefix and tags, keeps the URL structure, the generated documentation and the import graph aligned with how the code is actually maintained.
Do prefixes, tags and dependencies all combine the same way when routers nest? No, and this trips people up. Prefixes concatenate outermost-first into one path, tags merge into a list with the outermost level first, and dependencies accumulate and execute outermost-first. Only prefixes produce a single combined value.
Why does my route return the wrong handler?
Routes are matched by scanning in registration order and the first match wins, so a literal path such as /users/me registered after /users/{user_id} is unreachable. Include the router carrying literal paths before the one carrying parameterised paths.
When should I mount a sub-application rather than include a router?
Mount only when you need a genuinely separate published OpenAPI document, a different middleware stack, or a third-party ASGI or WSGI application. For anything that belongs to one cohesive API, include_router keeps a single schema and a single error contract.
Does a mounted sub-application inherit the parent's exception handlers? No. Each application resolves exceptions through its own handler registry, so a domain error raised inside a mounted app will not produce your standard envelope unless you register the handlers on that app too.
Where should the version prefix live?
On the include_router call in the application factory, never inside a domain router. Keeping it at the composition point means a domain module has no opinion about which versions it appears under, and adding a version does not require editing every router.
Related reading
- Up a level to Core Architecture and Routing Patterns for how routing sits beside configuration, middleware and error handling.
- How to structure large FastAPI projects for scale gives the package layout and an executable check on import direction.
- APIRouter prefix vs sub-application mounting measures exactly what a mount does and does not inherit.
- Versioning APIs with routers covers running two versions together and keeping each contract honest.
- Router tags and OpenAPI grouping covers group metadata, operation identifiers and generated client SDKs.
- Application factory patterns is where all of this composition should happen.