green snake
Photo by Pixabay on Pexels.com
目次

FastAPI×クリーンアーキテクチャ実践ガイド:ルーター分割・サービス層・リポジトリパターンで中長期に保守しやすいAPIを育てる


はじめに:この記事で目指すゴール

FastAPIで小さなAPIを作るところまでは、比較的すぐに到達できますよね。
ただ、ユーザーや機能が増えてくると、

  • どこに処理を書けばよいのか分からない
  • 1つのファイルがどんどん巨大になっていく
  • 仕様変更のたびに、あちこち書き換える必要があって怖い

といった悩みが、少しずつ顔を出してきます。

この記事では、FastAPIアプリを「中長期で保守しやすい形」に育てていくための クリーンアーキテクチャ的な設計パターン を、具体的なディレクトリ構成やコード例と一緒に整理していきます。


誰が読んで得をするか(具体的な読者像)

  • 個人開発・学習者さん
    小さなFastAPIアプリがそこそこ大きくなってきて、「main.py がパンパン…」「routers がカオス…」と感じている方。
    → レイヤー分割とサービス層・リポジトリパターンの基本を押さえて、無理なく整理していく道筋が見えます。

  • 小規模チームのバックエンドエンジニアさん
    3〜5人くらいでFastAPIベースのWeb APIを開発していて、機能が増えるにつれて「人によって書き方がバラバラ」になりつつあるチーム。
    → ルールを押し付けすぎずに、でも最低限の共通パターンを共有できる、現実的なアーキテクチャの雛形を持ち帰れます。

  • SaaS開発中のスタートアップチームさん
    「とりあえず動く」状態から、「いつ仕様変更が来ても怖くない」状態へとステップアップしたい方。
    → ドメインごとに責務を整理し、テストしやすく分割した構成への移行イメージが掴めます。


アクセシビリティ評価(読みやすさの配慮)

  • 各章は見出しを立てて区切り、1〜3段落程度のボリュームにしつつ、必要なところだけコード例を挟んでいます。
  • 専門用語(クリーンアーキテクチャ、レイヤードアーキテクチャ、リポジトリ、ユースケースなど)は、初出時にできるだけ噛み砕いた説明を添えています。
  • コードは固定幅フォントのブロックで示し、コメントは最低限にとどめて視線が迷わないようにしています。
  • 想定読者は「PythonとFastAPIをある程度触った方」ですが、章ごとに独立して読めるようにしました。

全体として、技術記事としての読みやすさ・理解しやすさの面で、WCAGのAA程度を意識しています。


1. なぜアーキテクチャが必要なのか:ファットなルーターの限界

まずは「なぜわざわざレイヤーを分けるのか」というところを、軽く整理しておきますね。

最初のうちは、こんなコードでも十分動きます。

# app/main.py に全部書いてしまうパターン(よくある始まり)
from fastapi import FastAPI, HTTPException
from pydantic import BaseModel
from sqlalchemy import create_engine, text

app = FastAPI()
engine = create_engine("sqlite:///./app.db", echo=True)

class Article(BaseModel):
    id: int | None = None
    title: str
    body: str

@app.post("/articles", response_model=Article)
def create_article(article: Article):
    with engine.begin() as conn:
        res = conn.execute(
            text("INSERT INTO articles (title, body) VALUES (:t, :b) RETURNING id"),
            {"t": article.title, "b": article.body},
        )
        article.id = res.scalar_one()
    return article

小さなサンプルとしてはかわいいのですが、ここに以下が混ざっていきます。

  • 認証・認可(ユーザー権限)
  • バリデーションの枝分かれ
  • ビジネスロジック(下書き→公開などの状態遷移)
  • DBアクセスや外部API呼び出し
  • ログ・トランザクション制御・トレースなど

気づくと、1つのエンドポイントで100行近くになり、「どこを直せばいいのか…?」という状態になりがちです。

アーキテクチャの目的は、とてもざっくり言えば、

  • 変更の影響範囲を小さくする
  • 役割ごとにコードを分けて、頭を切り替えやすくする
  • テストを書きやすくする(とくにビジネスロジック部分)

この3つに尽きます。


2. レイヤー構造の基本:どんな「層」を分けるか

クリーンアーキテクチャやレイヤードアーキテクチャには、さまざまな流儀がありますが、FastAPIで現実的に採用しやすい形としては、次のようなレイヤーを想像してみてください。

  1. プレゼンテーション層(APIルーター)
  2. アプリケーション層(サービス/ユースケース)
  3. ドメイン層(ドメインモデル・ビジネスルール)
  4. インフラ層(DB・外部API・メッセージングなど)

