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

    境界防御

    外部から入るデータは、ドメインに到達するまで未知として扱う。Pydanticは形状と宣言された制約を検証するが、ビジネス上の意味(テナント所有権、ライフサイクル上の前提、金額の単位など)は、ドメインコンストラクタや状態遷移の前提条件で守る必要がある。

    状態の型付けは ドメインモデリング を、検証コストと model_construct の境界は Pydantic のパフォーマンスunsafe 境界 を、DB行のマッピングは ORM アダプター を参照する。

    未知のデータはエッジでパースする

    Section titled “未知のデータはエッジでパースする”

    根拠は単純だ。検証前の値をドメインに流すと、型チェッカーは「もう正しい」とみなすが、ランタイムの不変条件はまだ証明されていない。

    APIボディ、DB行、キューメッセージ、ファイル、環境変数、SDKレスポンスは、Pydanticが検証するまで未知として扱う。

    CreateRequestInputAdapter = TypeAdapter(CreateRequestInput)
    def parse_create_request_input(raw: object) -> CreateRequestInput:
    return CreateRequestInputAdapter.validate_python(raw)

    判別共用体の場合は、共用体アダプター経由でパースする。

    request = TaxiRequestAdapter.validate_python(raw_request)

    生のJSONバイトまたは文字列には validate_json を使う。

    def parse_queue_message(body: bytes) -> TaxiRequestEvent:
    return TaxiRequestEventAdapter.validate_json(body)

    ホットパスでは、json.loads のあとに validate_python するより、model_validate_json / TypeAdapter.validate_json を優先する。JSONのパースとスキーマ検証は、PydanticのRustコア側でまとめて処理できる。差が重要になる場合は Pydantic のパフォーマンス を読む。

    フレームワーク境界では DTO を優先する

    Section titled “フレームワーク境界では DTO を優先する”

    フレームワークのリクエストモデルはDTOにできる。検証後、ドメインコマンド値またはドメイン状態へ変換する。フレームワーク専用の関心事をドメインモデルへ漏らさない。

    class AssignDriverBody(BaseModel):
    driver_id: UUID
    async def assign_driver_endpoint(body: AssignDriverBody) -> JSONResponse:
    result = await assign_driver_use_case(..., driver_id=body.driver_id, ...)
    return assign_driver_response(result)

    Pydanticは形状と宣言されたバリデータを証明するが、すべてのドメイン意味は証明しない。HTTPの外でも適用されるビジネス不変条件については、ドメインコンストラクタ、コマンドビルダー、または遷移前提条件関数を権威の場所として保つ。

    ドメイン状態は extra="forbid"frozen=True を使う。外部境界のインバウンド DTOには別の設定プロファイルが必要だ。

    ワイヤ向けDTOでstrictパースを有効にし、強制変換がデータ品質の問題を隠さないようにする("123"123"true"True)。

    from pydantic import BaseModel, ConfigDict, Field
    class CreateRequestInput(BaseModel):
    model_config = ConfigDict(strict=True, extra="forbid")
    passenger_id: UUID
    pickup_lat: float = Field(ge=-90, le=90)
    pickup_lng: float = Field(ge=-180, le=180)

    次のときに strict=True を使う:

    • ペイロードがHTTP、キュー、Webhook、サードパーティSDKから来る。
    • 黙って強制変換するとビジネス意味が変わる(金額、真偽値、列挙)。
    • 検証失敗を早く表面化し、上流のデータバグを見つけたい。

    両側がPythonコードで型がすでに一致する内部ハンドオフには strict=True を適用しない。安全性の利得なくコストが増える。Pydantic のパフォーマンス を読む。

    ConfigDict(strict=True) はすべてのフィールドに Strict* 型(StrictIntStrictStr など)を付けるのと等価である。DTOではモデルレベルフラグを優先し、1フィールドだけ強制変換が必要なときだけフィールド単位のstrict型を使う。

    モデルの役割extrastrict根拠
    ドメイン状態 / イベントforbiddefault無効フィールドは永続化やログに入ってはならない
    インバウンド HTTP/コマンド DTOforbidTrueドメイン変換前に未知または typo キーを拒否
    アウトバウンドレスポンス DTOforbiddefault意図しないフィールド漏洩を防ぐ
    Webhook / パートナーフィード(バージョン寛容な取り込み)allowTrueベンダーの前方互換フィールドを受け入れ。既知部分のみドメインにマップ
    ORM 行 / DB 投影 DTOforbiddefaultカラム集合は固定。余分なキーはマッパーバグの兆候
    設定 / フィーチャーフラグスナップショットignoredefault古いデプロイの未知キーは安全に捨てられる
    監査 / デバッグキャプチャ(非ドメイン)allowdefault生エンベロープは別保存。遷移には通さない

    チェックリスト対応(4.3、4.4): ドメイン状態の extra="allow" をフラグする。欠落フィールドが黙って振る舞いを変えるとき、インバウンドDTOの広いデフォルトをフラグする。互換性の理由を文書化しない限り、明示的な必須フィールドと extra="forbid" を優先する。

    extra="allow" が必要なときは、DTOをアダプターレイヤーに置き、宣言されたフィールドだけをドメインコンストラクタにマップする。許容的なDTOをドメインモデルにサブクラス化または継承しない。

    DTO デフォルトと未知フィールド

    Section titled “DTO デフォルトと未知フィールド”

    クライアントがフィールドを省略したときにビジネス意味が変わるデフォルトは避ける:

    # Risky: omitted "currency" silently becomes USD.
    class ChargeInput(BaseModel):
    amount_cents: int
    currency: str = "USD"
    # Prefer: require explicit values at the boundary.
    class ChargeInput(BaseModel):
    model_config = ConfigDict(strict=True, extra="forbid")
    amount_cents: int = Field(gt=0)
    currency: Literal["USD", "EUR", "JPY"]

    オプショナルフィールドには、「未提供」が別の、文書化された意味であるときだけ None を使う。隠れたデフォルトを意味するときには使わない。

    環境変数とCLI由来の設定には pydantic-settings を使う。設定モデルはDTOとして扱い、プロセス起動時に一度だけ検証し、ドメイン状態と混ぜない。

    Terminal window
    uv add pydantic-settings
    from pydantic import Field, SecretStr
    from pydantic_settings import BaseSettings, SettingsConfigDict
    class DatabaseSettings(BaseSettings):
    model_config = SettingsConfigDict(
    env_prefix="DB_",
    env_file=".env",
    env_file_encoding="utf-8",
    extra="forbid",
    strict=True,
    )
    host: str
    port: int = 5432
    name: str
    user: str
    password: SecretStr
    class AppSettings(BaseSettings):
    model_config = SettingsConfigDict(extra="forbid")
    database: DatabaseSettings
    tenant_header: str = "X-Tenant-Id"

    守るべき境界:

    • 起動時にパースする(コンポジションルート — application-wiring.md)。ユースケースや遷移内で os.environ を読まない。
    • extra="forbid" はフィールドにマップされる環境変数名のtypoを検出する。
    • 資格情報には SecretStrmodel_dump() で設定をログに出さない。
    • CLI フラグCliSettingsSource またはPydanticモデルを構築する薄いargparseレイヤー経由で設定モデルに入れられる。envベース設定と同じ検証ルール。
    • リクエストごとの値(テナントID、アクター ID)は設定ではない。リクエストコンテキストに属する。BaseSettings ではない。アプリケーション配線 を参照。

    チェックリスト対応(4.6): 認証済みコンテキストと比較せずに、パス、クエリ、ボディ、メッセージペイロードのテナントまたはアクター IDを信頼しない。

    よくある構成:

    Client → API gateway (authn) → service (authz + domain)
    injects: tenant_id, subject, scopes

    ゲートウェイはセッションまたはトークンを検証し、信頼できるヘッダーを転送する。サービスはそのテナントに対して操作が許可されているかを依然として検証する。

    from dataclasses import dataclass
    from uuid import UUID
    @dataclass(frozen=True)
    class RequestContext:
    tenant_id: UUID
    actor_id: UUID
    scopes: frozenset[str]
    class AssignDriverBody(BaseModel):
    model_config = ConfigDict(extra="forbid")
    driver_id: UUID
    # Do NOT accept tenant_id from body when gateway already established tenant.
    async def assign_driver_endpoint(
    body: AssignDriverBody,
    ctx: RequestContext, # from middleware / dependency
    request_id: UUID, # from path
    ) -> JSONResponse:
    result = await assign_driver_use_case(
    ctx=ctx,
    request_id=request_id,
    driver_id=body.driver_id,
    )
    return assign_driver_response(result)

    認可はユースケースに属する。読み込みの後、遷移の前:

    async def assign_driver_use_case(
    ctx: RequestContext,
    request_id: UUID,
    driver_id: UUID,
    *,
    store: RequestStore,
    resolver: RequestResolver,
    ) -> Result[EnRoute, AssignDriverError]:
    waiting = await resolver.find_waiting(request_id)
    if waiting is None:
    return Err(RequestNotFound(request_id=request_id))
    # Tenant ownership is a domain/application invariant, not a DTO concern.
    if waiting.tenant_id != ctx.tenant_id:
    return Err(RequestNotFound(request_id=request_id)) # or TenantMismatch
    if "driver:assign" not in ctx.scopes:
    return Err(Forbidden())
    en_route, events = assign_driver(waiting, driver_id, now=utc_now())
    await store.save_en_route(en_route, events, expected_version=waiting.version, ...)
    return Ok(en_route)

    ルール:

    • すべての変更コマンドでリソースの tenant_idctx.tenant_id と比較する。
    • テナント横断のIDプロービングには 404 または汎用拒否を優先する。方針を文書化する。
    • 永続化でFK制約を強制できるよう、アグリゲート状態または行DTOに tenant_id を置く。
    • キューコンシューマーは未認証ペイロードフィールドではなく、署名付きメッセージメタデータから RequestContext を再構築する。

    ドメイン状態では余分なフィールドを禁止する

    Section titled “ドメイン状態では余分なフィールドを禁止する”

    ドメイン状態とイベントモデルには extra="forbid" を使い、存在すべきでないフィールドを黙って受け入れない。未知キーを許すと、model_dump() 経由でログや永続化へ想定外のデータが載る経路を作る(たとえばクライアントが付けた余分なPIIフィールドを含む)。

    typing.cast# type: ignore、未検証の dict[str, Any]model_construct で境界データを信頼済みドメインオブジェクトにしてはならない。これらは検証を迂回する。

    許容される狭い例外:

    • データベースドライバーまたは先行するPydanticパースですでに検証された値を受け取る、テスト済みマッパー内の model_constructunsafe 境界 を読む。
    • 近くに実行時検証と短いコメントがあるフレームワーク制限まわりの cast

    生成クライアント、ネイティブアダプター、ORMはしばしば広すぎる、または信頼しすぎる型の値を返す。まずDTO/行モデル経由で変換し、その後ドメインモデルへ。

    スキーマ経由で永続化と再水和

    Section titled “スキーマ経由で永続化と再水和”

    データベースから読むときは、ユースケースへ渡す前に行をドメインモデルへパースする。データベースに書くときは、ドライバーに応じて model_dump(mode="python") または model_dump(mode="json") で意図的にダンプする。

    def request_from_row(row: Mapping[str, object]) -> TaxiRequest:
    return TaxiRequestAdapter.validate_python(row)
    def request_to_row(request: TaxiRequest) -> dict[str, object]:
    return request.model_dump(mode="python")

    ORMモデルをデフォルトでドメインモデルにしてはならない。永続化の関心事、遅延ロード、nullableカラム、ドメイン不変条件を弱める余分なフィールドを運ぶ。

    ドメイン外で検証エラーを処理する

    Section titled “ドメイン外で検証エラーを処理する”

    Pydanticは ValidationError を投げる。コントローラー、メッセージコンシューマー、CLIハンドラー、またはマッパーレイヤーで捕捉し、ローカルのエラー/レスポンス形状に変換する。すでに信頼すべきデータの検証エラーを純粋遷移関数が捕捉してはならない。

    from fastapi import Request
    from fastapi.exceptions import RequestValidationError
    from fastapi.responses import JSONResponse
    from pydantic import ValidationError
    def validation_error_response(exc: ValidationError | RequestValidationError) -> JSONResponse:
    return JSONResponse(
    status_code=422,
    content={
    "code": "validation_error",
    "details": [
    {
    "loc": list(err["loc"]),
    "type": err["type"],
    "msg": err["msg"],
    }
    for err in exc.errors()
    ],
    },
    )
    @app.exception_handler(ValidationError)
    async def pydantic_validation_handler(_: Request, exc: ValidationError) -> JSONResponse:
    return validation_error_response(exc)

    シークレットを含む可能性があるフィールドについてレビューせず、生のPydanticエラー dictをクライアントに返さない。公開レスポンスから入力値を除去する。

    import grpc
    from pydantic import ValidationError
    def validation_error_status(exc: ValidationError) -> grpc.aio.ServicerContext:
    # Return INVALID_ARGUMENT; attach sanitized details in trailing metadata if needed.
    details = "; ".join(f"{'.'.join(str(p) for p in e['loc'])}: {e['msg']}" for e in exc.errors())
    return grpc.StatusCode.INVALID_ARGUMENT, details

    形状違反は INTERNAL ではなく INVALID_ARGUMENT にマップする。

    async def handle_message(body: bytes) -> None:
    try:
    event = TaxiRequestEventAdapter.validate_json(body)
    except ValidationError as exc:
    logger.warning("dropping invalid message", extra={"error_count": exc.error_count()})
    await dead_letter.publish(body, reason="validation_error")
    return # do not retry forever on poison shape
    await process_event(event)

    恒久的な検証失敗となるpoisonメッセージは、デッドレターキューへ送る。一時的失敗はバックオフ付きリトライ。永続化、集約、イベント を読む。

    レイヤーValidationError を捕捉?返すもの
    HTTP コントローラー / gRPC サーバーはい422 / INVALID_ARGUMENT
    キューコンシューマーはいDLQ またはメトリクス + 破棄
    CLIはい終了コード 2 + stderr
    DTO → ドメインマッパーはい(またはコントローラーへバブル)ドメインエラーまたは再送出
    純粋遷移いいえN/A
    ユースケース(信頼済み状態)いいえN/A

    Pydantic を唯一の境界バリデータとみなしていないか — High

    Section titled “Pydantic を唯一の境界バリデータとみなしていないか — High”

    非空文字列、有効ID、正の金額、範囲、クロスフィールドルールなど、ドメイン不変条件を model_validate だけに頼り、ドメインコンストラクタや遷移前提がまだ必要な箇所を指摘する。

    境界で未検証キャストと Any を避けているか — High

    Section titled “境界で未検証キャストと Any を避けているか — High”

    信頼ドメインオブジェクト作成に typing.cast# type: ignore、未検証 dict[str, Any]model_construct、未知ペイロードの添字アクセスを使っている箇所を指摘する。

    外部境界はすべて DTO → ドメインで変換されているか — High

    Section titled “外部境界はすべて DTO → ドメインで変換されているか — High”

    HTTPハンドラ、キューコンシューマ、DB行マッパー、ファイル/設定/環境変数リーダー、CLIパーサーが検証済み変換なしに生データをドメインロジックへ渡している箇所を指摘する。

    値がアダプター層に留まる生DTO/読み取りモデル構築、検証アダプターまたはコンストラクタ経路での直接ドメイン構築は指摘しない。

    認可とテナント境界はチェックされているか — High

    Section titled “認可とテナント境界はチェックされているか — High”

    ドメイン操作前に、パス/ボディのテナントID、アクター ID、所有権主張を認証コンテキストと比較せず信頼しているハンドラやユースケースを指摘する。

    DTO のデフォルトと未知フィールドは意図的か — Medium

    Section titled “DTO のデフォルトと未知フィールドは意図的か — Medium”

    欠落や誤綴り入力がビジネス意味を変えうるのに、広いデフォルト、オプションフィールド、緩い未知フィールド処理を使う受信DTOを指摘する。互換性が不要なら明示デフォルトと extra="forbid" を優先する。

    ドメイン状態は外部形式向けに過剰設定されていないか — Medium

    Section titled “ドメイン状態は外部形式向けに過剰設定されていないか — Medium”

    別DTO/行で不変条件やマスクを守れるのに、受信 extra="allow"、緩いエイリアス設定、ドメイン状態へのORM/セッション結合を指摘する。

    意図的な読み取りモデル、投影、レスポンス専用DTOは指摘しない。