サイトアイコン IT & ライフハックブログ|学びと実践のためのアイデア集

FastAPIのプロジェクト構成:1ファイルのAPIを保守しやすく育てる方法

小さなAPIモジュールが役割別の構成へ整理されていく様子を表した抽象イラスト

FastAPIは、1ファイルでも入力検証と自動ドキュメントを備えたWeb APIを作れます。
しかし、エンドポイント、設定、データベース処理、外部サービス連携が同じファイルに集まると、小さな変更でも影響範囲を追いにくくなります。

保守しやすい構成は、最初から階層を増やすことではありません。
変更理由の異なるコードを分け、必要な依存関係だけを明示することから始まります。

FastAPIの特徴や採用判断を先に確認したい場合は、FastAPIでAPIを設計するときの基本も参照してください。
この記事では、動く試作を保守できるアプリケーションへ育てる構成に焦点を当てます。

1ファイルのままでは変更が絡み合う

ファイルが長いこと自体より、HTTPの都合と業務ルールと外部接続の都合が同じ場所で変わることが問題です。
たとえばレスポンス項目の追加だけで、データベースの取得方法や認証処理まで触る必要があるなら、責務の境界が曖昧です。

変更の種類 主な置き場所 分ける理由
URL、HTTPメソッド、ステータスコード router 通信上の契約を確認しやすくする
入力と出力の形、検証条件 schema APIの境界を業務処理から分離する
ユースケースと業務ルール service HTTPを使わずに検証できるようにする
環境ごとの値 config コードと実行環境の差を混ぜない
データベースや外部APIへの接続 dependency、必要ならrepository 実装をテスト用の代替へ差し替えやすくする

この分類は、フォルダ名を守るための規則ではありません。
一つの変更で複数の層を毎回修正するなら、分割位置かデータの受け渡し方を見直す合図です。

無理のない最小構成

エンドポイントが増え始めた段階では、次の構成で十分です。
永続化先がまだないのにrepository層を作るなど、将来を予想した空の抽象化は避けます。

app/
  __init__.py
  main.py
  config.py
  dependencies.py
  routers/
    __init__.py
    items.py
  schemas/
    __init__.py
    item.py
  services/
    __init__.py
    items.py
tests/
  test_items.py
pyproject.toml

main.pyはアプリケーションを組み立てる場所にとどめます。
FastAPI公式ドキュメントの複数ファイル構成でも、APIRouterをメインアプリへ取り込む方法が示されています。

main.pyは組み立てだけを担当する

from fastapi import FastAPI

from app.routers import items


def create_app() -> FastAPI:
    app = FastAPI()
    app.include_router(items.router)
    return app


app = create_app()

アプリケーション生成を関数にしておくと、テスト用設定を適用したインスタンスも作りやすくなります。
ルーター追加、ミドルウェア、例外ハンドラーなど、アプリ全体の配線はこの場所で確認できます。

routerはHTTPの境界に絞る

from typing import Annotated

from fastapi import APIRouter, Depends, status

from app.dependencies import get_item_service
from app.schemas.item import ItemCreate, ItemRead
from app.services.items import ItemService

router = APIRouter(prefix="/items", tags=["items"])


@router.post(
    "",
    response_model=ItemRead,
    status_code=status.HTTP_201_CREATED,
)
async def create_item(
    payload: ItemCreate,
    service: Annotated[ItemService, Depends(get_item_service)],
) -> ItemRead:
    return await service.create(payload)

routerが担うのは、入力を受け取り、必要な依存関係を受け取り、サービスを呼び、HTTPレスポンスへ変換することです。
SQLや外部APIの再試行処理まで書き始めると、通信のテストと業務処理のテストを切り分けにくくなります。

schemaは外部との契約を表す

from pydantic import BaseModel, Field


class ItemCreate(BaseModel):
    name: str = Field(min_length=1, max_length=100)


class ItemRead(ItemCreate):
    id: int

入力モデルと出力モデルを分けると、保存用の内部データをそのまま返す事故を防ぎやすくなります。
FastAPIはレスポンスモデルをOpenAPIへ反映し、宣言した形に合わせて出力を検証、変換、絞り込みます。

serviceはユースケースの入口にする

serviceには、「商品を登録する」「注文を確定する」のように、利用者が達成したい処理を置きます。
router名やHTTPステータスをserviceへ持ち込まなければ、バッチ処理や管理コマンドから同じルールを再利用できます。

一方、単純なCRUDを機械的に何層にも包むと、処理を追うためのファイルだけが増えます。
業務ルールがなく、routerから一つのデータアクセス関数を呼ぶだけなら、serviceを作らない判断も妥当です。

依存性注入をテスト境界として使う

