Skip to main content
友田 陽大
Python backend
Python
FastAPI
アーキテクチャ設計
依存性注入
保守性

FastAPI Large-App Design: Building a 'Maintainable API' with APIRouter, Tiered Dependency Injection, and Project Structure

A design guide for keeping a large FastAPI API maintainable. Explained with real code, faithful to the official Bigger Applications: APIRouter, the recommended project structure, and relative imports, plus 4-tier DI (global/router/decorator/argument), router→service→repository layering, avoiding circular imports, API versioning, docs maintenance, and testing.

Published
Reading time
19 min read
Author
友田 陽大
Share

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 fastapi CLI (fastapi dev / fastapi run) for development / production startup and a [tool.fastapi] entrypoint setting in pyproject.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.py exceeded 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").


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 router and from .routers.users import router, and the latter overrides the former. As in the official docs, import the module itselffrom .routers import items, users, and reference it namespace-qualified as items.router / users.router when 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 original APIRouter object 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 that prefix doesn'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.

TierHow to write itRange of effectSuited use
① GlobalFastAPI(dependencies=[...])All endpointsA premise common to all APIs (API-key validation, requiring a common header)
② RouterAPIRouter(dependencies=[...]) / include_router(dependencies=[...])All of that feature groupForce admin privilege under /admin, tenant resolution
③ Decorator@router.get(..., dependencies=[...])That one endpointValidation / side effects not needing a return value (rate limit, specific permission check)
④ Function argumentparam: Annotated[X, Depends(...)]That one endpointUsing 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: UserService can be verified with a plain unit test without standing up HTTP. Swap the repository for 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 repository imports a router, that's the seed of a cycle.
  • Place core/ (config, DB, security) as the bottom-most foundation, on the side imported by anyone. core doesn't import higher layers.
  • Don't import main.py from other modules. A router doesn't need app (it uses APIRouter). If you get a design where "the router wants app," that's the dependency direction reversed — the correct direction is to inject from the incorporating side (main) with include_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.json publish 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 with docs_url=None / redoc_url=None / openapi_url=None, or to hide them behind authentication (branching by environment as in Section 8). Set openapi_url=None and 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.py exceeds 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 the FastAPI class.
  • Structure: the official app/ (main.py, dependencies.py, routers/, internal/). __init__.py in 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 with router.include_router.
  • DI 4 tiers: global = FastAPI(dependencies=), feature group = APIRouter(dependencies=), side-effects only = the decorator's dependencies=[], using the return value = the function argument's Depends. 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, core is the foundation). Type-only references with TYPE_CHECKING.
  • Versioning: bundle /api/v1 with nested routers, run /api/v2 in parallel. Share common logic via the service. For complete independence, mount.
  • docs: line them up in openapi_tags order. In production, narrow exposure with docs_url=None etc.
  • Run/test: fastapi dev (development) / fastapi run (production), [tool.fastapi] entrypoint. Isolated tests with the create_app factory + 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)

友田

友田 陽大

Developer of a METI Minister's Award–winning product. With TypeScript + Python + AWS, I deliver SaaS, industry DX, and production-grade generative AI (RAG) end to end — from requirements to infrastructure and operations — single-handedly.

Got a challenge?

From design to implementation and operations — solo × generative AI

Implementation like this article's, end to end from requirements to production. Start with a free 30-minute technical consult and tell me about your situation.

Available for both project-based (contract) and advisory engagements. Start with a free 30-minute consult.

Also worth reading