Pydantic の居場所は “境界” がちょうどいい
はじめに
Pydantic はとても便利である。だがこれを過信してドメインモデルの中心に据えると、責務の境界が曖昧になり、設計はたいてい溶ける。戦略なく溶け固まった巨大なコードベースは、ソフトウェアの迅速かつ安全な進化を困難にするだけでなく、LLM-based な AI の推論の安定性も低下させる。
LLM-based な AI エージェントは、構造が論理的に明示されたコード(型・境界・不変条件)ほど扱いやすい傾向がある。型定義には datetime と書いてあるのに、実態はバリデータで文字列を無理やり変換して受け入れるような「嘘」があると、コードだけを読む AI は挙動を正しく予測できない。
Pydantic がカバーしてくれる値検証と変換の責務は、静的型安全性 と Always-Valid なドメイン を志向すればするほど、自然とドメインから剥がれていく。
この話は「Pydantic を捨てるべきか?」ではない。「どこを Pydantic に任せるべきか?」である。答えは単純で、境界(入力・出力・設定)以外は慎重でいい。
結論と前提整理
最初に結論を共有しておくと、本記事の主張は Pydantic は境界で使おう という一点に集約される。Pydantic 自体を否定したいわけではなく、あくまで どこに置くと設計が保たれやすいか という観点で整理していく。ここで言う境界とは、アプリケーション外部との入出力や設定値の読み込みなど、非構造化なデータを内部の定義型に変換する地点を指す。
なお、最近話題となった次の記事でも取り上げられたように、SQLModel + FastAPI を用いて Pydantic を永続化モデルの中心に据えるような設計も存在する。これは CRUD 中心でドメインルールが薄い場合には非常に有効である。
一方で、本記事で前提とするような 静的型安全性を強く意識したドメイン設計 では、同じ選択が必ずしも最適とは限らない。
本記事では、この境界に置く型を 境界モデル、ドメイン側に置く型を ドメインモデル と呼ぶ。境界モデルは外部都合で揺れる。ドメインモデルは意味と不変条件で安定させる。ここを混ぜないことが目的である。
なぜ仲良くしすぎると壊れるのか
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