FastAPIの依存性注入は、認証、設定、データベースセッション、外部クライアントなどをパス操作へ渡す仕組みです。
関数の引数に依存関係が現れるため、routerが暗黙のグローバル状態へ触れずに済みます。

テストではapp.dependency_overridesを使い、外部サービスへ接続する依存関係を偽物へ置き換えられます。
これにより、通信費や外部障害に左右されず、APIの振る舞いだけを検証できます。

from fastapi.testclient import TestClient

from app.dependencies import get_item_service
from app.main import app


def get_fake_service():
    return FakeItemService()


def test_create_item():
    app.dependency_overrides[get_item_service] = get_fake_service
    try:
        response = TestClient(app).post(
            "/items",
            json={"name": "Keyboard"},
        )
        assert response.status_code == 201
        assert response.json()["name"] == "Keyboard"
    finally:
        app.dependency_overrides = {}

依存関係の差し替えを前提にすると、テストのためだけに本番コードへ条件分岐を足す必要がありません。
FastAPI公式の依存関係を上書きするテストでも、この辞書を使う方法が案内されています。

設定と共有資源の寿命を分ける

データベースURLや外部サービスの接続先は、ソースコードへ直書きせず設定オブジェクトへ集約します。
FastAPIの設定ガイドはPydantic Settingsと依存性注入を組み合わせ、テスト時に設定を差し替える構成を示しています。

接続プールや大きなモデルなど、アプリ全体で共有する資源は、モジュールのimport時ではなくlifespanで初期化と解放を対にします。
import時の副作用を減らせば、単体テストが不要な接続や重い読み込みを始める問題も避けられます。

公式ドキュメントは、起動と終了の処理にlifespanコンテキストを使う方法を推奨しています。
yieldより前で資源を用意し、後ろで確実に解放するため、取得と後始末の対応がコード上で明確になります。

非同期処理はファイル分割とは別に判断する

ファイルを分けても、ブロッキングI/OやCPU負荷の高い処理は自動では解消しません。
利用するデータベースやHTTPクライアントがawaitに対応しているならasync defを使い、同期ライブラリしかない処理は通常のdefか適切な実行基盤へ分けます。

CPUを長時間占有する画像変換や大規模計算は、非同期化だけで同時実行性が上がるとは限りません。
ワーカープロセスやジョブキューへ逃がすかは、処理時間、再試行、結果の受け渡しを含めて決めます。

保守性を崩しやすい構成

既存の1ファイルAPIを移行する手順

  1. 現在のエンドポイントと入出力モデルをテストで固定する。
  2. 機能単位でAPIRouterを作り、URLとHTTP処理を移す。
  3. 入力と出力のPydanticモデルをschemasへ移す。
  4. 複数のエンドポイントで共有する業務処理だけをserviceへ抽出する。
  5. 設定、データベースセッション、外部クライアントを依存関係として渡す。
  6. 共有資源の初期化と解放をlifespanへ移す。
  7. 依存関係をテスト用実装へ差し替え、HTTP境界とserviceを別々に検証する。

一度に全ファイルを移動する必要はありません。
各段階でテストを通し、OpenAPIの差分を確認すれば、公開契約を不用意に変えずに移行できます。

起動方法もプロジェクトに固定する

複数ファイル構成では、アプリの場所を毎回コマンドへ書くより、pyproject.tomlへエントリーポイントを記録したほうが実行方法を揃えられます。

[tool.fastapi]
entrypoint = "app.main:app"

FastAPI CLIの公式説明では、開発時のfastapi devは自動リロードが有効で、本番向けのfastapi runは自動リロードが無効になると説明されています。
本番運用では、この違いに加えてHTTPS終端、再起動、監視、ワーカー数、データベース移行も配備方式に合わせて設計します。

よくある質問

小さなAPIでも最初から分割すべきですか

エンドポイントが数個で、変更する人も用途も限られるなら、1ファイルから始めて構いません。
同じ設定や認証を繰り返し始めたとき、または一つのテストに不要な外部接続が増えたときが分割の目安です。

routerごとにserviceとrepositoryが必要ですか

必要ありません。
serviceは共有する業務ルールがあるとき、repositoryは永続化の詳細を切り離す価値があるときに追加します。

Pydanticモデルとデータベースモデルは共有できますか

単純な試作では共有できる場合もありますが、公開APIと保存形式は別の理由で変わります。
非公開項目の流出やDB変更によるAPI破壊を避けるには、少なくとも出力モデルを独立させるほうが安全です。

構成を変えたら性能も上がりますか

ファイル分割は主に変更容易性とテスト容易性を改善するもので、直接の性能対策ではありません。
性能は計測結果に基づき、I/O待ち、CPU処理、クエリ、シリアライズ、ワーカー構成を個別に調べます。

参考資料

モバイルバージョンを終了