コンテンツにスキップ
検索語を入力してください

    ドメインモデリング

    Kamae Pythonの中心は、ビジネス状態をPydantic v2の凍結モデルと kind 判別子で表すことだ。素の str や可変モデルに頼ると無効な中間状態が表現でき、境界を一度すり抜けた値がドメイン深部まで届く。

    ライフサイクル上の変化は 状態遷移、外部データの取り込みは 境界防御、ホットパスでの検証コストは Pydantic のパフォーマンス を参照する。

    ドメイン状態には Pydantic v2 のバリアントを使う

    Section titled “ドメイン状態には Pydantic v2 のバリアントを使う”

    Python 3.12以降とPydantic v2を前提とする。frozen=Trueextra="forbid" は、構築後の暗黙的な変更と未知フィールドの混入を防ぐための既定とする。各ビジネス状態を個別の凍結モデルとして定義し、プロジェクト全体で kind という名前の判別子を1つ使う。

    from datetime import datetime
    from typing import Annotated, Literal
    from uuid import UUID
    from pydantic import BaseModel, ConfigDict, Field, TypeAdapter
    class DomainModel(BaseModel):
    model_config = ConfigDict(frozen=True, extra="forbid")
    class Waiting(DomainModel):
    kind: Literal["waiting"] = "waiting"
    request_id: UUID
    passenger_id: UUID
    created_at: datetime
    class EnRoute(DomainModel):
    kind: Literal["en_route"] = "en_route"
    request_id: UUID
    passenger_id: UUID
    driver_id: UUID
    assigned_at: datetime
    class InTrip(DomainModel):
    kind: Literal["in_trip"] = "in_trip"
    request_id: UUID
    passenger_id: UUID
    driver_id: UUID
    started_at: datetime
    class Completed(DomainModel):
    kind: Literal["completed"] = "completed"
    request_id: UUID
    passenger_id: UUID
    driver_id: UUID
    started_at: datetime
    completed_at: datetime
    class Cancelled(DomainModel):
    kind: Literal["cancelled"] = "cancelled"
    request_id: UUID
    passenger_id: UUID
    cancelled_at: datetime
    reason: str
    type TaxiRequest = Annotated[
    Waiting | EnRoute | InTrip | Completed | Cancelled,
    Field(discriminator="kind"),
    ]
    TaxiRequestAdapter = TypeAdapter(TaxiRequest)

    JSON向けのPythonサービスでは、プロジェクトが別の規約を使っていない限り、判別子の値はlower snake caseを優先する。

    オプショナルな状態フィールドを持つ blob モデルを避ける

    Section titled “オプショナルな状態フィールドを持つ blob モデルを避ける”

    status: str と多数のオプショナルフィールドを持つ1つのモデルでワークフローを表現してはならない。オプショナルフィールドは無効な状態を表現可能にしてしまう。

    # Avoid this shape for domain state.
    class TaxiRequest(BaseModel):
    status: str
    request_id: UUID
    passenger_id: UUID
    driver_id: UUID | None = None
    assigned_at: datetime | None = None
    completed_at: datetime | None = None

    あるフィールドが1つの状態にしか存在しないなら、その状態のモデルで必須とする。

    ドメインのPydanticモデルには ConfigDict(frozen=True, extra="forbid") を設定する。状態の変更は既存モデルを変更するのではなく、新しいターゲット状態を構築する。強制変換がデータ品質の問題を隠す場合は、外部DTO境界で strict=True を検討する。

    公開セッター、部分更新ヘルパー、またはフィールド間不変条件を破る可能性のある model_copy(update=...) パスは避ける。更新がビジネスアクションなら、遷移かコマンドとして命名し、不変条件全体を検証させる。

    Pydantic mypyプラグインを有効にすると、frozenモデルは静的にもチェックされる。モデルフィールドへの代入は、実行時より前にmypyで失敗するはずだ。

    必要に応じてドメインモデルとトランスポート DTO を分離する

    Section titled “必要に応じてドメインモデルとトランスポート DTO を分離する”

    APIのJSON形状とドメイン状態が同じである必要はない。エンドポイント固有のフィールドや互換性のためのoptionalはDTOに置き、検証済みDTOからドメインモデルまたはコマンドへマップする。コアのドメイン状態に versiontenant_id のような永続化・認可の関心事を載せると、レスポンス用の model_dump やログ経路から漏れやすくなる。

    内部APIだけで、かつ形状が完全に一致し不変条件も同じなら共通化してもよい。迷ったときは分離を選ぶ。

    意味のある ID には明示的な値型を使う

    Section titled “意味のある ID には明示的な値型を使う”

    UUIDEmailStrHttpUrl、制約付き文字列、またはドメイン上の意味を持つ小さなfrozen Pydanticモデルなど、組み込みの精密型を使う。区別が重要なときは、無関係なIDを素の str として渡さない。

    from pydantic import StringConstraints
    from typing import Annotated
    RequestCode = Annotated[str, StringConstraints(pattern=r"^req-[0-9]{8}$")]

    Annotated エイリアスと typing.NewType は、実行時にはベース型と構造的に等価である。Mypy/pyrightは一部のミスを検出するが、両方が UUID のとき、passenger_iddriver_id が期待される場所に渡すのを止めるものはない。IDの取り違えがビジネス上の影響を持つ場合は、より強いパターンを優先する。

    名目的 ID には frozen ラッパーモデルを優先する

    Section titled “名目的 ID には frozen ラッパーモデルを優先する”

    各意味的IDを独自のfrozen Pydanticモデル(またはプロセス内専用IDには @dataclass(frozen=True, slots=True))で包む。構築時に形式を検証し、ラッパー型は兄弟型と交換できない。

    from uuid import UUID
    from pydantic import field_validator
    class PassengerId(DomainModel):
    value: UUID
    class DriverId(DomainModel):
    value: UUID
    class RequestId(DomainModel):
    value: UUID
    @field_validator("value")
    @classmethod
    def not_nil(cls, value: UUID) -> UUID:
    if value.int == 0:
    raise ValueError("request id must not be nil")
    return value

    遷移では異なるパラメータ名と型を使う:

    def assign_driver(waiting: Waiting, driver_id: DriverId, now: datetime) -> EnRoute:
    ...

    インスタンス化不可ベースの __init_subclass__ ガード

    Section titled “インスタンス化不可ベースの __init_subclass__ ガード”

    複数のID型が検証ロジックを共有するときは、直接のインスタンス化を拒否する抽象ベースを使う。サブクラスは別々の名目的型のままである。

    class SemanticId(DomainModel):
    value: UUID
    def __init_subclass__(cls, **kwargs: object) -> None:
    super().__init_subclass__(**kwargs)
    if cls is SemanticId:
    raise TypeError("SemanticId cannot be instantiated directly")
    class TenantId(SemanticId):
    pass
    class AccountId(SemanticId):
    pass

    ルールが異なる場合のみサブクラスごとのバリデータを追加する。コードベースがすでにそのパターンを標準化していない限り、単一の汎用 Id[T] ラッパーは使わない。

    アプローチ静的チェック実行時の分離
    UUID パラメータ名のみ弱いなし
    Annotated[UUID, ...] / NewType良いなし
    ID ごとの frozen ラッパーモデル良い良い(別型)
    正規表現制約付き str形状のみID 種別の分離なし

    実行時の取り違えが無害なら NewType は軽量なドキュメントとして許容される。金額、テナント境界、認証に敏感なIDにはラッパーモデルを使う。

    値の構築は、ドメインコンストラクタとPydanticアダプターを正規の入口とする。テスト、リポジトリ、ネイティブアダプター、マイグレーションは、破損データ処理が明示的な目的でない限り、生のdictや model_construct で不変条件を持つ値を構築してはならない。信頼できるマッパーで model_construct が適切な場合は Pydantic のパフォーマンス を読む。

    プロトコルでリポジトリポートを定義する

    Section titled “プロトコルでリポジトリポートを定義する”

    ドメイン向けポートには typing.Protocol を使う。メソッドシグネチャは狭く保ち、ドメイン状態または明示的な結果型を返す。

    これはプロトコル導入のための最小ポート形状である。楽観的ロック、冪等性キー、イベントタプルを持つ本番ストアには、永続化、集約、イベント正規定義を使う。

    from typing import Protocol
    class RequestResolver(Protocol):
    async def find_waiting(self, request_id: UUID) -> Waiting | None: ...
    class RequestStore(Protocol):
    async def save_en_route(
    self,
    state: EnRoute,
    events: tuple[DomainEvent, ...],
    ) -> None: ...

    プロトコルクラスはポートを記述する。ドメインエンティティではない。

    外部表現が不変条件を迂回したり、余分なフィールドを含んだり、プライバシー/シリアライズ要件が異なる場合は、API DTO、DB行モデル、読み取りモデル、ドメインモデルを分離する。

    request_id.pytaxi_request.pyrequest_repository.py のように、1つのドメイン概念ごとにファイルを分ける。models.pytypes.py に無関係な型が集まり始めると、importの循環が起きやすく、レビューでも「この変更がどこに波及するか」が見えにくくなる。分割の目安は、ファイル名を説明せずに中身が想像できることだ。

    新規リポジトリでは、Python 3.12+ とPydantic v2を持つuv管理プロジェクトを作成する。

    Terminal window
    uv init --package
    uv python pin 3.13
    uv add "pydantic>=2,<3"
    uv lock

    インポート可能なPythonパッケージではないスキル、またはドキュメントリポジトリでは、[tool.uv] の下に package = false を設定する。

    Pydantic プラグイン付きで Mypy を設定する

    Section titled “Pydantic プラグイン付きで Mypy を設定する”

    Pydanticドメインモデルに依存するプロジェクトではPydantic v2 mypyプラグインを使う。モデル __init__model_construct、frozenモデル、フィールドデフォルト、型なしフィールド、動的エイリアスに対する静的チェックが改善される。

    [tool.mypy]
    python_version = "3.12"
    strict = true
    plugins = ["pydantic.mypy"]
    [tool.pydantic-mypy]
    init_forbid_extra = true
    init_typed = true
    warn_required_dynamic_aliases = true

    init_typed = true を維持し、コンストラクタ呼び出しがPydanticのデフォルト強制変換の Any を受け入れず、フィールド型に対してチェックされるようにする。init_forbid_extra = true も維持し、予期しないコンストラクタキーワードが **kwargs: Any の背後へ隠れないようにする。コンストラクタチェックを弱めるため、ドメインモデルに必須の動的エイリアスは避ける。

    Pydantic v2は、Kamae Pythonのドメイン状態、境界DTO、プロセス境界を越えるエラーバリアントのデフォルトである。検証とJSONスキーマが不要な場合は、より軽いツールでもよい。

    ニーズ優先
    判別共用体状態、境界パース、JSON/API 契約Pydantic v2 frozen モデル
    HTTP、キュー、永続化を越えるエラー/イベントkind 判別子付き Pydantic v2
    外部シリアライズのない小さなプロセス内値オブジェクト@dataclass(frozen=True, slots=True) または attrs frozen
    1 モジュール内のみで使う内部コマンド/結果タプルdataclass または NamedTuple
    豊富なバリデータ、コンバーター、attrs エコシステムプラグインfrozen=Trueattrs
    from dataclasses import dataclass
    from decimal import Decimal
    @dataclass(frozen=True, slots=True)
    class Money:
    amount: Decimal
    currency: str

    ログ、API、リポジトリ、イベントに現れる金額、ID、ライフサイクル状態はPydanticに置く。ドメインモジュールを離れないホットパスヘルパーにはdataclass/attrsを使う。

    明示的なマッパーなしに、同じ概念をPydanticとdataclassの両方で表現しない。

    Kamae Pythonは隠れた振る舞いより明示的なフィールド、コンストラクタ、関数引数を好む。効果が局所的でドメイン不変条件に置き換わらないとき、デコレータは共存できる。

    デコレータドメイン/遷移コード境界/アダプターコード
    @propertyアグリゲート状態では避ける。プレーンなフィールドを優先薄いアダプタービューでは許容
    @cached_property避ける。「値」の中に時間依存や高コスト処理を隠す稀。事前計算値の注入を優先
    @validate_call純粋遷移では避ける。型はすでに狭いはず小さな parse/convert ヘルパーに有用
    @functools.wrapsインフラ境界のロギング/トレースラッパーで可
    # Prefer explicit fields on domain states.
    class Waiting(DomainModel):
    kind: Literal["waiting"] = "waiting"
    request_id: UUID
    ...
    # Avoid computed lifecycle state that performs I/O or caching.
    class Waiting(DomainModel):
    @cached_property
    def display_label(self) -> str: ... # hides work; hard to test in isolation

    純粋遷移関数はすべての入力をパラメータとして受け取るべきである。デコレータが可観測な振る舞い(検証、キャッシュ、I/O)を変えるなら、遷移の外、アダプターまたはユースケースに置き、依存関係がシグネチャで見えるようにする。

    既存フィールドからの純粋な導出でありI/Oを行わない場合、小さな不変値オブジェクトの @property は許容される:

    @dataclass(frozen=True, slots=True)
    class DateRange:
    start: date
    end: date
    @property
    def days(self) -> int:
    return (self.end - self.start).days

    Pydanticのフィールドバリデータや model_validator がデコレータ多用クラスに置き換わるときは、構築を単一の検証エントリポイントに保つため、frozenモデル上のバリデータを優先する。

    呼び出し元が不変条件を迂回できないか — High

    Section titled “呼び出し元が不変条件を迂回できないか — High”

    可変ドメインモデル、バリデーションのない公開フィールド、不変条件を持つ型でバリデータを飛ばす model_construct や生dict組み立てを指摘する。

    複数フィールドの不変条件の一部だけを変えるミューテータや部分更新、再バリデーションの省略、無効な中間状態の流出を指摘する。

    正規のコンストラクタ/アダプター経路での構築、非公開テストヘルパー、使用前に検証ドメインコンストラクタへ変換されるDTO/行モデルは指摘しない。

    意味的プリミティブは明示的な型で表現されているか — High

    Section titled “意味的プリミティブは明示的な型で表現されているか — High”

    ユーザー ID、注文ID、メールアドレス、金額、数量、外部参照など、区別されるドメイン概念に素の strintfloatUUIDdict を直接使っている箇所を指摘する。

    小さな凍結Pydanticモデル、NewTypefield_validator / model_validator 付きの検証コンストラクタを提案する。

    ローカル一時変数、非公開アダプターフィールド、テストリテラル、シリアライズ専用DTOフィールド、Python型以上のドメイン不変条件がない値は指摘しない。

    DTO、ORM 行、ドメイン状態は分離されているか — Medium

    Section titled “DTO、ORM 行、ドメイン状態は分離されているか — Medium”

    フレームワーク専用の関心、ORMミックスイン、外部データがバリデーションを迂回したり不変条件がストレージ形状に結びついたりする受信デシリアライズ設定を持つドメイン状態を指摘する。

    意図的な読み取りモデル、APIレスポンスDTO、ドメイン状態へデシリアライズできない監査用エクスポート型は指摘しない。

    ドメイン状態は凍結かつ extra-forbid か — Medium

    Section titled “ドメイン状態は凍結かつ extra-forbid か — Medium”

    可変 BaseModel ドメイン状態、frozen=True の欠落、ライフサイクルモデルでの extra="allow"(プロジェクトが意図的例外を文書化していない場合)を指摘する。

    状態は判別共用体で明示的にモデル化されているか — Medium

    Section titled “状態は判別共用体で明示的にモデル化されているか — Medium”

    status: str と多数のオプションフィールドを持つ単一Pydanticモデルで、kind: Literal[...] の別凍結状態バリアントの方が必須フィールドを明確にできる場合を指摘する。

    domain-modeling.mdAnnotated[A | B, Field(discriminator="kind")] パターンと照合する。

    金額、時刻、単位は明示的か — Medium

    Section titled “金額、時刻、単位は明示的か — Medium”

    型や名前付きコンストラクタなしに単位、通貨、タイムゾーン、包含/排他範囲を混在させる金額、数量、期間、レート、タイムスタンプを指摘する。

    ドメインコードは概念ごとに整理されているか — Low

    Section titled “ドメインコードは概念ごとに整理されているか — Low”

    無関係な概念を集めたcatch-allの models.pytypes.pyschemas.py や、振る舞いとデータを分離したモジュールを指摘する。

    狭い境界づけられたコンテキスト目的のまとまったモジュール、生成スキーマモジュール、意図的に薄く保った互換シムは指摘しない。