🍺

Pydantic の居場所は “境界” がちょうどいい

に公開

はじめに

Pydantic はとても便利である。だがこれを過信してドメインモデルの中心に据えると、責務の境界が曖昧になり、設計はたいてい溶ける。戦略なく溶け固まった巨大なコードベースは、ソフトウェアの迅速かつ安全な進化を困難にするだけでなく、LLM-based な AI の推論の安定性も低下させる。

LLM-based な AI エージェントは、構造が論理的に明示されたコード(型・境界・不変条件)ほど扱いやすい傾向がある。型定義には datetime と書いてあるのに、実態はバリデータで文字列を無理やり変換して受け入れるような「嘘」があると、コードだけを読む AI は挙動を正しく予測できない。

Pydantic がカバーしてくれる値検証と変換の責務は、静的型安全性Always-Valid なドメイン を志向すればするほど、自然とドメインから剥がれていく。

この話は「Pydantic を捨てるべきか?」ではない。「どこを Pydantic に任せるべきか?」である。答えは単純で、境界(入力・出力・設定)以外は慎重でいい

結論と前提整理

最初に結論を共有しておくと、本記事の主張は Pydantic は境界で使おう という一点に集約される。Pydantic 自体を否定したいわけではなく、あくまで どこに置くと設計が保たれやすいか という観点で整理していく。ここで言う境界とは、アプリケーション外部との入出力や設定値の読み込みなど、非構造化なデータを内部の定義型に変換する地点を指す。

なお、最近話題となった次の記事でも取り上げられたように、SQLModel + FastAPI を用いて Pydantic を永続化モデルの中心に据えるような設計も存在する。これは CRUD 中心でドメインルールが薄い場合には非常に有効である。
https://zenn.dev/livetoon/articles/9923c448c2734c

一方で、本記事で前提とするような 静的型安全性を強く意識したドメイン設計 では、同じ選択が必ずしも最適とは限らない。

本記事では、この境界に置く型を 境界モデル、ドメイン側に置く型を ドメインモデル と呼ぶ。境界モデルは外部都合で揺れる。ドメインモデルは意味と不変条件で安定させる。ここを混ぜないことが目的である。

なぜ仲良くしすぎると壊れるのか

Pydantic をドメインの中心に据えると、壊れ方はだいたい似てくる。最初は入力の検証に使っているだけのつもりでも、気づくとモデルが増殖し、責務が混ざり始める。

  • 値の整形と業務ルールが validator に混ざる
  • シリアライズ都合(別名、後方互換、オプショナル)がドメインに焼き付く
  • DB 都合(JOIN、遅延ロード、nullable)がドメインの形を決め始める

結果として、レビュー時に境界の掃除なのか、ドメインの不変条件なのかが判別しづらくなる。さらに LLM による修正やリファクタでも、バリデータの中に隠れた「暗黙の仕様」は見落とされやすく、安全に触れる範囲が見えにくくなる。

ここからは、実際に起きがちな悪い例と、そこから抜けるための良い例を出す。

生々しい失敗例

弊社でも、一部すごく良くない使い方をした箇所があった。Pydantic モデルを「神モデル」として扱い、DB モデルから辞書化して ** で流し込み、validator で暗黙の変換を積み重ねた。見た目は短いが、責務が一箇所に押し込まれていく。

その沼の雰囲気を再現するとこうなる。型ヒントと実態が乖離し始めるのが特徴である。

# v1 っぽい例(from_orm / validator で寄せる)
from datetime import datetime
from pydantic import BaseModel, validator

# 実際には DTO 以上の責務を負わされた「万能モデル」
class UserUnifiedModel(BaseModel):
    id: str
    email: str
    # 型ヒントは datetime だが、実態は str も許容して内部で変換する
    # 静的解析や LLM はここが datetime だと信じてしまう
    created_at: datetime
    is_active: bool

    class Config:
        orm_mode = True  # from_orm のため

    @validator("email")
    def normalize_email(cls, v: str) -> str:
        return v.strip().lower()

    @validator("created_at", pre=True)
    def coerce_created_at(cls, v):
        # DB の型やフォーマット揺れ、API 入力の揺れをここで吸収し始める
        # これが「暗黙の変換」の温床
        if isinstance(v, str):
             # 本来はパース処理を書くが省略
             return datetime.fromisoformat(v)
        return v

    def can_login(self) -> bool:
        # いつの間にかドメインルールも混ざる
        return self.is_active and self.email != ""

さらに悪化すると、DB モデルから __dict__model_to_dict 的なもので取り出して UserUnifiedModel(**data) を大量に作り、暗黙変換に依存するようになる。入力と出力、永続化、ドメインルールが同居し、どれかが変わると全体が連鎖的に壊れる。

この構成で一番きつかったのは、Pydantic v2 への移行である。表面的には API が変わるだけに見えるが、実際には 「バリデータ実行順序」や「pre=True の挙動」に依存したビジネスロジック が埋まっているため、移行が単なる置換では済まない。

  • from_orm に依存した構造は v2 で model_validate 等の別の入口になる
  • 緩い型変換(Coercion)を前提にしたコードが、v2 の Strict モード思想と衝突する
  • 変換が散っているので、何を保証しているか説明できない