ディレクトリ構成の一例はこんな感じです。

app/
  api/
    v1/
      routers/
        articles.py       # ルーター(エンドポイント定義)
  core/
    settings.py           # 設定・共通インフラ
    logging.py
  domain/
    articles/
      models.py           # ドメインモデル
      services.py         # ユースケース(サービス層)
      repositories.py     # リポジトリIFと実装
      schemas.py          # Pydanticスキーマ(API用)
  infra/
    db/
      base.py             # Base, SessionLocal
    articles/
      sqlalchemy_repo.py  # SQLAlchemyベースのリポジトリ実装
  main.py

ポイントは、

  • ルーターには「HTTPまわり」だけを書く(認証情報の取り出しやリクエスト/レスポンスの変換など)
  • ビジネスロジックはサービス層に集める
  • DBアクセスはリポジトリに閉じ込める

という役割分担です。最初から完璧に分ける必要はなく、少しずつ移していくイメージで大丈夫です。


3. ルーターの責務を絞る:薄いエンドポイントを目指す

まずは、ルーターの側から見ていきましょう。
理想としては、1つのエンドポイントの中身は下記のようなステップに収まると、とても読みやすくなります。

  1. リクエストパラメータやボディを受け取る
  2. 認証ユーザーやテナント情報などを取り出す
  3. サービス層の関数・メソッドを呼び出す
  4. 結果をレスポンス用スキーマに詰めて返す

サンプルとして、「記事を作成する」エンドポイントを少し整理してみます。

# app/api/v1/routers/articles.py
from fastapi import APIRouter, Depends, status
from app.domain.articles.schemas import ArticleCreate, ArticleRead
from app.domain.articles.services import ArticleService
from app.deps.services import get_article_service
from app.deps.auth import get_current_user

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

@router.post(
    "",
    response_model=ArticleRead,
    status_code=status.HTTP_201_CREATED,
)
def create_article(
    payload: ArticleCreate,
    service: ArticleService = Depends(get_article_service),
    current_user=Depends(get_current_user),
):
    # HTTPに近い部分だけを書く
    article = service.create_article(
        author_id=current_user.id,
        data=payload,
    )
    return article

ここでは、

  • ArticleCreate / ArticleRead は API用のPydanticスキーマ
  • ArticleService はビジネスロジックを担うサービス層
  • get_current_user は認証済みユーザーを取り出す依存関数

という形で、ルーターは「受け取りと渡し役」にだけ集中させています。


4. サービス層(ユースケース)を定義する

次に、ビジネスロジックを受け止めるサービス層を見ていきます。
ここでは、記事の作成・更新・公開などをまとめた ArticleService を例にしますね。

# app/domain/articles/services.py
from dataclasses import dataclass
from typing import Protocol
from app.domain.articles.schemas import ArticleCreate, ArticleRead
from app.domain.articles.models import Article

class ArticleRepository(Protocol):
    def add(self, article: Article) -> Article: ...
    def get(self, article_id: int) -> Article | None: ...
    # 他にもfind_by_authorなど必要に応じて

@dataclass
class ArticleService:
    repo: ArticleRepository

    def create_article(self, author_id: int, data: ArticleCreate) -> ArticleRead:
        article = Article(
            title=data.title,
            body=data.body,
            author_id=author_id,
            status="draft",   # 初期状態は下書き
        )
        saved = self.repo.add(article)
        return ArticleRead.model_validate(saved)

ここでは、

  • ドメインモデルとしての Article(後で出てきます)を生成
  • 初期状態やルール(ここでは status="draft")をここで決める
  • リポジトリに保存を依頼し、戻ってきた結果をレスポンス用スキーマへ変換

という流れになっています。

サービス層のよいところは、

  • HTTPに依存しない(FastAPIじゃなくて別のフレームワークでも使える)
  • テストしやすい(リポジトリだけモックすれば良い)
  • 仕様変更の影響範囲を限定しやすい(ここを見に来ればルールが分かる)

という点にあります。


5. ドメインモデルとPydanticスキーマを分ける

ビジネスルールを扱う中心には、「ドメインモデル」をおいておくと考えやすくなります。
ここでは簡略化のために、SQLAlchemyモデルとドメインモデルを分けず、SQLAlchemyモデルをそのままドメインモデルとして扱うパターンを示します。

