The first main.py is beautiful. @app.get("/") lines up, and it works in 5 minutes. But the instant auth comes in, resources grow to 3, and a DB is plugged in, that main.py becomes a 2,000-line ball of mud. Every time you add an endpoint, an existing one breaks, tests are slow, and a new member can't tell where to write what — "works" and "maintainable" are different things.
This article is a guide to building a large, production-quality app structure in FastAPI. While following the APIRouter and project structure that FastAPI's official "Bigger Applications" shows faithfully to the latest spec, it delves into the areas the official tutorial (being teaching material) doesn't touch — when to split (YAGNI), router/service/repository layering, structural avoidance of circular imports, API versioning, docs hardening, and testability. As the subject matter, I'll weave in decisions from an internal AI platform I built for a major domestic broadcaster (a monorepo bundling multiple FastAPI services under a single convention. Unifying common auth, logging, and cache conventions into one, designed as a horizontally-deployable platform).
The rule of this article: The structure and APIs are based on the FastAPI official documentation (as of June 2026). In recent years FastAPI gained the
fastapiCLI (fastapi dev/fastapi run) for development / production startup and a[tool.fastapi]entrypoint setting inpyproject.toml. This article conforms to this latest version. Since specs get revised, always confirm the latest behavior officially before going to production. As a design premise, also read together: async usage, lifespan, and observability in the production operation guide, and authentication / authorization in the authentication guide. This article concentrates on "how to arrange and maintain them."
0. First, the judgment: when to split (YAGNI)
Before structuring, it's important to have the judgment of "don't split yet." Carving services/ repositories/ into a 3-endpoint tool is over-engineering (a YAGNI violation) and conversely makes it harder to read.
Use the signs of splitting as the criterion.
main.pyexceeded one screen (~200 lines).- Resources (nouns) became 3 or more (users / items / orders …).
- Cross-cutting concerns (auth, DB session, logging) started being copy-pasted into multiple handlers.
- The team became 2 or more people and started conflicting on the same file.
Conversely, if none of the above applies, staying a single file is correct (KISS). This article targets "an app that has come to the stage where it should be split."
1. APIRouter: carve out routes per feature
The minimal unit of splitting is the APIRouter. As the official wording goes — APIRouter works exactly like the FastAPI class. All the same options are usable — parameters, responses, dependencies, tags. Thinking of it as a "mini FastAPI" is accurate.
# app/routers/users.py
from fastapi import APIRouter
router = APIRouter() # この機能(users)のルートを束ねる入れ物
@router.get("/users/", tags=["users"])
async def read_users():
return [{"username": "Rick"}, {"username": "Morty"}]
@router.get("/users/{username}", tags=["users"])
async def read_user(username: str):
return {"username": username}
Just change @app.get to @router.get. With this, the routes about users cohere into one file and disappear from main.py (SRP: the only reason this file changes is "a change to the user feature").
2. The officially recommended project structure
The standard layout the official docs show is this. First building this shape correctly becomes the foundation of everything.
.
├── app # "app" は Python パッケージ
│ ├── __init__.py # これで "app" がパッケージになる
│ ├── main.py # アプリ本体(FastAPI() と include_router)
│ ├── dependencies.py # 共有の依存(認証・共通ヘッダ検証など)
│ ├── routers # "routers" はサブパッケージ
│ │ ├── __init__.py
│ │ ├── items.py # items 機能のルート
│ │ └── users.py # users 機能のルート
│ └── internal # 社内/管理用など外に出さない機能
│ ├── __init__.py
│ └── admin.py
There are three points.
2.1 __init__.py is not an "omittable decoration"
Each directory's __init__.py makes that directory a Python package. It's because of this that imports like from app.routers import items hold. An empty file is fine, but deleting it breaks the imports.
2.2 Consolidate shared dependencies in dependencies.py (DRY)
Dependencies used from multiple features, like an auth check or common header validation, are bundled in one place.
# app/dependencies.py
from typing import Annotated
from fastapi import Header, HTTPException
async def get_token_header(x_token: Annotated[str, Header()]):
if x_token != "fake-super-secret-token":
raise HTTPException(status_code=400, detail="X-Token header invalid")
async def get_query_token(token: str):
if token != "jessica":
raise HTTPException(status_code=400, detail="No Jessica token provided")
2.3 Reference with relative imports
When referencing dependencies.py from routers/items.py, use a relative import.
from ..dependencies import get_token_header
# ^^ 二つのドット = 親パッケージ(app)まで上がってから dependencies を見る
# 一つのドット(.)= 同じパッケージ内
The standard to avoid name collisions: write both
from .routers.items import routerandfrom .routers.users import router, and the latter overrides the former. As in the official docs, import the module itself —from .routers import items, users, and reference it namespace-qualified asitems.router/users.routerwhen using it.
3. include_router: incorporate features into the body
Incorporate the carved-out routers in main.py. Being able to attach prefix, tags, dependencies, and responses all at once here is powerful.
# app/main.py
from fastapi import Depends, FastAPI
from .dependencies import get_query_token, get_token_header
from .internal import admin
from .routers import items, users
app = FastAPI(dependencies=[Depends(get_query_token)]) # ← 全体に効く依存(第4章)
app.include_router(users.router)
app.include_router(items.router)
# admin は「組み込み時に」prefix・タグ・追加の依存・追加レスポンスを上書き付与する
app.include_router(
admin.router,
prefix="/admin",
tags=["admin"],
dependencies=[Depends(get_token_header)], # /admin 配下すべてにトークン検証を強制
responses={418: {"description": "I'm a teapot"}},
)
@app.get("/")
async def root():
return {"message": "Hello Bigger Applications!"}
prefix / tags / dependencies / responses can be specified both when creating the router (APIRouter(...)) and when incorporating it (include_router(...)). Distinguish them: "settings specific to that feature" on the router side, "settings added due to the circumstances of where it's incorporated" on the include side.
An important official spec: the original router isn't changed. Add a prefix or dependencies with
include_router, and the originalAPIRouterobject stays unmodified. So you can reuse the same router in a different app with a different prefix and different dependencies. It's a property that pays off when distributing a router like a shared library. Just note thatprefixdoesn't take a trailing slash (/items, not/items/).
3.1 Nest a router inside a router
A router can also be incorporated into another router. This becomes the key to the versioning (bundling /api/v1) described later.
parent.include_router(child_router) # APIRouter を APIRouter に入れ子にできる
4. Design dependency injection in "4 tiers"
FastAPI's true value is in dependency injection (DI). But what you agonize over in the field is not the feature but "at what height to place this dependency." FastAPI provides 4 tiers. Make this a map and design gets a lot easier.
| Tier | How to write it | Range of effect | Suited use |
|---|---|---|---|
| ① Global | FastAPI(dependencies=[...]) | All endpoints | A premise common to all APIs (API-key validation, requiring a common header) |
| ② Router | APIRouter(dependencies=[...]) / include_router(dependencies=[...]) | All of that feature group | Force admin privilege under /admin, tenant resolution |
| ③ Decorator | @router.get(..., dependencies=[...]) | That one endpoint | Validation / side effects not needing a return value (rate limit, specific permission check) |
| ④ Function argument | param: Annotated[X, Depends(...)] | That one endpoint | Using the return value (DB session, current user) |
The judgment is one line — "Does the handler body use the return value?" If yes, ④ (function argument). If not (validation / side effects only), one of ①–③. And the wider the range you want it to take effect, the higher the tier you place it.
4.1 ① Global dependency: a premise common to all endpoints
# 全リクエストに先立ってヘッダ検証を走らせる(一箇所で全体に効く)
app = FastAPI(dependencies=[Depends(verify_token), Depends(verify_key)])
A dependency passed to FastAPI(dependencies=[...]) is executed on all of the app's path operations. Declare "a premise mandatory in all APIs" once here, rather than scattering it across handlers (DRY).
4.2 ③ Decorator dependency: don't need the return value, but want it always executed
When "you want to validate, but don't use that result in the handler," the decorator's dependencies=[...] is optimal.
@app.get("/items/", dependencies=[Depends(verify_token), Depends(verify_key)])
async def read_items():
# verify_token / verify_key は必ず実行される(失敗すれば例外で弾かれる)。
# ただし戻り値はハンドラに渡らない(使わないので引数にも現れない)。
return [{"item": "Foo"}, {"item": "Bar"}]
These dependencies can throw exceptions like ordinary dependencies (can return 4xx with HTTPException) and can read headers too. The difference is that the return value is ignored. Hang a "dependency you don't use" on a function argument, and the editor warns "unused argument," confusing the reader. For side effects only, to the decorator rather than the argument — this is a small but effective device for readability.
4.3 ② Router dependency: consolidate authorization at the router layer (most important)
What pays off most in practice is the router-layer dependency. When you want to force a permission check on all endpoints under /admin, writing it in each handler is a breeding ground for gaps. Attach it once to the router, and forgetting to write it structurally doesn't happen.
from fastapi import APIRouter, Security
from app.core.security import get_current_active_user # 認証ガイド参照
# /admin 配下の全ルートに admin スコープを強制(DRY・抜け漏れ防止)
admin_router = APIRouter(
prefix="/admin",
tags=["admin"],
dependencies=[Security(get_current_active_user, scopes=["admin"])],
)
@admin_router.get("/reports") # ← ここに認可を書かなくても admin が要求される
async def list_reports():
...
On the broadcaster's platform too, I defined cross-cutting concerns like auth, logging, and rate limiting in one place as "a convention common to each service" and horizontally deployed them to 5 FastAPI services. On the lumber-distribution B2B SaaS (Minister of Economy, Trade and Industry Award-winning), I consolidated industry-based authorization at the router layer and protected all 221 endpoints with a consistent policy — demonstrating 0 missing-authorization findings in a third-party pen test. Authorization is production quality when protected by a structure consolidated at the boundary, not by "each handler's well-meaning if statement" (→ the authentication / authorization guide).
5. Layering: keep the router thin, push logic to service / repository
From here is the "beyond" of the official tutorial. The official examples, for learning, write business logic inside the handler (router). This is correct for small scale, but breaks down at large scale — a handler holding all of HTTP, business rules, and DB access makes tests heavy and changes propagate (an SRP violation).
So split the responsibility into 3 layers.
- router (the HTTP boundary): only the conversion of request/response. Keep it thin.
- service (business logic): business rules. Knows neither HTTP nor the DB driver.
- repository (I/O): only the input/output to the DB / external APIs.
# app/repositories/user_repository.py —— I/O のみ。SQLの詳細はここに閉じる
from sqlalchemy.ext.asyncio import AsyncSession
from app.models import User
class UserRepository:
def __init__(self, db: AsyncSession) -> None:
self._db = db
async def get(self, user_id: int) -> User | None:
return await self._db.get(User, user_id)
async def add(self, user: User) -> User:
self._db.add(user)
await self._db.flush()
return user
# app/services/user_service.py —— 業務ルールのみ。HTTPもSQLも登場しない(純粋でテストしやすい)
from app.repositories.user_repository import UserRepository
from app.schemas.user import UserCreate, UserPublic
class UserService:
def __init__(self, repo: UserRepository) -> None:
self._repo = repo
async def register(self, payload: UserCreate) -> UserPublic:
# 例:業務ルール(重複チェック・初期ロール付与など)はここに集約する
user = await self._repo.add(payload.to_model())
return UserPublic.model_validate(user)
# app/api/deps.py —— 「配線(DI)」を一箇所に集約する
from typing import Annotated
from fastapi import Depends
from app.core.db import DbDep # lifespan で作った session を注入(本番運用ガイド参照)
from app.repositories.user_repository import UserRepository
from app.services.user_service import UserService
def get_user_service(db: DbDep) -> UserService:
# repository を生成して service に注入する。これがDIの結節点。
return UserService(UserRepository(db))
UserServiceDep = Annotated[UserService, Depends(get_user_service)]
# app/api/v1/routers/users.py —— router は薄い。HTTP境界の翻訳だけ
from fastapi import APIRouter, status
from app.api.deps import UserServiceDep
from app.schemas.user import UserCreate, UserPublic
router = APIRouter()
@router.post("/", response_model=UserPublic, status_code=status.HTTP_201_CREATED)
async def create_user(payload: UserCreate, service: UserServiceDep) -> UserPublic:
# router の仕事は「受け取って、serviceに渡して、返す」だけ。業務ルールはここに無い。
return await service.register(payload)
The payoff of this split is concrete.
- Testability:
UserServicecan be verified with a plain unit test without standing up HTTP. Swap therepositoryfor a fake and you don't even need a DB. - Extensibility (ETC): swap the DB from PostgreSQL to something else, and the change is confined to the
repository. The service and router are untouched. - Type safety: fix the layer boundaries with Pydantic schemas (
UserCreate/UserPublic) and make input and output separate types (the Pydantic guide for boundary validation).
Don't overdo it (YAGNI): you don't need to mechanically carve 3 layers into every feature. A thin-logic CRUD can be written directly in the router. Grow a service only for "a feature where business rules have grown" — incrementalism is correct.
5.1 An advanced layout for large scale
Introduce the 3 layers and the directory grows from the official minimal form to the following (move to this shape after it becomes necessary).
app/
├── main.py # create_app・include_router・lifespan・middleware(第8章)
├── core/ # 全層から参照される土台(循環の起点にしない)
│ ├── config.py # Settings(pydantic-settings)
│ ├── db.py # engine・session factory・get_db(DbDep)
│ └── security.py # 認証の依存(get_current_user 等)
├── api/
│ ├── deps.py # DIの結節点(service生成・型エイリアス)
│ └── v1/
│ ├── router.py # v1の全routerを束ねる
│ └── routers/ # users.py・items.py …(HTTP境界)
├── services/ # ビジネスロジック
├── repositories/ # I/O
├── models/ # ORMモデル
└── schemas/ # Pydanticスキーマ(入力/出力)
6. Prevent circular imports with "structure"
The most common accident in scaling up FastAPI is a circular import (ImportError: cannot import name ...). The cause is almost always "the dependency direction has become bidirectional." This can be eradicated with discipline.
The iron rule: flow dependencies in only one direction.
main → api(routers) → services → repositories → models / schemas
↘ core(config / db / security) ← a foundation anyone may reference
- Higher layers may import lower layers. Lower layers must not import higher layers. The moment a
repositoryimports arouter, that's the seed of a cycle. - Place
core/(config, DB, security) as the bottom-most foundation, on the side imported by anyone.coredoesn't import higher layers. - Don't import
main.pyfrom other modules. A router doesn't needapp(it usesAPIRouter). If you get a design where "the router wantsapp," that's the dependency direction reversed — the correct direction is to inject from the incorporating side (main) withinclude_router. - A mutual reference solely for type hints is resolved by avoiding runtime import.
from __future__ import annotations
from typing import TYPE_CHECKING
if TYPE_CHECKING: # 型チェック時だけ評価され、実行時には import されない
from app.services.user_service import UserService
def handler(service: "UserService") -> None: ...
As long as you keep the dependency direction one-way, a cycle simply can't occur in the first place. This isn't a story of "being careful" but of "making it impossible by structure."
7. API versioning: evolve without breaking
For a public API, backward compatibility is the lifeline. You want to change the response shape of /users but can't break existing clients — in such cases, run versions in parallel. The "nest a router in a router" from Section 3.1 can be used as-is.
# app/api/v1/router.py —— v1 の全機能を1つの router に束ねる
from fastapi import APIRouter
from .routers import users, items
api_router = APIRouter()
api_router.include_router(users.router, prefix="/users", tags=["users"])
api_router.include_router(items.router, prefix="/items", tags=["items"])
# app/main.py —— /api/v1 配下にまとめて組み込む
from app.api.v1.router import api_router as v1_router
# from app.api.v2.router import api_router as v2_router # v2 を足しても v1 は無傷
app.include_router(v1_router, prefix="/api/v1")
# app.include_router(v2_router, prefix="/api/v2")
For a new version, grow a new router in a separate directory (api/v2/) and run it in parallel at /api/v2. Because you don't touch v1's code, existing clients break not one line (ETC). Share common logic via service/repository across both versions, and keep what changes to only "how HTTP is presented (router/schema)" (DRY).
An alternative: mounting a sub-app. If you want to make it a completely independent separate app (separate docs, separate lifecycle), there's also the hand of mounting a whole sub-application with
app.mount("/api/v2", subapp). But for many cases, nesting routers is enough (KISS). Ask yourself "is it really an independent app?" before choosing mount.
8. Consolidate settings, lifespan, and exception handlers (the create_app factory)
Create app = FastAPI() globally in main.py and side effects run the instant it's imported (settings load, connections), making it hard to swap in tests. Fold this into a factory function and the assembly can be surveyed at a glance, and you can create a fresh app per test.
# app/main.py
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from app.core.config import Settings, get_settings
from app.core.lifespan import lifespan # DBプール等の確保(本番運用ガイド参照)
from app.core.errors import register_exception_handlers
from app.api.v1.router import api_router as v1_router
tags_metadata = [
{"name": "users", "description": "ユーザーの作成・取得。**ログイン**もここ。"},
{"name": "items", "description": "アイテム管理。"},
]
def create_app(settings: Settings | None = None) -> FastAPI:
settings = settings or get_settings()
app = FastAPI(
title=settings.app_name,
version=settings.version,
lifespan=lifespan,
openapi_tags=tags_metadata,
# 本番では docs/openapi を閉じて API 構造の露出を絞る(後述)
docs_url="/docs" if settings.docs_enabled else None,
redoc_url=None,
openapi_url="/openapi.json" if settings.docs_enabled else None,
)
app.add_middleware(
CORSMiddleware,
allow_origins=settings.cors_origins, # "*" は使わない(認証ガイド参照)
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
register_exception_handlers(app) # 例外→HTTPの翻訳を一箇所に
app.include_router(v1_router, prefix="/api/v1")
return app
app = create_app() # ASGIサーバ(fastapi run / uvicorn)が参照するエントリポイント
The factory's payoff: in tests, with create_app(test_settings) you can newly generate an app isolated from production every time. Because there are no global side effects, contamination between tests doesn't occur either (testability).
9. Make documentation an "asset" (and narrow exposure in production)
FastAPI's automatic docs are a powerful asset. Just by tidying up the tags' descriptions and metadata, /docs becomes a readable API spec.
app = FastAPI(
title="ChimichangApp",
summary="一行サマリ(OpenAPI 3.1 / FastAPI 0.99.0+)",
description="Markdownが使える詳細説明。**太字**やリストも可。",
version="1.4.0",
contact={"name": "API Support", "url": "https://example.com/contact", "email": "api@example.com"},
license_info={"name": "Apache 2.0", "url": "https://www.apache.org/licenses/LICENSE-2.0.html"},
openapi_tags=tags_metadata, # ← この **リストの順序が docs の表示順**になる
)
Each element of openapi_tags can have name (required), description (Markdown OK), and externalDocs. And the order you line them up in the list becomes the section order of the docs UI as-is. Rather than a messy alphabetical order, you can line them up in a business order natural for the reader.
[Security] Don't expose docs defenselessly in production:
/docs,/redoc, and/openapi.jsonpublish the API's entire structure (all endpoints, all schemas). For an internal API or an authenticated API, this can be a map for an attacker. In production, the standard is to disable them withdocs_url=None/redoc_url=None/openapi_url=None, or to hide them behind authentication (branching by environment as in Section 8). Setopenapi_url=Noneand the docs UI is also disabled at the same time.
10. Running and testing: the fastapi CLI and per-router verification
10.1 Startup: fastapi dev for development, fastapi run for production
FastAPI now has a dedicated CLI. Development is fastapi dev with reload, production is fastapi run without reload (driving uvicorn internally).
fastapi dev app/main.py # 開発:自動リロード(既定は 127.0.0.1:8000)
fastapi run app/main.py # 本番:リロード無し(既定は 0.0.0.0:8000)
The entrypoint can be written in pyproject.toml (fastapi run auto-discovers the app).
[tool.fastapi]
entrypoint = "app.main:app" # from app.main import app と同義
For production worker multiplexing and graceful shutdown, follow Section 9 of the production operation guide (a process manager + multiple workers, cleanup in lifespan).
10.2 Testing: per-router + dependency_overrides
The biggest payoff of structuring is testability. Create an isolated app with create_app and swap dependencies.
# tests/test_users.py
import pytest
from fastapi.testclient import TestClient
from app.main import create_app
from app.api.deps import get_user_service
@pytest.fixture
def client():
app = create_app(test_settings()) # 本番から隔離した新鮮なアプリ
# service を「フェイク」に差し替える → DB を立てずに router の契約だけ検証できる
app.dependency_overrides[get_user_service] = lambda: FakeUserService()
yield TestClient(app)
app.dependency_overrides.clear() # テスト間の汚染を防ぐ
def test_create_user_returns_201(client):
res = client.post("/api/v1/users/", json={"email": "a@example.com", "display_name": "A"})
assert res.status_code == 201
assert "id" in res.json()
There are two points. (1) The service layer can be unit-tested without a DB (the recovery of Section 5's split). (2) The router can swap the service for a fake with dependency_overrides and verify only the HTTP contract (status, schema) fast. Layering and DI connect directly to the test strategy.
11. Summary: a maintainable-FastAPI structure cheat sheet
A quick-reference table for when you're unsure.
- The split judgment: split when
main.pyexceeds 200 lines / there are 3 or more resources / cross-cutting concerns start being copy-pasted (YAGNI: until then, one file is fine). - APIRouter: carve out per feature with
router = APIRouter(). A "mini FastAPI" with the same options as theFastAPIclass. - Structure: the official
app/(main.py,dependencies.py,routers/,internal/).__init__.pyin each dir. Relative imports (./..). Import the router as a module to avoid name collisions. - include_router: attach
prefix/tags/dependencies/responses. The original router is unmodified and reusable. Nest withrouter.include_router. - DI 4 tiers: global =
FastAPI(dependencies=), feature group =APIRouter(dependencies=), side-effects only = the decorator'sdependencies=[], using the return value = the function argument'sDepends. The judgment is "do you use the return value, and how wide a range to affect." - Layering: thin router, business in service, I/O in repository (SRP/ETC). Grow a service only for a feature where logic has grown (YAGNI).
- Circular imports: dependencies one-way (
main→api→services→repositories→models,coreis the foundation). Type-only references withTYPE_CHECKING. - Versioning: bundle
/api/v1with nested routers, run/api/v2in parallel. Share common logic via the service. For complete independence,mount. - docs: line them up in
openapi_tagsorder. In production, narrow exposure withdocs_url=Noneetc. - Run/test:
fastapi dev(development) /fastapi run(production),[tool.fastapi] entrypoint. Isolated tests with thecreate_appfactory +dependency_overrides.
FastAPI is a "works in 5 minutes" framework, but whether you can still touch it 6 months later is decided by the initial structure. Cohering features into routers, placing cross-cutting concerns at the correct height of dependency, splitting logic into layers, and fixing the dependency direction one-way — none of these is flashy, but this accumulation builds "an API that doesn't break down even when endpoints grow to 200."
On the broadcaster's internal AI platform, I designed a monorepo bundling multiple FastAPI services under a single auth, logging, and cache convention, and on the lumber-distribution SaaS I maintained all 221 endpoints with a consistent structure and demonstrated 0 missing-authorization findings in a third-party pen test. With generative AI (Claude Code) as a partner, my way of proceeding is to build fast and cheap, alone while ensuring quality with conventions and tests.
"This FastAPI API of mine — features grew and main.py is suffering," "how should I introduce versioning or layering?" — from that design judgment through implementation, testing, and operation, I accompany you end to end. Even from the requirements-organizing stage, feel free to consult me.
Reference (official documentation)
- Bigger Applications - Multiple Files (FastAPI) —
APIRouter, project structure,include_router, relative imports - Global Dependencies (FastAPI) — dependencies that affect everything with
FastAPI(dependencies=[...]) - Dependencies in path operation decorators (FastAPI) — how to place a dependency that doesn't use the return value
- Dependencies (FastAPI) — the basics of dependency injection with
DependsandAnnotated - Metadata and Docs URLs (FastAPI) —
title/version/openapi_tagsand control of the docs URLs - APIRouter (FastAPI reference) — all parameters of
APIRouter - FastAPI CLI —
fastapi dev/fastapi runand startup settings