👻

FastAPI × DDD における モデル設計の課題と解決策

2025/02/08に公開

1. はじめに

FastAPI を使って DDD (Domain-Driven Design) のアーキテクチャを採用すると、
データの永続化 (infra 層) と request のバリデーション (router 層) で 異なる目的のモデルが必要 になります。

しかし、

  • どこまでモデルを分けるべきか?
  • リクエストモデルとドメインモデルを統一できないか?
  • 永続化処理で id を扱うべきか?

といった 設計の迷い が出てきます。

本記事では、DDD を適用する中で直面したモデル設計の課題とその解決策 について整理していきます。

2. 課題と解決策

課題 ① id を含めるべきか?

DDD では idOptional にするかどうか で悩むことが多いです。
たとえば、以下の User モデルを考えてみます。

📝 ユーザーを表す User モデル

from typing import Optional
from pydantic import BaseModel

class User(BaseModel):
    id: Optional[int]  # ID はオプショナル
    name: str
    age: int

このモデルを POST のリクエストとして使うと、Swagger で id が表示されてしまう という問題があります。


解決策 ① id をリクエストモデルから分離

FastAPI の Swaggerid を含めたくない場合、リクエスト専用の CreateUserRequest を作る ことで解決できます。

📝 Request モデル

class CreateUserRequest(BaseModel):
    name: str
    age: int

🎯 メリット

  • POST /users のリクエストに id が表示されなくなる
  • リクエストバリデーションの責務を Request モデルに分離できる
  • リクエストの仕様が明確になる

課題 ② Create / Update のモデルが増えすぎる

CreateUserRequest を作ると、UpdateUserRequest も必要になる という問題があります。

📝 UpdateUserRequest を作成

class UpdateUserRequest(BaseModel):
    id: int  # 更新時は ID が必須
    name: str
    age: int

問題点

  • CreateUserRequest / UpdateUserRequest でほぼ同じフィールドを持つ
  • API が増えると、Create / Update / Delete ごとに似たようなモデルが増えてしまう

解決策 ② BaseUser を作成し、Create / Update を継承

リクエストの共通部分 (name, age) を BaseUser にまとめることで、
モデルの重複を減らすことができます。

📝 BaseUser を作成

class BaseUser(BaseModel):
    name: str
    age: int

class CreateUserRequest(BaseUser):
    pass  # ✅ ID は含まない

class UpdateUserRequest(BaseUser):
    id: int  # ✅ ID は必須

🎯 メリット

  • Create / Update の重複を減らせる
  • BaseUser に共通のバリデーションロジックを定義できる
  • Request モデルの構造が明確になる

課題 ③ infra 層のインターフェースでどのモデルを使うべきか?

DDD では infra 層は データベースへのアクセスのみを責務とする ため、

  • infra 層で pydantic を使うのか?
  • infra 層のインターフェース (IUserRepository) で CreateUserRequest などを使うべきか?
  • id を使わない create でも id を含めた User を使うべきか?

といった疑問が生まれます。

解決策 ③ infra 層は User モデルを統一的に使用

infra 層では 作成 (create_user) でも User を使う が、
idOptional にして infra 層のロジックで使うか決める

📝 domain/model/user.py

class User:
    def __init__(
        self, 
        id: Optional[int] = None,  # `id` を含めるが、使うかどうかは `infra` 層の責務
        name: str = "",
        age: int = 0,
    ):
        self.id = id
        self.name = name
        self.age = age

このモデルを infra 層のリポジトリで使用する

リクエストの変換

router では CreateUserRequestUser に詰め替える。

from fastapi import APIRouter, Depends
from app.domain.repository.user import IUserRepository
from app.domain.model.user import User, CreateUserRequest, UpdateUserRequest

router = APIRouter()

def to_create_model(request: CreateUserRequest) -> User:
    """リクエストモデルを `User` に変換"""
    return User(name=request.name, age=request.age)

@router.post("/users", response_model=User)
async def create_user(
    request: CreateUserRequest,
    user_repo: IUserRepository = Depends()
):
    user = to_create_model(request)  
    return user_repo.create_user(user)

🎯 メリット

  • infra 層は User だけを扱うので統一感が出る
  • リクエストを infra のモデルに詰め替えるのは router の責務とする
  • FastAPI の Swagger に不要な id が表示されない

📌 まとめ

課題 解決策 メリット
idPOST で表示させたくない CreateUserRequest を作る Swagger で id を非表示
Create / Update モデルが増える BaseUser を作り、Create / Update を継承 モデルの重複を減らせる
infra 層でどのモデルを使うか User を統一的に使用し、id の扱いは infra で決める infrarepository の一貫性を保てる
  1. Request モデル (CreateUserRequest / UpdateUserRequest) を作成する
  2. infra 層は User を統一的に使用し、id の扱いは infra の責務とする
  3. routerRequestUser に変換して repository に渡す
  4. この設計により、Swagger の問題を回避しつつ、infrarepository も統一的に管理できる

Discussion