つまり v2 移行に失敗したのは、API が変わったからではなく、設計が溶けていたからである。

静的型安全性を突き詰めると起きる必然

Python においての静的型はあくまで型ヒントであり、実行時の値の妥当性を保証しない。外部入力は壊れている可能性があるので、値検証は必要である。ただし Always-Valid なドメインを志向するなら、ドメインは作れた時点で正しい状態に寄せるべき。

この前提に立つと、外部入力の検証と変換は境界側に寄っていく。境界で受けた非構造化データを境界モデルで受け止め、明示的に整形し、ドメインモデルに変換する。ドメイン内部ではその前提を信じて処理する。これが一番扱いやすい。

良い例:境界で一発変換して、ドメインを静かに保つ

ここからは、Pydantic を境界に閉じる例である。ポイントは次の3つだけである。

  • Pydantic は入力 DTO と出力 DTO に置く
  • ドメインモデルは不変に寄せ、作成時に不変条件を満たす
  • 変換は明示的な関数で行う
from __future__ import annotations
from dataclasses import dataclass
from datetime import datetime
from typing import NewType

# pip install "pydantic[email]" が必要
from pydantic import BaseModel, EmailStr, Field

UserId = NewType("UserId", str)

# 境界: 入力 (Pydantic)
class CreateUserInput(BaseModel):
    # バリデーションはここ(境界)で終わらせる
    email: EmailStr = Field(min_length=3)
    # 入力時点では None かもしれない
    created_at: datetime | None = None

# ドメイン: Always-Valid 寄せ (標準ライブラリ)
@dataclass(frozen=True, slots=True)
class User:
    id: UserId
    email: str
    created_at: datetime
    is_active: bool

    def __post_init__(self) -> None:
        # ドメインの不変条件
        if not self.email:
            raise ValueError("email must not be empty")

# 変換層: 明示的なマッピング
def to_domain(input: CreateUserInput, *, new_id: UserId, now: datetime) -> User:
    # 欠損値の補完や型変換をここで行う(ロジックの可視化)
    created_at = input.created_at or now
    
    return User(
        id=new_id,
        email=str(input.email), # 明示的に str 化して渡す
        created_at=created_at,
        is_active=True,
    )

# 境界: 出力 (Pydantic)
class UserOutput(BaseModel):
    id: str
    email: EmailStr
    created_at: datetime
    is_active: bool

def to_output(user: User) -> UserOutput:
    return UserOutput(
        id=user.id,
        email=user.email,
        created_at=user.created_at,
        is_active=user.is_active,
    )

この形にすると、変換が目に見える。どこで何を整形しているかが分かる。ドメインの不変条件も、ドメインに閉じる。入力の揺れや互換対応は境界に閉じる。これだけで壊れにくくなる。IDE や Linter の恩恵も受けやすい。

v1 から v2 に上げられない問題の正体

ここで一つ厳しい話をしておく。Pydantic v2 移行が辛い箇所は、だいたい設計が溶けている。移行できない理由を API 差分のせいにしたくなるが、実際には神モデル化と暗黙変換が足を引っ張っていることが多い。

たとえば v2 では from_orm 相当の入口が変わる。v2 では ORM オブジェクトから読む場合、入口は model_validate 側に寄り、from_attributes を明示する形になる。

# v2 っぽい入口の例(雰囲気)
from pydantic import BaseModel, ConfigDict

class UserDTO(BaseModel):
    model_config = ConfigDict(from_attributes=True)
    id: str
    email: str

# UserDTO.model_validate(orm_obj) のように入る

しかし本質はそこではない。問題は変換が散っていて、保証が説明できないことだ。境界とドメインを分けておけば、v2 移行は境界側で完結する。神モデルのままだと、移行はドメイン全体に波及する。

境界に寄せると得られるメリット

  • ドメインが濃くなる:不変条件と状態遷移に集中できる。
  • テストが分離される:入力値検証(境界テスト)とビジネスロジック(ドメインテスト)が混ざらない。
  • 将来の置換が容易になる:Pydantic のバージョンアップや、他のライブラリへの変更が境界に閉じる。

さらに、LLM を使った修正でも安全になる。境界とドメインが分かれていると、触ってよい領域が明確になる。「ここはバリデーションロジック」「ここはビジネスロジック」とコンテキストが分断されているため、AI が意図せず破壊的な変更を加えるリスクも減らせる。

まとめ:Pydantic の居場所は “境界” がちょうどいい

Pydantic は最高である。外部入力を安全に内部の型へ落とす用途では強い。だからこそ、境界で使うのがよい。ドメインなどのコアなレイヤーにまで不用意に広げると、便利さの代償として責務が混ざりやすい。

ただし例外はある。CRUD 中心でドメインルールが薄い場合、統合されたモデルは有効になり得る。プロトタイピングや社内ツールでも同様である。重要なのは、どの世界観を前提にしているかを自覚することである。

最後に、迷ったときの目安だけ置いておく。

  • それは外部入力の都合で変わるか。変わるなら境界で扱う。
  • それは業務の意味や不変条件か。そうならドメインで扱う。
  • それは互換対応やスキーマの都合か。そうなら境界で扱う。

Discussion