FastAPIの基本と採用時の設計ポイントを押さえてAPIを作り始めても、エンドポイントが増えると、ルート定義、認証、DB接続、設定の読み込みが一つのファイルへ集まりやすくなります。
この状態を立て直す中心が、ルートを機能単位に分ける APIRouter と、共通処理を宣言して受け取る Depends です。
本記事では、両者の役割を切り分けたうえで、設定管理、DBセッション、認証、テストまでを一つの構成にまとめます。
この記事で作る設計の全体像
APIRouterでusersやtodosなどの機能ごとにルートを分けます。Dependsで設定、DBセッション、認証済みユーザーを必要な場所へ渡します。yieldを使う依存で、DBセッションの終了処理を一か所に集めます。app.dependency_overridesで、本番用の依存をテスト用の実装へ差し替えます。- ルーターはHTTP入出力に集中させ、業務ロジックはサービス層へ移します。
小規模なAPIで最初から全層を用意する必要はありません。
main.py の見通しが悪くなった箇所から、一つずつ責務を分けるほうが移行しやすくなります。
APIRouterとDependsの役割
APIRouter は、関連するパス操作をひとまとまりとして定義する仕組みです。
prefix、tags、responses、共通の依存をルーター単位で設定し、最後に include_router() でアプリへ組み込みます。
依存性注入は、処理に必要な値や前処理を、関数の内部で直接生成せず外から受け取る設計です。
FastAPIでは Depends に依存関数を指定すると、その結果をパス操作関数や別の依存へ渡せます。
Annotated を使えば、値の型と依存の指定を一つの型エイリアスにまとめられます。
| 仕組み | 担当すること | 代表例 |
|---|---|---|
APIRouter |
関連するURLと共通設定を機能単位にまとめる | /users、/todos |
| 値を返す依存 | パス操作が使う値を渡す | DBセッション、設定、認証済みユーザー |
| ルーター共通依存 | ルーター内の全パスで前提条件や処理を実行する | 認証の必須化、監査処理 |
| ミドルウェア | アプリ全体のリクエストとレスポンスを包む | CORS、リクエストID、処理時間の計測 |
| サービス層 | HTTPから独立した業務ロジックを扱う | 作成条件の検証、登録、更新 |
最小のプロジェクト構成
次の構成では、横断的な処理を core、DB関連を db、HTTPの入口を routers、入出力モデルを schemas に置きます。
fastapi-arch/
├─ app/
│ ├─ main.py
│ ├─ core/
│ │ ├─ settings.py
│ │ ├─ security.py
│ │ └─ exceptions.py
│ ├─ db/
│ │ ├─ session.py
│ │ └─ models.py
│ ├─ routers/
│ │ ├─ users.py
│ │ └─ todos.py
│ ├─ schemas/
│ │ └─ todos.py
│ └─ services/
│ └─ todos.py
└─ tests/
フォルダ名そのものより、変更理由が異なるコードを分けることが大切です。
分割の順序を詳しく確認したい場合は、1ファイルのFastAPIを段階的に分ける方法も参考になります。
pydantic-settingsで設定を一元化する
設定値を各モジュールで個別に読み込むと、どの値が必須なのか、テストで何を差し替えるのかが見えにくくなります。
Pydantic v2向けの pydantic-settings では、SettingsConfigDict を使って環境ファイルなどの読み込み方を定義できます。
# app/core/settings.py
from functools import lru_cache
from typing import Annotated
from fastapi import Depends
from pydantic import Field
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
model_config = SettingsConfigDict(
env_file=".env",
env_file_encoding="utf-8",
extra="ignore",
)
app_name: str = "My FastAPI"
environment: str = Field(default="dev", description="実行環境名")
database_url: str = "sqlite:///./app.db"
secret_key: str
@lru_cache
def get_settings() -> Settings:
return Settings()
SettingsDep = Annotated[Settings, Depends(get_settings)]
secret_key に既定値を置いていないため、設定取得時に値がなければ検証エラーになります。
get_settings を依存として公開しておけば、パス操作は環境変数の読み込み方法を知る必要がありません。
@lru_cache は、同じプロセス内で設定オブジェクトを繰り返し生成しないために使います。
DBセッションをyield依存で管理する
yield依存は、値を渡す処理と、その値を使い終えた後の終了処理を一つの関数に書く依存です。
DBセッションでは、yield より前でセッションを作り、finally で閉じます。
# app/db/session.py
from collections.abc import Generator
from typing import Annotated
from fastapi import Depends
from sqlalchemy import create_engine
from sqlalchemy.orm import Session, sessionmaker
from app.core.settings import get_settings
settings = get_settings()
connect_args = (
{"check_same_thread": False}
if settings.database_url.startswith("sqlite")
else {}
)
engine = create_engine(
settings.database_url,
connect_args=connect_args,
)
SessionLocal = sessionmaker(
bind=engine,
autoflush=False,
expire_on_commit=False,
)
def get_db() -> Generator[Session, None, None]:
db = SessionLocal()
try:
yield db
finally:
db.close()
DbDep = Annotated[Session, Depends(get_db)]
この依存が担当するのは、セッションの生成と終了です。
コミットやロールバックをどの層で行うかは、サービス関数ごとに行うのか、トランザクション境界をまとめるのかを決め、プロジェクト内で統一します。
認証結果を依存として受け取る
認証処理をパス操作へ直接書くと、検証方法の変更がすべてのルートに波及します。
認証済みユーザーを返す依存を一つ用意すれば、パス操作は利用者の情報だけを受け取れます。
次の X-Token 検証は依存の形を示す学習用の例であり、本番用の認証実装ではありません。
# app/core/security.py
from typing import Annotated, TypedDict
from fastapi import Depends, Header, HTTPException, status
class CurrentUser(TypedDict):
username: str
role: str
def get_current_user(
x_token: Annotated[str | None, Header(alias="X-Token")] = None,
) -> CurrentUser:
if x_token != "valid":
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Unauthorized",
)
return {"username": "alice", "role": "user"}
UserDep = Annotated[CurrentUser, Depends(get_current_user)]
def require_role(role: str):
def checker(user: UserDep) -> CurrentUser:
if user["role"] != role:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Forbidden",
)
return user
return checker
AdminUserDep = Annotated[
CurrentUser,
Depends(require_role("admin")),
]
require_role は get_current_user の結果を受け取るため、認証と権限確認を別々にテストできます。
認証結果をパス操作内で使う場合は、ルーターの dependencies ではなく、UserDep のような引数として宣言します。
Pydantic v2の入出力モデルを分ける
作成時の入力とAPIから返すデータでは、必要な項目が異なります。
入力用と出力用のモデルを分けると、クライアントが送る値とサーバーが決める値を区別できます。
# app/schemas/todos.py
from datetime import datetime
from pydantic import BaseModel, ConfigDict
class TodoCreate(BaseModel):
title: str
is_done: bool = False
class TodoRead(TodoCreate):
model_config = ConfigDict(from_attributes=True)
id: int
created_at: datetime
from_attributes=True は、属性を持つORMオブジェクトからレスポンスモデルを組み立てる設定です。
APIRouterはHTTPの配線に集中させる
ルーターには、URL、入力モデル、レスポンスモデル、ステータスコード、必要な依存を置きます。
登録条件や更新規則などの業務ロジックはサービス関数へ渡すと、HTTPを介さない単体テストを書きやすくなります。
# app/routers/todos.py
from fastapi import APIRouter, status
from app.core.security import UserDep
from app.db.session import DbDep
from app.schemas.todos import TodoCreate, TodoRead
from app.services import todos as todo_service
router = APIRouter(
prefix="/todos",
tags=["todos"],
responses={404: {"description": "Not found"}},
)
@router.post(
"",
response_model=TodoRead,
status_code=status.HTTP_201_CREATED,
)
def create_todo(
payload: TodoCreate,
db: DbDep,
user: UserDep,
):
return todo_service.create(
db,
payload,
owner=user["username"],
)
@router.get("", response_model=list[TodoRead])
def list_todos(
db: DbDep,
user: UserDep,
):
return todo_service.list_all(
db,
owner=user["username"],
)
ルーター共通の dependencies=[Depends(...)] は、返り値をパス操作へ渡さなくてもよい処理に向いています。
たとえば、ルーター全体で認証を必須にするだけなら共通依存を使えますが、認証済みユーザー名を保存処理で使うなら引数として受け取ります。
main.pyでルーターとAPIバージョンを集約する
main.py はアプリの生成とルーターの登録に絞ります。
バージョンの接頭辞を include_router() 側へ置くと、各ルーターの機能名と公開URLのバージョンを分けて管理できます。
# app/main.py
from fastapi import FastAPI
from app.core.settings import SettingsDep
from app.routers import todos, users
app = FastAPI(title="Modular FastAPI")
app.include_router(todos.router, prefix="/api/v1")
app.include_router(users.router, prefix="/api/v1")
@app.get("/health")
def health(settings: SettingsDep):
return {
"app": settings.app_name,
"environment": settings.environment,
}
settings = get_settings() を引数の既定値へ直接書くと、FastAPIの依存として解決されません。
SettingsDep または Depends(get_settings) と宣言して、テスト時に差し替えられる形を保ちます。
ミドルウェアと依存を作用範囲で選ぶ
共通処理をどこへ置くかは、処理が必要な範囲と、返り値をパス操作で使うかによって決めます。
| 必要な範囲 | 置き場所 | 判断の目安 |
|---|---|---|
| 全リクエスト | ミドルウェア | レスポンスを含む全体を包む処理 |
| 特定機能の全ルート | ルーター共通依存 | 値を受け取らず、前提条件だけを適用する処理 |
| 一つのパス操作 | パス操作の依存 | その処理だけが必要とする値や検証 |
| 複数の依存が共有する処理 | サブ依存 | 認証後に権限を確認する場合など |
範囲を必要以上に広げると、対象外にしたいルートが増えたときに例外設定が複雑になります。
最も狭い適切な範囲から置き場所を選ぶと、影響範囲を説明しやすくなります。
例外処理とエラーレスポンスを一か所に集める
サービス層が業務上の失敗をHTTPレスポンスへ直接変換すると、HTTP以外から同じ処理を呼び出しにくくなります。
業務例外を投げ、アプリ側の例外ハンドラでレスポンスへ変換すると、役割を分けられます。
from fastapi import Request
from fastapi.responses import JSONResponse
class DomainError(Exception):
def __init__(self, message: str):
self.message = message
@app.exception_handler(DomainError)
def handle_domain_error(
_request: Request,
exc: DomainError,
) -> JSONResponse:
return JSONResponse(
status_code=400,
content={"detail": exc.message},
)
responses={...} はOpenAPI上の説明を補いますが、実際のレスポンス形式を統一する処理は例外ハンドラやレスポンスモデルが担います。
dependency_overridesでテスト用の依存へ差し替える
app.dependency_overrides は、元の依存関数をキー、テスト用の依存関数を値に持つ辞書です。
外部認証や本番設定を呼ばずに、パス操作の振る舞いを確認できます。
# tests/conftest.py
import pytest
from fastapi.testclient import TestClient
from app.core.security import get_current_user
from app.core.settings import Settings, get_settings
from app.main import app
client = TestClient(app)
def override_settings() -> Settings:
return Settings(
app_name="Test API",
environment="test",
database_url="sqlite:///./test.db",
secret_key="test-secret",
)
def override_current_user():
return {"username": "tester", "role": "admin"}
@pytest.fixture(autouse=True)
def dependency_overrides():
app.dependency_overrides[get_settings] = override_settings
app.dependency_overrides[get_current_user] = override_current_user
yield
app.dependency_overrides.clear()
def test_health():
response = client.get("/health")
assert response.status_code == 200
assert response.json()["environment"] == "test"
差し替えを解除しないと別のテストへ状態が残るため、フィクスチャの終了時に clear() します。
型定義、依存性注入、テストを一緒に見直す場合は、FastAPIで壊れにくいAPIを作る実践も補助資料になります。
既存のmain.pyを安全に分ける手順
- 現在のパス操作を、利用者、ToDo、認証などの機能名で一覧にします。
- 一つの機能だけを
APIRouterへ移し、公開URLが変わっていないことをテストします。 - 複数のパス操作で重複する設定、DBセッション、認証を依存関数へ移します。
- 依存の返り値を使う処理と、実行だけでよい共通依存を分けます。
- 業務ロジックをサービス関数へ移し、ルーターをHTTP入出力に絞ります。
- 本番の依存をテスト用へ差し替え、成功時と失敗時の両方を確認します。
- バージョン接頭辞を追加する場合は、既存クライアントへの影響を確認してから公開します。
一度に全機能を移すと、URL変更、依存の変更、DB処理の変更が同じ差分に混ざります。
一つのルーターごとに移すと、問題が起きた箇所を特定しやすくなります。
よくある混乱と直し方
| 症状 | 原因 | 直し方 |
|---|---|---|
| 設定をテストで差し替えられない | パス操作の既定値で get_settings() を直接呼んでいる |
Depends(get_settings) または型エイリアスで宣言する |
| ルーター共通依存の返り値を受け取れない | dependencies=[...] は実行だけを指定する |
値が必要な依存はパス操作の引数に置く |
| 認証例をそのまま本番へ使ってしまう | 学習用のダミートークンと実運用の認証を区別していない | 例の目的を明記し、採用する認証方式に合わせて実装する |
| DB更新の失敗後も処理を続ける | コミットとロールバックの責任が決まっていない | トランザクション境界を決め、例外時の処理をテストする |
| ルーター間で循環importが起きる | ルーター同士が相互に実装を参照している | 共有する型や処理を下位のモジュールへ移す |
| Pydantic v2の設定が古い書式のまま | class Config を引き継いでいる |
model_config と SettingsConfigDict を使う |
実装後の確認項目
- FastAPIアプリのエントリーポイントにルート定義が集中していないか。
- ルーターの
prefixとinclude_router()の接頭辞を連結した公開URLが意図どおりか。 - DBセッションが正常時と例外時のどちらでも閉じられるか。
- 認証結果を使うパス操作が、ユーザー情報を引数で受け取っているか。
- 設定、認証、DBの依存をテスト用に差し替えられるか。
- サービス層がFastAPIの
RequestやResponseに不必要に依存していないか。 - 入力モデルと出力モデルで、利用者が指定できる項目を区別できているか。
- 例外レスポンスとOpenAPI上の説明が一致しているか。
変更しやすいFastAPI構成の要点
APIRouter は機能ごとの境界を作り、Depends は設定、DB、認証などの受け渡し方を揃えます。
yield 依存で終了処理をまとめ、dependency_overrides でテスト時の差し替え口を用意すれば、変更の影響を一つの層に閉じ込めやすくなります。
ルーター、依存、サービスの役割を一度に増やすのではなく、現在の重複や変更頻度に合わせて順番に分けることが、保守しやすい構成への近道です。
この記事に関連する株式会社greedenの取り組み
FastAPIの構成を見直す目的は、変更しやすく運用を続けられる基盤を整えることです。株式会社greedenは、FastAPIを含む技術を用いたWebシステム開発と、公開後の運用、改善、機能追加を支援しています。

