Project Structure

How the pieces are arranged once the application is more than one file - and why the assembly file should be boring.

Overview

The layout

Project structure is how you arrange your files and folders so a growing app stays easy to understand. FastAPI does not require any layout. A single file works for a demo. Once you have users, orders, and billing, though, where each piece of code lives starts to decide how quickly you can change anything.

A good structure answers a simple question: if I need to change this behavior, where do I look? When the answer is obvious, new teammates become useful faster, and reviews get shorter. When it is not, the same logic gets copied into three places and drifts apart.

This article starts with a layout that works for most projects and the reasoning behind each folder. It then argues that main.py should be boring. It creates the app, includes the routers, and does little else. Interesting logic belongs elsewhere.

The next section covers grouping by resource inside a shallow layer structure. You keep a few clear layers, such as routers, services, and schemas, but you do not let them grow deep. Then comes a rule for dividing the work: handlers translate between HTTP and your domain, and services do the real work. That separation keeps your business logic free of web concerns and easy to test.

None of this is mandatory. It is a set of defaults that tend to hold up as a project doubles in size, then doubles again.

You will not find a single correct answer here, because teams differ. What you will find is a set of trade-offs, explained plainly, along with the problems each choice tends to cause as a project grows. The article is most useful when you are about to start a new service, or when an existing one has started to feel awkward to work in. Either way, a little planning now costs far less than a large reshuffle later, when other people depend on your import paths. Each section is short and ends with a habit you can apply straight away, so you finish with a layout you can adopt and adjust.

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 use 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, checked at startup:

class Settings(BaseModel):
    page_size: int = Field(default=20, ge=1, le=100)
    debug: bool = False

Two gains over reading the setting where it's 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 all the use needs to run, in one place. This Is what a deployment checklist wants to be made from.

pydantic-settings does the setting reading properly, including .env files and nested config.

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's the one place to answer "what does this use consist of?". If it grows business logic, that answer disappears.

Two things often end up there and shouldn't. Settings belong in their own module. Scattering os.getenv makes it hopeless to see what the use 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 fair 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 other folders. Easy to find, easy to review.

Grouping by layer alone, with each router in one file, means those files grow without bound and each 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 checked input, call a thing, turn the result into a response. all 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. One exception handler maps that to a 404 at the edge.

That mapping is worth doing early. Retrofitting it means finding each HTTPException scattered through service code and deciding what each should have been, often while changing a thing 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 all optional. ModuleOut declares what may be seen. Three small classes in one file, rather than one clever class with all optional that docs nothing.

Keeping them beside each other makes the differences visible. This Is when people notice that the output model still holds a field it shouldn't.

Dependencies in one module

a few routers need the same ones. The session, the current user, pagination. A shared dependencies.py is where they're found.

Reading that file should tell you what the use's endpoints are allowed to assume. That is a truly handy summary. It's the same arg as the types.py from the Pydantic track.

When to split

Earlier than feels needed.

Create routers/ on day one, even with two endpoints. Moving three routes is trivial. Moving eighty means untangling imports and moving tests. By then it doesn't happen and the file keeps growing.

The same applies to the service boundary. Extracting the first service when there's 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 checked against a payload, settings made from a dict.

A suite that needs "the module under test" rather than "a DB, Redis and three setting vars" is the hands-on gap. It comes almost fully from where things were put.

Mistakes people make with Project Structure

Waiting to split. Moving three routes is trivial. Moving eighty is a rewrite that doesn't happen. The file keeps growing.

Business logic in routers. A router importing your ORM works and stops being testable without a DB.

**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 use needs to run. A bad value fails at request time instead of at startup.

Startup work at import. each 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 each router grows without bound and each 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 code for a string, it isn't.

That test matters more than any given setup. A layout is a guess about where people will look. 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 a thing real rather than anticipated. Each takes minutes at the moment it's prompted. The option - deferring all of them until the file is unmanageable - means doing them all at once, in a diff no one 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 alone is code whose pieces have clear boundaries. Clear boundaries are what make a layout guessable. A structure that scores well on one often scores well on the other. This Is a convenient property when deciding whether an setup is worth the move.

Project Structure: 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 split by direction. Shared dependencies in one module. Settings in one checked model read at startup. And a main.py short enough to read in a screen, doing nothing but assembly.

The value isn't tidiness - it's that each piece turns testable on its own. The suite needs the module under test rather than the whole world.

Where the track leaves you

Routing and the methods. each source of input a request has. What each is properly for. The response, its shape and its status. Errors, both auto and raised. Structure, once one file stops being enough.

The whole dependency system - declaring what an endpoint needs, composing those needs, giving them a lifetime, applying them to a section. Replacing them at the edges.

The runtime: where a handler runs and why the wrong choice is costly, work after the response, and work once per process.

And the practices: a suite that runs in seconds, a made doc 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 - DBs, deployment, observability, the working 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 hands-on detail that decides whether a layout lives.

Circular imports are the failure mode of splitting an use up. They come from the same place each time. A service importing a thing 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 hopeless. Break it once - a service that raises HTTPException, a schema that calls a service to validate - and the cycle appears later, from a direction no one expected, often while adding a thing unrelated.

The domain-exception pattern isn't only about testability. It's also what keeps the arrow pointing one way.

A closing thought

None of this layout is FastAPI-exact. None of it's new. Grouping by resource, keeping handlers thin, separating config, and assembling in a boring file are practices older than the framework.

What FastAPI adds is that following them is nearly free. A router is four lines. Inclusion is one. The docs reorganises itself around your tags without being asked. A dependency is a default arg.

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 needed.

What was left out, and why

Three subjects have no module here, on purpose.

Middleware and streaming responses need a real event loop with anyio task groups. This These pages can't provide. Writing them with code that can't run would have broken the property every other page on this track has.

WebSockets need a real link. This No amount of cleverness conjures in a browser tab.

They're real parts of the framework and worth learning from the official docs. The rest of this track runs. This Is the trade that was chosen.

Questions people ask about Project Structure

Where should I put the FastAPI instance? In main.py (or app/main.py), which creates the app, includes routers, and stays short.

How do I split a growing project? Group by resource into folders such as users/ and orders/, each with routes, schemas, and dependencies.

Where do Pydantic models go? Beside the routes that use them, in a schemas.py per resource, kept separate from database models.

Where does configuration go? In a settings class, typically pydantic-settings BaseSettings, that reads environment variables once.

How do I avoid circular imports? Keep dependencies and models in modules that do not import the routers, and import routers only in main.py.

Where do tests live? In a top-level tests/ folder that mirrors the app layout, with shared fixtures in conftest.py.

Project Structure in one line

One router per resource, services that never import the framework, schemas by direction, settings in one checked model. An assembly file short enough to read at a glance - each added the moment it's 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 blocks the circular imports that else arrive from a direction no one expected.

Check yourself

0 of 4

Answer without scrolling back up.

  1. What should `main.py` contain?

  2. How do you tell whether the service boundary is right?

  3. Why put settings in one validated model?

  4. When should you create a routers/ directory?

Cheat sheet

Project Structure

Organise a FastAPI project into routers, models and dependencies across files and see a layout that scales without causing circular imports.

FASTAPI · vizlearn.in/fastapi/project_structure.html

About the author

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.