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
from fastapi import APIRouter, FastAPI
# routers/modules.py
modules = APIRouter(prefix="/modules", tags=["modules"])
@modules.get("")
def list_modules(): return ["Vectors"]
@modules.get("/{i}")
def read(i: int): return {"id": i}
# routers/tracks.py
tracks = APIRouter(prefix="/tracks", tags=["tracks"])
@tracks.get("")
def list_tracks(): return ["maths"]
# main.py - assembly, and nothing else
app = FastAPI(title="VizLearn API", version="1.0.0")
app.include_router(modules)
app.include_router(tracks)
c = TestClient(app)
print(c.get("/modules").json(), c.get("/modules/7").json(), c.get("/tracks").json())
print()
for r in app.routes:
if getattr(r, "methods", None):
print(" %-6s %s" % (",".join(sorted(r.methods)), r.path))
Output
Handlers stay thin
The handler translates HTTP; the service does the work and imports nothing from the framework.
example_02.pyFastAPI
from fastapi import APIRouter, FastAPI, HTTPException
from pydantic import BaseModel, Field
# services/modules.py - no fastapi import anywhere in here
class ModuleNotFound(Exception):
def __init__(self, i): self.i = i
DB = {1: {"id": 1, "title": "Vectors"}}
def get_module(i: int) -> dict:
if i not in DB:
raise ModuleNotFound(i)
return DB[i]
def create_module(title: str) -> dict:
i = max(DB) + 1
DB[i] = {"id": i, "title": title}
return DB[i]
# schemas/modules.py
class ModuleIn(BaseModel):
title: str = Field(min_length=3)
class ModuleOut(BaseModel):
id: int
title: str
# routers/modules.py
router = APIRouter(prefix="/modules", tags=["modules"])
@router.get("/{i}", response_model=ModuleOut)
def read(i: int):
try:
return get_module(i)
except ModuleNotFound:
raise HTTPException(404, "No module %d" % i)
@router.post("", response_model=ModuleOut, status_code=201)
def create(body: ModuleIn):
return create_module(body.title)
app = FastAPI(); app.include_router(router)
c = TestClient(app)
print(c.post("/modules", json={"title": "Norms"}).json())
print(c.get("/modules/1").json())
print("missing:", c.get("/modules/99").status_code)
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
from fastapi import APIRouter, FastAPI, Request
from fastapi.responses import JSONResponse
class ModuleNotFound(Exception):
def __init__(self, i): self.i = i
class TitleTaken(Exception):
def __init__(self, t): self.t = t
DB, TITLES = {1: "Vectors"}, {"Vectors"}
def get_module(i):
if i not in DB: raise ModuleNotFound(i)
return {"id": i, "title": DB[i]}
def create_module(title):
if title in TITLES: raise TitleTaken(title)
TITLES.add(title); return {"id": 2, "title": title}
router = APIRouter(prefix="/modules")
@router.get("/{i}")
def read(i: int): return get_module(i) # no try/except
@router.post("")
def create(title: str): return create_module(title)
app = FastAPI()
app.include_router(router)
@app.exception_handler(ModuleNotFound)
async def nf(r: Request, e: ModuleNotFound):
return JSONResponse(status_code=404, content={"detail": "No module %d" % e.i})
@app.exception_handler(TitleTaken)
async def tt(r: Request, e: TitleTaken):
return JSONResponse(status_code=409, content={"detail": "%r exists" % e.t})
c = TestClient(app)
print(c.get("/modules/1").json())
print("missing :", c.get("/modules/99").status_code, c.get("/modules/99").json())
print("duplicate:", c.post("/modules?title=Vectors").status_code)
Output
Settings in one model
Configuration read once and validated, rather than os.getenv scattered through the routers.
example_04.pyFastAPI
import os
from fastapi import FastAPI
from pydantic import BaseModel, Field, ValidationError
class Settings(BaseModel):
app_name: str = "VizLearn API"
page_size: int = Field(default=20, ge=1, le=100)
debug: bool = False
def load_settings(env: dict) -> Settings:
return Settings(**{k[3:].lower(): v for k, v in env.items()
if k.startswith("VZ_")})
good = load_settings({"VZ_PAGE_SIZE": "50", "VZ_DEBUG": "yes"})
print("loaded :", good.model_dump())
try:
load_settings({"VZ_PAGE_SIZE": "5000"})
except ValidationError as e:
print("refused:", e.errors()[0]["msg"])
app = FastAPI(title=good.app_name)
@app.get("/config")
def config(): return good.model_dump()
print("served :", TestClient(app).get("/config").json())
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.
Everything in one place so the arrangement is visible: settings, dependencies, services, schemas, routers, handlers.
example_06.pyFastAPI
from fastapi import APIRouter, Depends, FastAPI, Request
from fastapi.responses import JSONResponse
from pydantic import BaseModel, Field
# --- settings ------------------------------------------------------------
class Settings(BaseModel):
page_size: int = Field(default=2, ge=1, le=100)
settings = Settings()
# --- domain --------------------------------------------------------------
class NotFound(Exception): pass
STORE = {1: "Vectors", 2: "Norms", 3: "Loops"}
def list_modules(limit): return list(STORE.items())[:limit]
def get_module(i):
if i not in STORE: raise NotFound()
return {"id": i, "title": STORE[i]}
# --- schemas -------------------------------------------------------------
class ModuleOut(BaseModel):
id: int
title: str
# --- dependencies --------------------------------------------------------
def page_limit(limit: int = None):
return limit or settings.page_size
# --- routers -------------------------------------------------------------
router = APIRouter(prefix="/modules", tags=["modules"])
@router.get("")
def index(limit: int = Depends(page_limit)):
return [{"id": i, "title": t} for i, t in list_modules(limit)]
@router.get("/{i}", response_model=ModuleOut)
def read(i: int):
return get_module(i)
# --- assembly ------------------------------------------------------------
app = FastAPI(title="VizLearn API", version="1.0.0")
app.include_router(router)
@app.exception_handler(NotFound)
async def nf(r: Request, e: NotFound):
return JSONResponse(status_code=404, content={"detail": "Not found"})
c = TestClient(app)
print(c.get("/modules").json())
print(c.get("/modules?limit=3").json())
print(c.get("/modules/2").json())
print("missing:", c.get("/modules/99").status_code)
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.
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
Organise a FastAPI project into routers, models and dependencies across files and see a layout that scales without causing circular imports.
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.