Everyone has opinions about FastAPI folder structure and most of them are vibes; the part that matters you usually decide on day one, when the app is four routes and the layout feels like the least important thing in the world. Then it’s month six, twenty domains import each other, and moving one file means a find-and-replace across the whole repo and a small prayer.
The best-practices post already made the call I’d defend hardest, organize by feature and not by file type, and I won’t relitigate it. This is everything around that: the other layouts and why they exist, what goes inside a feature folder once you’ve got one, the two rules that decide whether this ages well or becomes spaghetti with tidier names, and where clean architecture and DDD fit if you’ve heard the words and wondered whether you’re skipping homework. Roughly in the order you hit them.
One file
Every app starts as one main.py, and plenty should stay there. A demo, an internal tool with five endpoints, a throwaway: splitting that across eight folders is navigating a tree to read what fit on one screen.
You’ve outgrown it when you start scrolling to find a route, or when two people keep landing on the same file in every pull request. Then you split; how you split is the rest of this.
By file type
The first split most people reach for groups files by what they are:
app/
├── routers/ # users.py, products.py, orders.py
├── models/ # users.py, products.py, orders.py
├── schemas/ # users.py, products.py, orders.py
└── main.py
For one small domain this is not only fine but better. It’s what the official tutorial shows, it reads cleanly while routers/ has three files in it, and a microservice that owns a single concern can live like this forever.
This starts to hurt with more domains. By a dozen features, routers/ is a junk drawer of twenty unrelated files, every feature is smeared across three directories, and touching “users” means opening routers/users.py, models/users.py, and schemas/users.py in three different corners of the tree. The best-practices post has the full autopsy on why that retrofit hurts, so I’ll point you there. The short version: you’ve filed your code by Python category, but you navigate it by product domain, and those two never line up.
By feature
Put everything about a domain in one folder, and put the cross-cutting machinery in a package the domains import from:
backend/src/
├── modules/
│ ├── user/
│ │ ├── router.py # HTTP edge: paths, status codes, dependencies
│ │ ├── schemas.py # request/response contracts (Pydantic)
│ │ ├── models.py # storage shape (SQLAlchemy)
│ │ ├── service.py # business logic, no HTTP
│ │ └── crud.py # data access, no business rules
│ └── billing/
│ └── ...
├── infrastructure/
│ ├── config/
│ ├── database/
│ ├── auth/
│ ├── cache/
│ ├── storage/
│ └── observability/
├── workers/ # long-running background processes
├── api.py # mounts every module's router
└── main.py # app object and lifespan
Now a feature is a folder; you open it, read the whole thing from HTTP down to the database, and you can move it or delete it without searching for stuff all over the place. New people find code by domain, which is how they already think about the product. fastro.ai runs this in production, and I’d reach for it on day one with two endpoints if I know the project will grow, because it’s the most expensive thing here to change later and free to adopt now.
It has a name, and the name is worth knowing because it tells people what you’re doing: a modular monolith. One thing you deploy as a unit, carved up inside into modules with real separation between them. You get code organized like microservices without paying to operate microservices, which stays the right call for a long time.
Drawing that tree is one thing; whether it lasts comes down to two others: what’s inside a module, and how modules are allowed to touch each other.
Inside a module
Five files. The split is what lets the code do anything beyond answer an HTTP request.
The router is the HTTP edge and it should be boring: paths, dependencies, status codes, hand off. The moment it grows an if about a business rule, you’ve buried logic somewhere only an HTTP request can reach it.
That logic belongs in the service, the one file in here I care about: it holds what the feature does, and it doesn’t know HTTP exists: no Request, no HTTPException, no status codes. Keep it that clean and the same “create the user, send the welcome email” code runs from a route today, a Taskiq worker next month, and a test that never starts a server. Let HTTP leak in and you’ve stapled your logic to the web layer for nothing.
The crud file (repository, if that’s the word you grew up with) is queries and nothing else, no business rules; you split it off the service so you can read what a feature decides without reading how it talks to Postgres, and so there’s one obvious place to go the day you change databases.
models is your SQLAlchemy tables and schemas is your Pydantic request and response shapes. The reason to keep those two apart gets its own section in the best-practices post, so here it’s a pointer: your stored row carries a hashed_password, your API response had better not.
Most modules don’t need all five; a thin one is a router and some schemas. A fat one grows a tasks.py for background jobs and a dependencies.py for the Depends callables it reuses. The exact file list is whatever the module needs. The constant is that one domain lives in one folder, top to bottom.
The two rules that keep it from rotting
This is where a feature layout either holds or quietly turns into the file-type mess with better-looking folders. Two rules.
Infrastructure gets imported by features and never imports them back. Your config doesn’t know the billing module exists. Your database layer doesn’t import user. Flip that arrow even once and the bottom of your stack now belongs to one feature instead of the whole app, and it’s stopped being plumbing anyone can lean on. Cheap to keep, annoying to undo.
Features don’t reach into each other’s guts. The day billing imports user’s service.py, the two are married: you can’t test, move, or delete one without dragging the other along. When they really do need to talk, give them a narrow door, a function user chooses to expose, or an event it fires that billing listens for. My test for whether the boundary is real is blunt: can I delete this folder and does the rest still import? If yes, it’s real.
A shared/ package is fine on a short leash. Mixins, pagination, a couple of common dependencies. The day it’s a thousand lines it isn’t “shared,” it’s a second place everything’s tangled together, and you’ve rebuilt the problem you were avoiding.
The trap that cost me an afternoon: importing a module’s tasks or services at the top of its __init__.py. Feels tidy. Builds a cycle: the package imports the service, the service imports infrastructure, infrastructure pulls the package back in half-loaded. What you see is workers that boot, report healthy, and process exactly nothing. No error, no crash, only silence. Keep __init__.py nearly empty, import where you use the thing, and it never happens.
The rest of the tree
A worker is a second process, always on, working through what you don’t do inside a request: email, uploads, the 2am cron. Two sane homes for its code. Keep each feature’s tasks next to the feature in user/tasks.py and put only the broker wiring and the entrypoint in workers/, or pull the long-running processes into workers/ once there are enough of them that you want them in one eyeline. fastro does the latter past a certain size. The import rule doesn’t care which you pick: a task imports infrastructure and its own module, not three others.
Something has to mount the routers. One place, main.py while you’re small and an api.py once you’re not, imports each module’s router and includes it. That keeps main.py ignorant of module internals, and adding a feature stays a one-line include instead of open-heart surgery on the app object.
Tests sit where the code sits. tests/unit/ and tests/integration/ mirror the module tree, so a feature’s tests are wherever the feature is, and you can run the fast ones on save and the slow ones in CI by folder. The best-practices post has the trick for tagging each tier by location so nobody does it by hand.
A few things flat refuse to live in a module, because they don’t belong to any one feature: Alembic migrations (one migration touches every model), the compose file, the Caddyfile, the setup scripts. Step out one more level and that’s the whole repo, backend/ next to frontend/, the worker, the proxy config, the monorepo the self-hosting guide runs off a single box.
Clean architecture, hexagonal, DDD
Sooner or later someone says “DDD,” or “hexagonal,” or “clean architecture,” usually in a tone that suggests you should feel bad. Worth knowing what they mean, because the ideas are smaller than the words around them and you’re probably already doing the useful part.
Domain-driven design is barely about folders. Its real bet is that the hard part of software is the business, not the framework, so you build the code in the language the business speaks. They call that the ubiquitous language, which is a heavy name for “if the sales team says invoice, the class is Invoice, not BillingRecord.” It also hands you vocabulary worth having: an entity has an identity that sticks around (a user), a value object is nothing but its contents (a money amount, swap one for an equal one and nobody notices), an aggregate is a clump of objects you only touch through one front door. And a bounded context is a hard border around one model, so “user” in billing and “user” in auth can be different shapes without anyone reconciling them. If that one sounds familiar, it’s because a feature module is a bounded context that gave up pretending and admitted it’s a folder.
Clean architecture (and onion architecture, same family) is one rule about which way arrows point: domain in the middle, framework and database on the outside, dependencies only ever pointing inward. Your business logic never imports FastAPI or SQLAlchemy; those are details bolted on at the edge. Which is the rule from two sections ago, infrastructure depends on your domain and not the reverse, drawn as rings instead of folders.
Hexagonal (ports and adapters) is how you pull that off. The core declares ports, plain interfaces for what it needs: “something that can save an order,” “something that can charge a card.” The outside writes adapters that implement them, a Postgres one, a Stripe one. Swap the adapter and the core never notices, which is what lets you test against a fake and what would let you move off Stripe without surgery.
Strip the jargon and all three want the same two things: business logic you can run without a web server, and a database or vendor you can swap. A feature module with an HTTP-free service and a crud layer behind it already gives you both. That’s most of the prize.
The rest costs you files and indirection on every single change, and it’s worth paying when the domain is gnarly, when the team’s big enough that hard walls keep people from stepping on each other, or when an API and a CLI and a queue consumer all drive the same core and you want it beholden to none of them. Short of that, start with feature modules and let one module grow a real port the day it actually hurts. Nobody ever regretted not wrapping four abstraction layers around what is, today, a CRUD endpoint.
How to pick, and when to move
Pick feature modules for anything real, even at two endpoints. It’s free now and it’s the one thing here that’s miserable to retrofit. Stay single-file only for code you might delete next week. And move off the by-file-type layout the second a second domain shows up, because that migration is nothing at domain two and a wrecked weekend at domain twelve, and people always wait for twelve.
Split a module when it’s grown a few sub-concerns that don’t share data, the way billing eventually wants to be subscriptions and invoices and webhooks. Break a piece out into its own deployable service only when it’s got a real reason, a different scaling shape or a different deploy cadence, not because someone read that microservices are tidy; the deployment guide gets into when that’s real and when it’s premature.
The tell that you’ve outgrown whatever you’ve got is always the same feeling: the ordinary change is annoying. Adding one feature touches five folders. You go to drop in a new file and can’t tell where it goes. That’s the structure pushing back, and it’s a better signal than any line count.
None of this deserves the agonizing it gets. Folder structure is the part of a codebase you can see in a screenshot, so it’s the part everyone has feelings about, while the decisions that sink projects sit three levels down in a service nobody opened. Get the shape roughly right and go build the thing.
FastroAI comes with this already wired: a modules/ directory where each domain owns its router, schemas, models, service, and crud, an infrastructure/ package the modules import from and that never imports back, a workers setup for background jobs, and a test tree that mirrors the source. It’s a production FastAPI and Astro foundation, so these calls are made and you start on the product instead of the project layout.
References
- FastAPI: Bigger Applications (project structure)
- FastAPI: Dependencies
- SQLAlchemy 2.0: ORM
- Alembic: migrations
- FastAPI Best Practices: The Decisions That Are Cheap Now and Expensive Later
- Where to Deploy FastAPI: FastAPI Cloud, Render, DigitalOcean, and Hetzner
- Self-Hosting FastAPI on a VPS: From a Fresh Box to a Hardened Production Deploy
- FastroAI documentation