# app/domain/articles/models.py
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
from sqlalchemy import Integer, String, Text, ForeignKey

class Base(DeclarativeBase):
    pass

class Article(Base):
    __tablename__ = "articles"

    id: Mapped[int] = mapped_column(Integer, primary_key=True)
    title: Mapped[str] = mapped_column(String(200), nullable=False)
    body: Mapped[str] = mapped_column(Text, nullable=False)
    author_id: Mapped[int] = mapped_column(ForeignKey("users.id"), nullable=False)
    status: Mapped[str] = mapped_column(String(20), default="draft", nullable=False)

API用のPydanticスキーマとは、責務を分けておきます。

# app/domain/articles/schemas.py
from pydantic import BaseModel

class ArticleCreate(BaseModel):
    title: str
    body: str

class ArticleRead(BaseModel):
    id: int
    title: str
    body: str
    author_id: int
    status: str

    class Config:
        from_attributes = True

このように、

  • 外部(HTTP)の世界で使う型(ArticleCreate, ArticleRead
  • 内部(DB・ドメイン)の世界で使う型(Article

を分けておくことで、「API仕様」と「内部実装」をゆるく切り離すことができます。


6. リポジトリパターンでDBアクセスを隔離する

次は、インフラ層であるリポジトリです。
サービス層から見ると、「記事を保存してくれる何か」であればOKなので、そのインターフェイス(Protocol)を先に決めておきます。

# app/domain/articles/repositories.py
from typing import Protocol
from app.domain.articles.models import Article

class ArticleRepository(Protocol):
    def add(self, article: Article) -> Article: ...
    def get(self, article_id: int) -> Article | None: ...

実際のSQLAlchemy実装は、インフラ層に置きます。

# app/infra/articles/sqlalchemy_repo.py
from sqlalchemy.orm import Session
from app.domain.articles.models import Article
from app.domain.articles.repositories import ArticleRepository

class SqlAlchemyArticleRepository(ArticleRepository):
    def __init__(self, db: Session):
        self.db = db

    def add(self, article: Article) -> Article:
        self.db.add(article)
        self.db.flush()   # idを採番
        self.db.refresh(article)
        return article

    def get(self, article_id: int) -> Article | None:
        return self.db.get(Article, article_id)

こうしておくと、

  • サービス層は「ArticleRepository」という抽象にだけ依存
  • DBを変えたくなった場合(SQLite → PostgreSQLなど)も、このインフラ層の実装を差し替えるだけで済む
  • 将来的に外部APIベースの実装や、メモリ内のダミー実装に切り替えることも容易

という柔らかい構造にしていけます。


7. FastAPIの依存性注入でレイヤー同士をつなぐ

さて、ルーター・サービス・リポジトリを分けたあとは、「どうやってつなぐか」という話になります。ここで、FastAPIの依存性注入(Depends)が活きてきます。

7.1 DBセッションの依存

# app/infra/db/base.py
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker

engine = create_engine("sqlite:///./app.db", connect_args={"check_same_thread": False})
SessionLocal = sessionmaker(bind=engine, autoflush=False, autocommit=False)

def get_db():
    db = SessionLocal()
    try:
        yield db
    finally:
        db.close()

7.2 リポジトリとサービスの依存を組み立てる

# app/deps/services.py
from fastapi import Depends
from sqlalchemy.orm import Session
from app.infra.db.base import get_db
from app.infra.articles.sqlalchemy_repo import SqlAlchemyArticleRepository
from app.domain.articles.services import ArticleService

def get_article_repository(db: Session = Depends(get_db)):
    return SqlAlchemyArticleRepository(db)

def get_article_service(repo=Depends(get_article_repository)) -> ArticleService:
    return ArticleService(repo=repo)

これで、先ほどのルーターからはただ get_article_serviceDepends すればよい、という形になります。

# app/api/v1/routers/articles.py(おさらい)
@router.post("", response_model=ArticleRead)
def create_article(
    payload: ArticleCreate,
    service: ArticleService = Depends(get_article_service),
    current_user=Depends(get_current_user),
):
    return service.create_article(author_id=current_user.id, data=payload)

レイヤーを分けつつ、FastAPIの「注入パイプライン」をうまく利用するイメージですね。


8. トランザクション管理とUnit of Workパターン(軽く触れておきます)

少し発展的な話ですが、ビジネスロジックが複数リポジトリにまたがってくると、「どこでトランザクションを開始・コミットするか」という問題が出てきます。

  • サービス層のメソッド(ユースケース)を1トランザクションの単位とする
  • Unit of Work(作業単位)というオブジェクトで、「開始→複数リポジトリ操作→コミット/ロールバック」をまとめる

という方針を取ると、整理しやすくなります。

簡略化した例だけ触れておきますね。

# app/infra/db/unit_of_work.py
from contextlib import AbstractContextManager
from dataclasses import dataclass
from sqlalchemy.orm import Session
from app.infra.db.base import SessionLocal

@dataclass
class UnitOfWork(AbstractContextManager):
    session: Session | None = None

    def __enter__(self):
        self.session = SessionLocal()
        return self

    def __exit__(self, exc_type, exc, tb):
        try:
            if exc_type is None:
                self.session.commit()
            else:
                self.session.rollback()
        finally:
            self.session.close()

サービス層では、こんなイメージで使えます。

from app.infra.db.unit_of_work import UnitOfWork
from app.infra.articles.sqlalchemy_repo import SqlAlchemyArticleRepository

def create_article_with_uow(author_id: int, data: ArticleCreate) -> ArticleRead:
    with UnitOfWork() as uow:
        repo = SqlAlchemyArticleRepository(uow.session)
        article = Article(
            title=data.title,
            body=data.body,
            author_id=author_id,
            status="draft",
        )
        saved = repo.add(article)
        return ArticleRead.model_validate(saved)

実務では、Unit of Work自体を依存性としたり、複数のリポジトリが同じセッションを共有できるように設計していきます。


9. テストのしやすさを高める:サービス層だけをテストしてみる

レイヤー分割の「ご利益」が一番実感しやすいのが、テストの場面です。

9.1 サービス層のユニットテスト

先ほどの ArticleService は、リポジトリに対してProtocol(インターフェイス)だけを要求していました。
テストでは、実際にDBにはつながず、メモリ上のモックを渡してしまいましょう。

# tests/test_article_service.py
from app.domain.articles.services import ArticleService
from app.domain.articles.schemas import ArticleCreate
from app.domain.articles.models import Article

class InMemoryArticleRepo:
    def __init__(self):
        self.items: list[Article] = []
        self._next_id = 1

    def add(self, article: Article) -> Article:
        article.id = self._next_id
        self._next_id += 1
        self.items.append(article)
        return article

    def get(self, article_id: int) -> Article | None:
        for a in self.items:
            if a.id == article_id:
                return a
        return None

def test_create_article_sets_draft_status():
    repo = InMemoryArticleRepo()
    service = ArticleService(repo=repo)

    data = ArticleCreate(title="Hello", body="World")
    result = service.create_article(author_id=1, data=data)

    assert result.status == "draft"
    assert result.title == "Hello"
    assert result.author_id == 1

HTTPやDBのことを考えなくて済むので、かなり気軽にテストが書けるのではないでしょうか。
これが、そのままリグレッションテスト(仕様変更時の安心材料)として効いてきます。


10. 機能単位でのモジュール分割:スケールしても迷子にならないために

ディレクトリ構成は、プロジェクトの規模やチームの好みによって変わりますが、FastAPIでは「機能(ドメイン)ごとにまとめる」構成をおすすめされることが多いです。

先ほどの例も、

domain/
  articles/
    models.py
    services.py
    repositories.py
    schemas.py
  users/
    models.py
    services.py
    ...

というように、機能ごとに「小さい世界」を作ってしまうイメージでした。

メリットとしては、

  • ある機能に関するファイルが近くに集まるので、読みやすい
  • チーム内で機能ごとに担当を分けやすい(articles担当・users担当など)
  • 特定の機能だけを切り出して別サービスに分離する、という将来の選択肢も残しやすい

といった点が挙げられます。

逆に、「層ごと(controller, service, repository)」にディレクトリを分けてしまうと、特定の機能に関する理解のために、あちこちファイルを飛び回ることになりがちです。
どちらが絶対の正解というわけではありませんが、FastAPIのようなWeb APIでは「機能ごと」が相性が良いことが多いと感じます。


11. よくあるアンチパターンと、その緩やかな脱出方法

ここまで良い話ばかりしてきましたが、現実には次のような「あるあるアンチパターン」に悩まされることも多いです。

パターン 何がつらいか どう脱出するか
main.py に全部書いてある 1ファイルが巨大で読みづらい まずはルーターを分割して api/routers に移す
ルーターが太りすぎ HTTPとビジネスロジックが混在 サービス層用のモジュールを1つ作り、そこに移せる処理から移す
DBアクセスがあちこちから直書き 接続管理・トランザクションがバラバラ リポジトリクラスを導入し、まずは1機能だけでもDBアクセスをそこへ寄せる
テストが書きづらくて後回し 修正時に毎回手動で確認が必要 サービス層を切り出し、ユニットテストから少しずつ追加する

大事なのは、「いきなり全部きれいにしようとしない」ことです。
まずは1つのエンドポイントだけ、サービス層+リポジトリへと分割してみる。
それがうまくいったら、次の1つへ…というように、少しずつ移行していくのが心にもコードにも優しいと思います。


12. 読者別に見た、このアーキテクチャのインパクト

少し視点を変えて、「この構成にすることで、どんな良いことがあるか」を、先ほどの読者像ごとに整理してみますね。

  • 個人開発・学習者さん

    • 将来、機能追加を続けても「負債が積もって動かせなくなる」という状態を避けやすくなります。
    • 転職や案件参加の際にも、「こういう構成で作れます」と具体的に話せるようになるので、アピール材料にもなります。
  • 小規模チームのエンジニアさん

    • チーム内で「どこに何を書くか」の共通認識が生まれることで、コードレビューや共同開発がぐっと楽になります。
    • 新しく入ってきたメンバーにも、「この機能は domain/xxx を見れば大体わかるよ」と案内しやすくなります。
  • SaaS開発チームさん

    • ビジネスルールをサービス層に閉じ込めておくことで、将来の仕様変更時にも「ここを見ればルールが全部載っている」という安心感が得られます。
    • 一部機能だけ別サービスに切り出す、といったアーキテクチャの変更にも対応しやすくなります。

13. 導入ロードマップ:少しずつ整えていくために

最後に、「これからFastAPIアプリをクリーンアーキテクチャ風に整えていくときの道筋」を、段階的にまとめておきます。

  1. ルーターを分割する

    • main.pyからルート定義を切り出し、api/v1/routers/*.py にまとめる。
    • ここまでは、動作を変えずに「見通し」だけを良くするステップです。
  2. サービス層を1つだけ作ってみる

    • 代表的なドメイン(たとえばarticles)について、domain/articles/services.py を作り、1つのエンドポイントのロジックを移してみる。
  3. Pydanticスキーマとドメインモデルを分ける

    • API用のArticleCreateArticleReadschemas.pyにまとめ、ルーターからは主にそれだけを意識するようにする。
  4. リポジトリパターンを導入する

    • DBアクセスをSqlAlchemyArticleRepositoryのようなクラスに集約し、サービス層からはリポジトリの抽象にだけ依存するようにする。
  5. テストを書き始める

    • サービス層をモックリポジトリでテストするところから始め、徐々にユースケース単位のテストを増やしていく。
  6. 他のドメインへ横展開

    • users・comments・tags…といった他の機能にも、同様のパターンを少しずつ適用していく。
    • 必要に応じて、Unit of Workやイベントなど、もう一歩発展的なパターンも取り入れていく。

すべてを1度にやろうとすると、どうしても大変になってしまうので、「1機能ずつ」「1エンドポイントずつ」の小さな単位で育てていくことをおすすめします。


参考リンク(さらに深く学びたい方向け)

※ここでは一般的なクリーンアーキテクチャやFastAPIの設計に関する情報源を挙げています。
最新版は各サイトや書籍でご確認ください。


おわりに

FastAPIは、とても軽やかに「まず動くもの」を作らせてくれるフレームワークです。
その一方で、気づくと main.py やルーターがどんどん肥大化していき、「手を入れるのが怖い…」という状態になってしまうことも少なくありません。

この記事でご紹介したような、レイヤー分割・サービス層・リポジトリパターンは、

  • ビジネスロジックを見つけやすくする
  • 変更の影響範囲を小さくする
  • テストを書きやすくする

ための、ひとつの「型」に過ぎません。
絶対的な正解ではありませんが、この型をベースにしながら、チームやプロジェクトに合わせて少しずつアレンジしていけば、きっと「長く付き合っていけるFastAPIアプリ」に育っていくと思います。

どうぞ無理のないペースで、まずは1つのエンドポイントから、少しずつ整えていってみてくださいね。
わたしも、あなたのAPIがすっきりと整理されて、より楽しく開発できるようになることを、そっと応援しています。


投稿者 greeden

コメントを残す

メールアドレスが公開されることはありません。 が付いている欄は必須項目です

日本語が含まれない投稿は無視されますのでご注意ください。(スパム対策)