Every FastAPI project starts the same way: one main.py file with a handful of routes, a Pydantic model or two, and a database call inline in the handler. It works. It’s fast to write, fast to read, and for the first few weeks it feels like proof that you don’t need to overthink structure. Then the project grows — more routes, more models, a background job, an auth dependency shared across six endpoints — and that single file becomes a 2,000-line scroll where finding anything means grepping for a function name and hoping.
The mistake isn’t writing a flat main.py early on. It’s not having a plan for the moment it stops working. Most teams either wait too long and do a panicked big-bang rewrite, or overcorrect on day one with a folder structure copied from a Java enterprise app that adds five layers of indirection to a project with three endpoints. Neither extreme is what actually survives growth. What works is a structure you can start simple and evolve deliberately — one that separates concerns before you’re forced to, and that sidesteps the circular import errors that show up right when the codebase is too big to easily untangle them.
You’ll learn:
- When a flat
main.pystructure actually breaks down, and the concrete signs to watch for - The real difference between a layered (technical) layout and a domain-driven (feature-based) layout, and which one fits which team
- How to compose routers with
APIRouterso route registration doesn’t become its own maintenance burden - Why FastAPI projects get circular imports specifically, and the import direction rules that prevent them
- A complete, opinionated folder structure you can copy directly into a new project
- How to migrate an existing flat app into a scalable structure without a full rewrite
Table of Contents
- The Basics
- Layered vs Domain-Driven Layouts
- Router Composition with APIRouter
- Why Circular Imports Happen in FastAPI Apps
- A Structure That Scales
- Shared Code: Core, Schemas, and Dependencies
- Migrating an Existing Flat App
- Common Pitfalls
- Production Best Practices
- Where to Go From Here
The Basics
The Signs a Flat Structure Is Breaking
A single-file FastAPI app isn’t wrong at small scale — it’s the right amount of structure for the size of the problem. The signal to change isn’t a line count, it’s friction: you open main.py to add one endpoint and have to scroll past fifteen unrelated ones to find where it belongs; two people editing the same file generate merge conflicts on every PR; a Pydantic model used by three routes gets copy-pasted instead of imported because nobody can quickly tell where the “real” version lives.
None of these are architecture failures in the abstract sense — they’re navigation failures. The fix isn’t a framework, it’s putting related code next to related code and giving imports a direction that doesn’t loop back on itself.
Why FastAPI Specifically Needs a Plan
FastAPI doesn’t impose a project structure the way Django does with apps, and that flexibility is a double-edged sword. It means you can build a structure that fits your team, but it also means there’s no guardrail stopping a routers folder from importing from services, which imports from routers again to reuse a helper — a mistake Django’s app boundaries make harder to reach for on day one. A large FastAPI application is really just a large Python application with a web layer bolted on, so it inherits every Python packaging problem, plus FastAPI’s own dependency injection system, which has structural opinions of its own about where Depends callables should live.
Layered vs Domain-Driven Layouts
There are two dominant ways to organize a growing FastAPI codebase, and the right one depends less on project size than on how your team actually works day to day.
Layered (Technical) Structure
A layered structure groups files by what they are — all routers together, all schemas together, all database models together:
app/
├── routers/
│ ├── users.py
│ ├── orders.py
│ └── payments.py
├── schemas/
│ ├── users.py
│ ├── orders.py
│ └── payments.py
├── models/
│ ├── users.py
│ ├── orders.py
│ └── payments.py
├── services/
│ ├── users.py
│ ├── orders.py
│ └── payments.py
└── main.py
This is the structure most FastAPI tutorials teach, and it works well for small-to-medium APIs, especially when one or two engineers touch most of the codebase. The downside shows up as the app grows: adding one feature — say, a refunds capability — means touching four or five different top-level folders for a single piece of functionality, and it’s easy to add a schema without its matching router, or a router that quietly skips the service layer and talks to the database directly.
Domain-Driven (Feature-Based) Structure
A domain-driven structure groups files by what they do for the business — everything related to orders lives together, regardless of whether it’s a router, schema, or service:
app/
├── users/
│ ├── router.py
│ ├── schemas.py
│ ├── models.py
│ ├── service.py
│ └── dependencies.py
├── orders/
│ ├── router.py
│ ├── schemas.py
│ ├── models.py
│ ├── service.py
│ └── dependencies.py
├── payments/
│ ├── router.py
│ ├── schemas.py
│ ├── models.py
│ ├── service.py
│ └── dependencies.py
└── main.py
This scales better for larger teams and larger domains, because each folder is close to a self-contained unit — a new engineer working on orders rarely needs to open payments at all, and the folder is a natural boundary for code ownership. The tradeoff is more upfront ceremony for genuinely small apps, and some duplication when two domains need very similar schemas.
Rule of thumb: start layered if you have under roughly ten routes and one or two contributors. Once you cross that, or once a single feature routinely spans four unrelated folders, switch to domain-driven. Netflix’s public engineering write-ups and most large-scale FastAPI references converge on domain-driven for exactly this reason — it’s the layout that keeps merge conflicts and cross-team stepping-on-toes down as headcount grows, even though it costs a bit more structure on day one.
Router Composition with APIRouter
Whichever layout you choose, the mechanism for wiring routes together is the same: APIRouter. Each feature module defines its own router instead of registering routes directly on the global FastAPI() app:
# app/orders/router.py
from fastapi import APIRouter, Depends
from app.orders.schemas import OrderOut, OrderCreate
from app.orders.service import create_order, get_order
from app.core.dependencies import get_current_user
router = APIRouter(prefix="/orders", tags=["orders"])
@router.post("/", response_model=OrderOut)
async def create_order_endpoint(
payload: OrderCreate,
user=Depends(get_current_user),
):
return await create_order(payload, user)
@router.get("/{order_id}", response_model=OrderOut)
async def read_order(order_id: int, user=Depends(get_current_user)):
return await get_order(order_id, user)
Then a single place — usually app/api.py or app/main.py — composes every feature router into the app:
# app/api.py
from fastapi import APIRouter
from app.users.router import router as users_router
from app.orders.router import router as orders_router
from app.payments.router import router as payments_router
api_router = APIRouter()
api_router.include_router(users_router)
api_router.include_router(orders_router)
api_router.include_router(payments_router)
# app/main.py
from fastapi import FastAPI
from app.api import api_router
app = FastAPI(title="My API")
app.include_router(api_router, prefix="/api/v1")
This gives you three things a flat app doesn’t have for free: a single place to see every registered route group, a natural spot to version the API (/api/v1, /api/v2 as separate APIRouter trees), and per-router tags and prefix so OpenAPI docs stay organized without per-route boilerplate. It’s also the structural foundation the rest of this cluster builds on — the dependencies you attach to a router here are exactly what we dig into in FastAPI Dependency Injection: Patterns and Anti-Patterns.
Why Circular Imports Happen in FastAPI Apps
Circular imports are the single most common structural bug in growing FastAPI apps, and they follow a predictable pattern: a router imports a service, the service imports a schema, and — because someone wanted to reuse a validation helper — the schema imports something from the router module. Python raises ImportError: cannot import name 'X' from partially initialized module and the fix looks unrelated to the actual cause.
The real cause is almost always a missing direction rule. In a well-structured FastAPI app, imports should only flow one way:
router → service → models/schemas → core
A router can import a service. A service can import models and schemas. Nothing in models, schemas, or core should ever import from a router or a service — those are the top of the dependency graph, not the bottom. When that rule breaks, it’s usually because a schema needs a type that’s defined near a router, or two domains need to share a helper and someone imports directly across domains instead of promoting the shared thing into core.
Three concrete fixes cover almost every real case:
- Promote genuinely shared code into
coreor a dedicatedsharedpackage. Ifordersandpaymentsboth need aMoneytype, it doesn’t belong in either domain — it belongs inapp/core/types.py, and both domains import it downward. - Use
TYPE_CHECKINGfor type-only cross-references. If a schema needs a type hint from another module purely for annotations, guard the import so it doesn’t execute at runtime:
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from app.orders.models import Order
def summarize(order: "Order") -> str:
...
- Break the cycle with a local import inside the function, as a last resort — it works, but it’s a sign the module boundary is wrong, not a pattern to reach for by default.
The layered vs domain-driven decision matters here too: a domain-driven layout makes cross-domain imports visually obvious (from app.payments.service import x inside app/orders/), which makes it much easier to notice — and question — when a domain boundary is being crossed than a layered structure where everything already lives in shared top-level folders.
A Structure That Scales
For a medium-to-large FastAPI application, this is a structure that holds up well in practice, combining a domain-driven core with a small layered core package for genuinely cross-cutting concerns:
app/
├── main.py # creates the FastAPI() instance, mounts api_router
├── api.py # top-level APIRouter composing all domain routers
├── config.py # Settings via pydantic-settings, loaded once
├── core/
│ ├── database.py # engine, session factory
│ ├── security.py # JWT/password hashing helpers
│ ├── dependencies.py # get_current_user, get_db, shared Depends
│ ├── exceptions.py # custom exception classes + handlers
│ └── logging.py
├── users/
│ ├── router.py
│ ├── schemas.py
│ ├── models.py
│ ├── service.py
│ └── dependencies.py
├── orders/
│ ├── router.py
│ ├── schemas.py
│ ├── models.py
│ ├── service.py
│ └── dependencies.py
└── tests/
├── users/
└── orders/
Every domain folder is self-contained and importable in isolation — you could delete orders/ entirely and users/ would still function. core sits underneath every domain and never imports from one, which is what makes the whole graph acyclic. This is the layout referenced throughout the rest of this cluster: the get_db dependency in core/dependencies.py is where the sessions discussed in Async SQLAlchemy Sessions in FastAPI, Done Right get wired in, and core/dependencies.py is also where you’ll put the shared auth dependency instead of duplicating it per router.
Shared Code: Core, Schemas, and Dependencies
core deserves a specific rule: it’s for code that has no opinion about any single domain. A database session factory doesn’t know or care whether it’s serving orders or users — it belongs in core. A get_current_user dependency is used everywhere but implemented once — it belongs in core. Contrast that with something like OrderStatus, an enum that’s meaningless outside the orders domain — that stays in orders/models.py, not core, even though it might feel “shared” because multiple functions reference it.
A common anti-pattern is a schemas.py that grows into a dumping ground for every Pydantic model in the app, including ones that are really domain-specific. If a schema is only ever used by one domain’s router and service, it belongs inside that domain’s folder — moving it to a global schemas/ package doesn’t make it more reusable, it just makes it harder to find.
# app/core/dependencies.py
from fastapi import Depends, HTTPException, status
from app.core.database import get_db
from app.core.security import decode_token
async def get_current_user(token: str = Depends(decode_token), db=Depends(get_db)):
user = await db.get_user_by_token(token)
if user is None:
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "Invalid credentials")
return user
Every domain router imports get_current_user from core, never from another domain’s dependencies.py — that’s the one-way flow that keeps the import graph a tree instead of a web.
Migrating an Existing Flat App
Rewriting a working flat main.py into a full domain-driven structure in one pull request is a good way to introduce regressions and stall the migration halfway. A safer path:
- Create
app/core/first and move the database session, config, and shared auth dependency into it — nothing else changes yet, and the app still runs. - Pick the busiest domain (usually the one with the most routes or the most recent bugs) and extract it into its own folder — router, schemas, models, service — updating imports as you go.
- Update
main.pytoinclude_routerthe newly extracted domain alongside the still-flat remainder. - Repeat per domain, one PR at a time, so each change is reviewable and the app is deployable after every step.
- Delete the flat file last, once every route has been moved into a domain folder and
main.pyonly wires things together.
This mirrors how most real teams actually do it — incrementally, behind working tests, rather than as a big-bang rewrite.
Common Pitfalls
Mistake: routers importing from other routers. This almost always means a shared dependency was duplicated instead of promoted to core. Solution: if two routers need the same thing, it belongs in core, not in either router.
Mistake: business logic living inside route handlers. A handler that queries the database, applies business rules, and formats a response makes the logic untestable without spinning up the whole HTTP layer. Solution: keep handlers thin — parse input, call a service function, return the result — and put logic in service.py where it can be unit tested directly.
Mistake: one giant schemas.py for the whole app. It becomes unclear which schemas are actually used where, and refactors touch a file everyone else is also editing. Solution: scope schemas to the domain that owns them.
Mistake: mixing layered and domain-driven structure inconsistently. Half the app organized by domain, half by technical layer, with no clear rule for new code. Solution: pick one pattern deliberately and document it, even if it’s just a sentence in the README.
Mistake: over-engineering a three-endpoint prototype with five layers. Repository interfaces, abstract service classes, and dependency-injection containers for an app with three routes add cost with no corresponding benefit yet. Solution: match structure to actual size — start layered or even flat, and graduate to domain-driven when the pain shows up, not before.
Production Best Practices
- Enforce the one-way import rule.
router → service → models/schemas → core, never the reverse. If you want it automated,import-lintercan enforce module boundaries in CI. - Version the API at the router-composition layer, not per-endpoint — a
v2APIRoutertree mounted alongsidev1is far less error-prone than sprinkling version checks through handlers. - Keep
main.pyboring. It should create the app, mount routers, and register exception handlers and lifespan events — nothing else. - Write one
tests/<domain>/folder per domain, mirroring the app structure, so it’s obvious where a new test belongs. - Re-evaluate structure at real inflection points — a new domain, a second team, a service extraction — rather than on a fixed schedule.
Where to Go From Here
Project structure is the foundation the rest of a FastAPI application’s behavior sits on, but it doesn’t solve everything on its own. Once the folders are in place, the next problems tend to be runtime ones — and this cluster covers the five that come up most:
- Why Your FastAPI Endpoint Blocks the Event Loop — diagnosing
defvsasync defand the blocking calls that stall every other request - FastAPI BackgroundTasks vs Celery: Picking the Right One — a decision framework for where background work belongs
- FastAPI Dependency Injection: Patterns and Anti-Patterns —
Depends, yield-based teardown, and dependency caching - Async SQLAlchemy Sessions in FastAPI, Done Right — session scoping,
MissingGreenleterrors, and lifespan-managed engines - Streaming in FastAPI: SSE vs WebSockets vs Polling — choosing a transport for token-by-token and real-time responses
Wrapping Up
There’s no single correct FastAPI folder structure — there’s a structure that fits your team’s size and your domain’s shape, applied consistently, with a clear rule about which direction imports flow. Start simple, watch for the specific friction that signals it’s time to change, and when you do change, move one domain at a time behind working tests rather than attempting a rewrite. The goal was never the folder names — it’s a codebase where adding the fortieth endpoint is exactly as easy as adding the fourth.
Is your current FastAPI app still one file, or has it already outgrown its structure?
