How the pieces are arranged once the application is more than one file - and why the assembly file should be boring.
Overview
The layout
Nothing here is clever, and that is the point.
app/
main.py # create the app, register handlers, include routers
settings.py # one validated model
dependencies.py # what several routers share
routers/
modules.py
tracks.py
schemas/
modules.py # ModuleCreate, ModuleUpdate, ModuleOut
services/
modules.py # the work; imports nothing from fastapi
The test of a layout is whether somebody asked to add a field can guess which files to open. If the answer is "search for the word", the structure has stopped helping.
Worth knowing
One router per resource, in its own file. Create the directory on day one, not at the eightieth endpoint.
Handlers translate HTTP and call a service. The service imports nothing from FastAPI, so it works from a job, a CLI or a test.
Domain exceptions raised low and mapped to statuses by one handler keeps HTTP out of the business logic entirely.
Settings belong in one validated model read once, not os.getenv scattered through routers.
Shared dependencies live in one module, because several routers need the same ones.
main.py should be boring: create the app, register handlers, include routers, add middleware. Short enough to read in one screen.
Project Structure
How the pieces are arranged once the application is more than one file.
A router per resource
The unit that scales. Each file owns one resource's paths and nothing else.
example_01.pyFastAPI
Output
Handlers stay thin
The handler translates HTTP; the service does the work and imports nothing from the framework.
example_02.pyFastAPI
Output
Domain errors mapped in one place
Better than the try/except above: the service raises, and one handler decides the status.
example_03.pyFastAPI
Output
Settings in one model
Configuration read once and validated, rather than os.getenv scattered through the routers.
example_04.pyFastAPI
Output
A Pydantic model, read once, validated at startup:
class Settings(BaseModel):
page_size: int = Field(default=20, ge=1, le=100)
debug: bool = False
Two benefits over reading the environment where it is needed. A missing or invalid value fails at startup with a message naming the field, rather than at request time in a handler. And the model is a list of everything the application needs to run, in one place, which is what a deployment checklist wants to be generated from.
pydantic-settings does the environment reading properly, including .env files and nested configuration.
Dependencies shared across routers
One module holding what several routers need, so the same rule is not written twice.
example_05.pyFastAPI
Output
The whole shape, assembled
Everything in one place so the arrangement is visible: settings, dependencies, services, schemas, routers, handlers.
example_06.pyFastAPI
Output
main.py should be boring
It does four things: create the FastAPI instance, register exception handlers, include routers, add middleware.
If it fits on a screen, it is the one place to answer "what does this application consist of?". If it grows business logic, that answer disappears.
Two things commonly end up there and should not. Settings belong in their own module, because scattering os.getenv makes it impossible to see what the application needs to run. And startup work belongs in a lifespan rather than at import, because work done at import happens when a test collector imports the module.
Group by resource, inside a shallow layer structure
Both options look reasonable and one degrades.
Grouping by resource — routers/modules.py, services/modules.py, schemas/modules.py — means a change to one concept touches files with the same name in different directories. Easy to find, easy to review.
Grouping by layer alone, with every router in one file, means those files grow without bound and every change collides with every other.
Resource-first inside a shallow layer structure is what stays navigable. Two levels is plenty; deep package trees make imports long and tell you nothing extra.
Handlers translate, services do
The most valuable boundary in the whole layout.
A handler's job is HTTP: take validated input, call something, turn the result into a response. Everything else belongs in a service that knows nothing about the framework.
The test is whether the service imports fastapi. If it raises HTTPException, it can only be used from a request — not from a background job, a management command, a scheduled task or a test. If it raises ModuleNotFound, it can be used from all of them, and one exception handler maps that to a 404 at the edge.
That mapping is worth doing early. Retrofitting it means finding every HTTPException scattered through service code and deciding what each should have been, usually while changing something else.
Schemas by direction
From the response-model module, and it belongs in the layout too.
ModuleCreate takes what a caller may supply. ModuleUpdate has everything optional. ModuleOut declares what may be seen. Three small classes in one file, rather than one clever class with everything optional that documents nothing.
Keeping them beside each other makes the differences visible, which is when people notice that the output model still contains a field it should not.
Dependencies in one module
Several routers need the same ones — the session, the current user, pagination. A shared dependencies.py is where they are found.
Reading that file should tell you what the application's endpoints are allowed to assume. That is a genuinely useful summary, and it is the same argument as the types.py from the Pydantic track.
When to split
Earlier than feels necessary.
Create routers/ on day one, even with two endpoints. Moving three routes is trivial; moving eighty means untangling imports and moving tests, and by then it does not happen and the file keeps growing.
The same applies to the service boundary. Extracting the first service when there is one function is a two-minute job. Extracting the twentieth from handlers that have grown around them is a rewrite.
What this buys
Each piece testable on its own: a router included into a small app, a service called as a plain function, a schema validated against a payload, settings constructed from a dict.
A suite that needs "the module under test" rather than "a database, Redis and three environment variables" is the practical difference, and it comes almost entirely from where things were put.
Mistakes people make
Waiting to split. Moving three routes is trivial; moving eighty is a rewrite that does not happen, so the file keeps growing.
Business logic in routers. A router importing your ORM works and stops being testable without a database.
HTTPException in services. The service can then only run inside a request - not from a job, a CLI or a test.
os.getenv scattered about. Nothing then says what the application needs to run, and a bad value fails at request time instead of at startup.
Startup work at import. Every test collector and linter pays for it.
Deep package trees. Two levels is plenty. Long import paths tell you nothing extra.
Grouping only by layer. One file with every router grows without bound and every change collides with every other.
The test of a layout
Ask somebody new to add a field to one resource and watch what they do.
If they open schemas/modules.py, services/modules.py and routers/modules.py, the structure is working. If they search the codebase for a string, it is not.
That test matters more than any particular arrangement. A layout is a guess about where people will look, and the only evidence is whether they find it.
Growing into it
No project should start with the full layout, and none should wait for it.
Day one: main.py and routers/. Two files, one router, room to grow.
When a handler grows past a few lines: extract the service. That is the boundary worth defending earliest, because everything else follows from it.
When two routers need the same thing: dependencies.py.
When a model appears in two places: schemas/.
When configuration appears in two places: settings.py.
Each step is prompted by something real rather than anticipated, and each takes minutes at the moment it is prompted. The alternative - deferring all of them until the file is unmanageable - means doing them all at once, in a diff nobody can review.
What the layout is for
Not tidiness. Testability, and the ability for somebody new to guess where things are.
Those two are related: code that can be tested in isolation is code whose pieces have clear boundaries, and clear boundaries are what make a layout guessable. A structure that scores well on one usually scores well on the other, which is a convenient property when deciding whether an arrangement is worth the move.
Summary
One router per resource in its own file, created on day one. Handlers translate HTTP and call services that import nothing from the framework, raising domain exceptions that one handler maps to statuses.
Schemas separated by direction. Shared dependencies in one module. Settings in one validated model read at startup. And a main.py short enough to read in a screen, doing nothing but assembly.
The value is not tidiness - it is that each piece becomes testable on its own, so the suite needs the module under test rather than the whole world.
Where the track leaves you
Routing and the methods. Every source of input a request has, and what each is properly for. The response, its shape and its status. Errors, both automatic and raised. Structure, once one file stops being enough.
The whole dependency system - declaring what an endpoint needs, composing those requirements, giving them a lifetime, applying them to a section, and replacing them at the edges.
The runtime: where a handler runs and why the wrong choice is expensive, work after the response, and work once per process.
And the practices: a suite that runs in seconds, a generated document consumers can build against, and the shape of authentication.
That is enough to build and maintain a real API. What is left is largely not FastAPI - databases, deployment, observability, the operational parts - and each is easier once the layer underneath is arranged so it can be reasoned about a piece at a time.
A note on imports
One practical detail that decides whether a layout survives.
Circular imports are the failure mode of splitting an application up, and they come from the same place every time: a service importing something from a router, or a schema importing a service.
The dependency direction should be one way. Routers import schemas and services. Services import schemas. Schemas import nothing of yours. Dependencies import schemas and services.
Follow that and cycles are impossible. Break it once - a service that raises HTTPException, a schema that calls a service to validate - and the cycle appears later, from a direction nobody expected, usually while adding something unrelated.
The domain-exception pattern is not only about testability. It is also what keeps the arrow pointing one way.
A closing thought
None of this layout is FastAPI-specific, and none of it is new. Grouping by resource, keeping handlers thin, separating configuration, and assembling in a boring file are practices older than the framework.
What FastAPI contributes is that following them is nearly free. A router is four lines. Inclusion is one. The documentation reorganises itself around your tags without being asked. A dependency is a default argument.
When good structure costs almost nothing, the reason not to have it stops being effort and starts being habit - which is why the advice throughout this module is to do each piece earlier than it feels necessary.
What was left out, and why
Three subjects have no module here, deliberately.
Middleware and streaming responses need a real event loop with anyio task groups, which these pages cannot provide. Writing them with code that cannot run would have broken the property every other page on this track has.
WebSockets need a real connection, which no amount of cleverness conjures in a browser tab.
They are real parts of the framework and worth learning from the official documentation. The rest of this track runs, which is the trade that was chosen.
In one line
One router per resource, services that never import the framework, schemas by direction, settings in one validated model, and an assembly file short enough to read at a glance - each introduced the moment it is prompted rather than once the file has become unmanageable.
The arrow points one way - routers to services to schemas - and keeping it pointing that way is what prevents the circular imports that otherwise arrive from a direction nobody expected.
Check yourself
0 of 4
Answer without scrolling back up.
What should `main.py` contain?
If it fits on a screen it answers "what does this application consist of?". Growing logic there destroys that.
How do you tell whether the service boundary is right?
A service raising HTTPException can only run inside a request. One raising a domain exception works from a job, a CLI or a test, with one handler mapping it at the edge.
Why put settings in one validated model?
Scattered `os.getenv` fails at request time inside a handler, and leaves no single place that says what the application needs to run.
When should you create a routers/ directory?
Moving three routes is trivial and moving eighty is a rewrite that does not happen - so the file keeps growing instead.
Cheat sheet
Project Structure
The test of a layout is whether somebody asked to add a field can guess which files to open. If the answer is "search for the word", the structure has stopped helping.
Ashish Jangra builds and maintains VizLearn. Every module here is written and the visualisation behind it hand-built, so the numbers in a readout come from the same code that draws the picture. Corrections are genuinely welcome and get priority over everything else — if a page states something wrong, or an animation misrepresents what the algorithm does, get in touch.