メインコンテンツへスキップ
友田 陽大
marshmallow
Python
marshmallow
バリデーション
型安全
ドメインモデリング
DRY
アーキテクチャ設計

marshmallow カスタムフィールドと高度なバリデーション:再利用可能なドメイン型を設計する

marshmallowのカスタムフィールドを公式仕様に忠実に解説。fields.Field[T]と_serialize/_deserialize、make_errorとerror_messagesのi18n、金額(Decimal)・電話(E.164)・列挙(fields.Enum)など再利用可能なドメイン型、fields.Method/Function/Constant、既存フィールド拡張までを実コードで示します。

公開日
読了時間
22分
著者
友田 陽大
シェア

導入:組み込み fields で足りなくなったとき

fields.Str()fields.Int()fields.Email()——marshmallow の組み込みフィールドは強力で、API の入出力の大半はこれだけで賄えます。ところが実務のドメインには、組み込みでは表現しきれない業務ルールを持った値が必ず登場します。

  • 金額:通貨は Decimal で持ち、小数第 2 位に丸め、float では絶対に扱わない。
  • 電話番号:ユーザーは 090-1234-5678 とも 09012345678 とも入力するが、内部では常に E.164 形式 +819012345678 に正規化したい。
  • 郵便番号、SKU コード、社内 ID——「形式が決まっていて、検証と正規化が伴う」値。

こうした値を、ビュー関数やサービス層で「その都度 if 文で検証し、.replace() で整形する」のは典型的なアンチパターンです。同じ正規化ロジックが API ごとにコピーされ、ある場所では +81 を付けるのに別の場所では付け忘れる——知識が散在し、いつか必ず食い違います

ドメイン駆動設計でいう値オブジェクト(Value Object)の発想がここで効きます。「金額」や「電話番号」は単なる文字列ではなく、固有の制約を持つ型です。その制約を、システム境界で一度だけ適用し、内部には正規化済みの値だけを流し込む。marshmallow のカスタムフィールドは、まさにこの「境界での 1 度きりの正規化・検証」を、再利用可能な部品として表現するための仕組みです。

💡 本記事は marshmallow の基礎——Schema / load() / dump() / 三層バリデーション / セキュリティ境界——を解説した marshmallow 実践ガイド(v4対応) の続編です。load() が境界バリデーションの入口であること、@validates / @validates_schema の責務分離を前提に進めます。未読であればそちらを先にどうぞ。

💡 この記事で扱うバージョン:marshmallow 4.3.0(2026年4月時点の安定版)を前提とします。v4 の重要な変更点として、fields.Fieldジェネリック化され、class MyField(fields.Field[T]) と「このフィールドがデシリアライズ後に返す Python 型 T」を型引数で宣言できるようになりました。これは単なる飾りではなく、IDE 補完と静的解析の精度を上げる実利があります。


1. カスタムフィールドの解剖:_serialize_deserialize

カスタムフィールドの実装は、突き詰めればたった 2 つのメソッドを書くことに尽きます。fields.Field[T] を継承し、出力方向の _serialize と入力方向の _deserialize を実装します。

from marshmallow import ValidationError, fields


class PinCode(fields.Field[list[int]]):
    """暗証番号を「内部では int のリスト、外向きには文字列」として双方向変換するフィールド。"""

    def _serialize(self, value, attr, obj, **kwargs):
        # dump 方向:内部の [1, 2, 3, 4] を "1234" という文字列にする
        if value is None:
            return ""
        return "".join(str(d) for d in value)

    def _deserialize(self, value, attr, data, **kwargs):
        # load 方向:外部の "1234" を内部の [1, 2, 3, 4] に変換し、不正なら弾く
        try:
            return [int(c) for c in value]
        except ValueError as error:
            raise ValidationError("Pin codes must contain only digits.") from error

2 つのメソッドの役割と引数は、公式仕様で次のように定まっています。

メソッド方向受け取る value返すべきもの
_serialize(self, value, attr, obj, **kwargs)dump(出力)内部の Python 値JSON 互換の外向き表現
_deserialize(self, value, attr, data, **kwargs)load(入力)外部の生データ検証・正規化済みの内部値 T
  • _serializeobj変換元のオブジェクト全体_deserializedata入力 dict 全体です。他フィールドの値を参照したいときに使えますが、まずは第 1 引数の value だけで完結させるのが基本です。
  • _deserialize が返した値が、load() の結果 dict に入ります。PinCode なら load({"pin": "1234"}){"pin": [1, 2, 3, 4]} を返し、dump({"pin": [1, 2, 3, 4]}){"pin": "1234"} を返す——往復が閉じているのが要点です。
from marshmallow import Schema


class LoginSchema(Schema):
    pin = PinCode()


LoginSchema().load({"pin": "1234"})   # → {'pin': [1, 2, 3, 4]}
LoginSchema().dump({"pin": [1, 2, 3, 4]})  # → {'pin': '1234'}
LoginSchema().load({"pin": "12ab"})   # → ValidationError: Pin codes must contain only digits.

なぜこれが優れているのか? 「文字列 "1234"int のリストに変換し、数字以外なら拒否する」という知識が、PinCode というひとつの型に凝集します。この型を使うすべてのスキーマが、自動的に同じ変換と検証を得る——典型的な DRY の実践です。さらに fields.Field[list[int]] という型引数のおかげで、「このフィールドの _deserializelist[int] を返す」という契約が型として明示され、ETC(Easy To Change)にも効きます。変換ルールが変わっても、修正箇所は PinCode の中だけです。


2. エラー設計と i18n:make_error でメッセージを一元管理する

PinCode ではエラーメッセージを _deserialize 内にベタ書きしました。動きはしますが、メッセージが実装ロジックに埋め込まれているため、多言語対応や文言の統一が困難です。marshmallow は、この問題を default_error_messagesmake_error で解決します。

from marshmallow import ValidationError, fields


class PinCode(fields.Field[list[int]]):
    # フィールドクラスにエラーメッセージを「キー → 文言」の辞書として宣言する
    default_error_messages = {
        "invalid": "暗証番号は数字のみで構成してください。",
        "length": "暗証番号は4桁である必要があります。",
    }

    def _serialize(self, value, attr, obj, **kwargs):
        if value is None:
            return ""
        return "".join(str(d) for d in value)

    def _deserialize(self, value, attr, data, **kwargs):
        try:
            digits = [int(c) for c in value]
        except (TypeError, ValueError) as error:
            # メッセージ本文ではなく「キー」で参照する
            raise self.make_error("invalid") from error
        if len(digits) != 4:
            raise self.make_error("length")
        return digits

self.make_error("invalid") は、default_error_messages からキー "invalid" に対応する文言を引き、ValidationError を構築して返します。直接 raise ValidationError("...") と書くこともできますが、make_error には次の利点があります。

  • メッセージの一元管理:すべての文言が default_error_messages 辞書に集約され、実装ロジックと分離される。
  • インスタンス単位の上書き:呼び出し側がフィールド生成時に error_messages={...} を渡すと、同じキーの文言だけを差し替えられる。組み込みフィールドの required メッセージを和訳するのと同じ仕組みです。
from marshmallow import Schema, fields


class CheckoutSchema(Schema):
    # 組み込みフィールドも同じ仕組み。キー単位でメッセージを上書きできる
    email = fields.Email(
        required=True,
        error_messages={"required": "メールアドレスは必須です。", "invalid": "メール形式が不正です。"},
    )
    # 自作フィールドの "length" だけ、この画面用に差し替える
    pin = PinCode(error_messages={"length": "4桁の暗証番号を入力してください。"})

なぜこれが優れているのか? make_error("invalid") は、文言の実体ではなく**意味(キー)**を参照します。これにより、「数字以外が来た」という検証ロジックと、「ユーザーに何と伝えるか」という表示の関心が分離されます(SRP)。i18n リソースを差し込みたい、あるいは画面ごとに文言を変えたい——そうした要求が、_deserialize の中身を一切触らずに error_messages の差し替えだけで実現できる。これが「ロジックとメッセージを分けておく」ことの配当です。

⚠️ _deserialize で例外を握り潰さない[int(c) for c in value]valueNoneint だと TypeError を、数字以外を含むと ValueError を投げます。これらを try/except で捕まえたら、必ず ValidationError(または make_error)へ変換して再送出してください。生の TypeError をそのまま外へ漏らすと、marshmallow のエラー集約機構(err.messages への構造化、many=True でのインデックス付け)をすり抜け、500 エラーとしてユーザーに露出します。raise ... from error で元例外を __cause__ に保持しておくと、ログ調査の手がかりも失いません。


3. 実例①:金額フィールド(Decimal ベース)

最も「自作する価値が高い」ドメイン型が金額です。金額を float で扱うのは、本番障害に直結する危険な選択です。

⚠️ 金額を float で扱ってはいけない0.1 + 0.20.30000000000000004 になるのは有名ですが、これは IEEE 754 浮動小数点が 2 進数で 10 進小数を正確に表現できないために起こります。金額計算でこれが積み重なると、請求額が 1 円ずれる、合計が合わない、といった会計上あってはならない誤差になります。金額は必ず decimal.Decimal で保持し、JSON へは文字列として出力します(float で出力すると JSON パーサ側で再び誤差が混入するため)。

marshmallow には fields.Decimal があり、places(小数桁数)・as_string(文字列出力)・rounding(丸めモード)を備えています。これをサブクラス化して、通貨ドメインの規約をデフォルトとして焼き込んだ MoneyField を作ります。

import decimal

from marshmallow import ValidationError, fields, validate


class MoneyField(fields.Decimal):
    """通貨額を表すフィールド。小数第2位・四捨五入(ROUND_HALF_UP)・文字列出力をデフォルトに固定する。"""

    default_error_messages = {
        "negative": "金額は0以上である必要があります。",
    }

    def __init__(self, *, allow_negative: bool = False, **kwargs):
        self.allow_negative = allow_negative
        # ドメイン規約をデフォルト値として設定する。呼び出し側が明示すれば上書き可能
        kwargs.setdefault("places", 2)
        kwargs.setdefault("rounding", decimal.ROUND_HALF_UP)
        kwargs.setdefault("as_string", True)  # JSON には文字列で出す(精度保持)
        super().__init__(**kwargs)

    def _deserialize(self, value, attr, data, **kwargs):
        # まず親(fields.Decimal)に Decimal への変換・桁丸めを任せる
        amount = super()._deserialize(value, attr, data, **kwargs)
        if not self.allow_negative and amount < 0:
            raise self.make_error("negative")
        return amount

使い方は組み込みフィールドと変わりません。

from marshmallow import Schema


class OrderSchema(Schema):
    subtotal = MoneyField(required=True)
    # 値引きはここでは正負の検証だけ。Range など追加ルールは validate= で合成できる
    discount = MoneyField(allow_negative=False, validate=validate.Range(min=0))


OrderSchema().load({"subtotal": "1980.005", "discount": "200"})
# → {'subtotal': Decimal('1980.01'), 'discount': Decimal('200.00')}  ← 桁丸め済み
OrderSchema().dump({"subtotal": decimal.Decimal("1980.01")})
# → {'subtotal': '1980.01', ...}  ← float ではなく文字列で出力

ポイントは、_deserializeまず親に変換を委譲してから(super()._deserialize(...))、その結果に対してドメイン固有の検証(負数チェック)を追加していることです。Decimal への変換と桁丸めという「車輪」を再発明せず、業務ルールだけを上乗せしています。

なぜこれが優れているのか? 「金額は小数第 2 位・四捨五入・文字列出力・負数禁止」という通貨ドメインの規約が、MoneyField というひとつの値オブジェクトに封じ込められます。プロジェクト中のすべての金額フィールドを MoneyField で宣言すれば、ある API だけ float で出してしまう、ある画面だけ丸めを忘れる、といった規約のほころびが構造的に起こり得なくなります。筆者が木材流通の B2B SaaS で請求・決済まわりを設計した際も、金額の取り扱いを型レベルで統一することが、課金の正確性を担保する土台でした。


4. 実例②:電話番号フィールド(E.164 正規化)

電話番号は「ユーザーの入力形式が多様」かつ「内部では 1 つの正規形に統一したい」典型例です。090-1234-567809012345678+81 90 1234 5678——これらをすべて E.164 形式 +819012345678 に正規化し、不正な入力は弾くフィールドを作ります。

import re

from marshmallow import ValidationError, fields

# 国内番号(0始まり、10〜11桁)を想定したシンプルな正規化例
_DOMESTIC_RE = re.compile(r"^0\d{9,10}$")


class PhoneField(fields.Field[str]):
    """日本国内の電話番号を E.164 形式(+81...)に正規化するフィールド。"""

    default_error_messages = {
        "invalid": "電話番号の形式が正しくありません。",
    }

    def _serialize(self, value, attr, obj, **kwargs):
        # 内部では常に +81... の正規形なので、そのまま出力する
        return value if value is not None else None

    def _deserialize(self, value, attr, data, **kwargs):
        if not isinstance(value, str):
            raise self.make_error("invalid")
        # 数字以外(ハイフン・空白・括弧)を除去してから判定する
        digits = re.sub(r"[^\d]", "", value)
        if _DOMESTIC_RE.match(digits):
            # 先頭の 0 を国番号 +81 に置き換える(E.164 化)
            return "+81" + digits[1:]
        # 既に +81 始まりの国際形式で来たケースも受理する
        if value.startswith("+81") and re.fullmatch(r"\+81\d{9,10}", value):
            return value
        raise self.make_error("invalid")
from marshmallow import Schema


class ContactSchema(Schema):
    phone = PhoneField(required=True)


ContactSchema().load({"phone": "090-1234-5678"})  # → {'phone': '+819012345678'}
ContactSchema().load({"phone": "09012345678"})    # → {'phone': '+819012345678'}
ContactSchema().load({"phone": "+819012345678"})  # → {'phone': '+819012345678'}
ContactSchema().load({"phone": "1234"})           # → ValidationError: 電話番号の形式が正しくありません。

💡 上記は正規化の設計パターンを示すための最小実装です。実運用で国際番号・固定電話・桁数の地域差まで厳密に扱うなら、phonenumbers(Google libphonenumber の Python 移植)を _deserialize 内で呼び出し、phonenumbers.format_number(..., E164) の結果を返す構成にすると堅牢です。要は「正規化と検証のロジックを _deserialize の中に閉じ込める」という構造が本質で、中身のアルゴリズムは差し替え可能です。

なぜこれが優れているのか? 入力形式の揺れ(ハイフンあり/なし、国際表記)を吸収して唯一の正規形に揃える責務が、PhoneField ひとつに集約されます。これを全 API の電話番号入力に使えば、DB に保存される電話番号は必ず E.164という不変条件が境界で保証されます。下流の SMS 送信・重複チェック・名寄せといった処理は、「電話番号は正規化済み」という前提を信頼でき、各処理が個別に正規化を再実装する必要がなくなります。これは「境界で 1 度だけ正規化する」という値オブジェクトの思想そのものです。


5. 実例③:列挙(fields.Enum)で Python の Enum を境界で安全に扱う

ステータスや種別といった「決められた選択肢のいずれか」は、Python 標準の enum.Enum で表現するのが型安全です。marshmallow には専用の fields.Enum があり、これを使うと外部からの文字列を境界で安全に Enum メンバーへ変換できます。fields.Enum は自作不要——組み込みで十分です。

import enum

from marshmallow import Schema, fields


class OrderStatus(enum.Enum):
    PENDING = "pending"
    PAID = "paid"
    SHIPPED = "shipped"
    CANCELLED = "cancelled"


class OrderSchema(Schema):
    # デフォルト:メンバーの「名前」でシリアライズ/デシリアライズする
    status_by_name = fields.Enum(OrderStatus)
    # by_value=True:メンバーの「値」でシリアライズ/デシリアライズする
    status_by_value = fields.Enum(OrderStatus, by_value=True)

2 つのモードの違いは、外向きの表現に名前を使うか、値を使うかです。

宣言load が受け付ける入力dump の出力
fields.Enum(OrderStatus)"PAID"(メンバー名)"PAID"
fields.Enum(OrderStatus, by_value=True)"paid"(メンバー値)"paid"
schema = OrderSchema()
schema.load({"status_by_name": "PAID", "status_by_value": "paid"})
# → {'status_by_name': <OrderStatus.PAID: 'paid'>, 'status_by_value': <OrderStatus.PAID: 'paid'>}

# 列挙にない値は ValidationError で弾かれる
schema.load({"status_by_name": "UNKNOWN", "status_by_value": "paid"})
# → ValidationError: Must be one of: PENDING, PAID, SHIPPED, CANCELLED.

どちらを選ぶかは、外部 API の契約で決まります。フロントエンドや外部システムが "paid"(小文字の値)でやり取りするなら by_value=True"PAID"(メンバー名)でやり取りするなら既定のままです。

なぜこれが優れているのか? 列挙の選択肢チェックを validate.OneOf(["pending", "paid", ...]) で文字列リストとして書くと、リストと実際の Enum 定義が二重管理になり、片方を変更し忘れる DRY 違反を招きます。fields.Enum(OrderStatus)Enum 定義そのものを唯一の真実とし、load() を通過した時点で値は生の文字列ではなく型付きの Enum メンバーになります。下流のコードは if order.status is OrderStatus.PAID:is 比較で安全に分岐でき、文字列のタイポによる分岐漏れが型レベルで防がれます。


6. 算出・派生値:fields.Method / Function / Constant

すべてのフィールドが入力に対応するわけではありません。「他のフィールドから計算される値」「サーバーが固定で返す値」を出力専用で宣言したい場面があります。marshmallow は 3 つの軽量な仕組みを用意しています。

fields.Function:その場のラムダで算出する

from marshmallow import Schema, fields


class UserSchema(Schema):
    first_name = fields.Str()
    last_name = fields.Str()
    # obj(変換元オブジェクト全体)を受け取り、その場で算出する
    upper_name = fields.Function(lambda obj: obj["first_name"].upper())

fields.Functionserialize 用の呼び出し可能オブジェクトを取り、必要なら deserialize= で逆方向も指定できます。1 行で完結する単純な算出に向きます。

fields.Method:スキーマのメソッドに委ねる

算出ロジックが複雑、あるいは複数フィールドで共有したいなら、スキーマのメソッドを指す fields.Method が読みやすくなります。

from marshmallow import Schema, fields


class OrderSchema(Schema):
    subtotal = MoneyField(required=True)
    tax_rate = fields.Decimal(load_default="0.10")
    # serialize 時は total_amount を、deserialize 時は parse_total を呼ぶ
    total = fields.Method("total_amount", deserialize="parse_total")

    def total_amount(self, obj):
        # 小計 × (1 + 税率) を算出して返す
        return str(obj["subtotal"] * (1 + obj["tax_rate"]))

    def parse_total(self, value):
        # 入力側で受け取りたい場合の変換(不要なら deserialize= を省略すれば dump 専用になる)
        return decimal.Decimal(value)

fields.Method のシリアライズメソッドは def method(self, obj): ...、デシリアライズメソッドは def method(self, value): ... という署名を取ります。

fields.Constant:常に同じ値を出力する

API のバージョンタグやレスポンスの種別など、入力に依らず常に固定の値を返すには fields.Constant です。

from marshmallow import Schema, fields


class ResponseSchema(Schema):
    api_version = fields.Constant("2026-06")  # 常にこの値を出力する
    object_type = fields.Constant("order")

dump_only との組み合わせ

算出・派生値は本質的に出力専用であることがほとんどです。クライアントに total を書き込ませてしまうと改ざんの余地になるため、dump_only=True を併用して入力を遮断します。

class OrderSchema(Schema):
    subtotal = MoneyField(required=True)
    # 算出値はサーバーが決める。load では受け付けない
    total = fields.Function(lambda obj: str(obj["subtotal"] * decimal.Decimal("1.10")), dump_only=True)

なぜこれが優れているのか? 「合計金額の算出」「バージョンタグの付与」といったロジックを、fields.Method / Function / Constant としてスキーマ定義に宣言すると、レスポンス整形の知識がスキーマに凝集します。ビュー関数の中で response["total"] = subtotal * 1.1 のように手で組み立てる必要がなくなり、ビジネスロジックがプレゼンテーション層に漏れ出すのを防げます(SRP)。出力仕様が変わっても、修正はスキーマの中だけで完結します。


7. 既存フィールドの拡張:サブクラス化と _bind_to_schema

ゼロから fields.Field[T] を継承するだけが手ではありません。既存フィールドをサブクラス化し、_deserialize をオーバーライドして共通の前処理を常時かけるのは、極めて実用的なパターンです。

代表例が「文字列を常にトリム(前後空白除去)する」フィールドです。@pre_load でスキーマごとに書くより、型として持たせるほうが再利用が効きます。

from marshmallow import fields


class TrimmedString(fields.String):
    """前後の空白を常に除去する文字列フィールド。空白起因のバグを型レベルで防ぐ。"""

    def __init__(self, *, lower: bool = False, **kwargs):
        self.lower = lower
        super().__init__(**kwargs)

    def _deserialize(self, value, attr, data, **kwargs):
        # まず親(fields.String)に文字列としての検証を任せる
        result = super()._deserialize(value, attr, data, **kwargs)
        result = result.strip()
        if self.lower:
            result = result.lower()
        return result
from marshmallow import Schema, validate


class SignupSchema(Schema):
    name = TrimmedString(required=True, validate=validate.Length(min=1))
    # メールは小文字化+トリムを型に焼き込む。validate= の検証はトリム後の値にかかる
    email = TrimmedString(lower=True, validate=validate.Email())


SignupSchema().load({"name": "  友田  ", "email": " Tomoda@Example.com "})
# → {'name': '友田', 'email': 'tomoda@example.com'}

super()._deserialize(...) で親の「文字列型としての検証」を再利用しつつ、strip() / lower() という正規化だけを上乗せしている点に注目してください。車輪の再発明をせず、振る舞いだけを足す——これが拡張の王道です。

_bind_to_schema:スキーマ束縛時のフック

フィールドが「自分が属するスキーマ」を知る必要があるとき——たとえば親スキーマの設定に応じてエラーメッセージを切り替える、別フィールドへの参照を解決する、といった高度なケース——では、_bind_to_schema フックに触れます。これはフィールドがスキーマに紐付けられる瞬間に呼ばれます。

from marshmallow import fields


class ContextAwareField(fields.String):
    def _bind_to_schema(self, field_name, parent):
        # v4 ではフックの第2引数が `schema` から `parent` に改名された点に注意
        super()._bind_to_schema(field_name, parent)
        # ここで self.parent(属するスキーマ)や field_name にアクセスできる
        # 例:親スキーマの状態に応じてこのフィールドの挙動を初期化する

💡 _bind_to_schema は marshmallow 4 で引数名が schema から parent に変更されました(フィールドはスキーマだけでなく別フィールドの内側にも束縛され得るため、より正確な名前になりました)。3.x のコードを移植する際は署名を合わせてください。日常的に触る機会は多くありませんが、フィールドにスキーマ文脈を持たせたい高度な拡張で役立ちます。

なぜこれが優れているのか? TrimmedString は、「入力文字列は常にトリムされている」という不変条件を型に焼き込みます。@pre_load でスキーマごとに正規化を書く方式と比べ、フィールドを宣言した時点で正規化が保証されるため、書き忘れが起こり得ません。コンポジション(既存フィールド+追加の振る舞い)でドメイン型を組み立てるこのやり方は、CLAUDE.md でいう「継承より合成」「ETC」の体現です。


8. バリデータの合成:validate.And と再利用可能な関数

カスタムフィールドが「型変換と正規化」を担うのに対し、validate= 引数は「値が満たすべき制約」を宣言します。両者は補完関係にあり、1 つのフィールドに複数のバリデータを合成できます。

from marshmallow import Schema, fields, validate


class ProductSchema(Schema):
    # リストで渡すと、すべてのバリデータが順に適用される
    sku = fields.Str(validate=[validate.Length(equal=8), validate.Regexp(r"^[A-Z0-9]+$")])
    # validate.And は同じ合成を1つのバリデータオブジェクトとして表現する(再利用しやすい)
    code = fields.Str(validate=validate.And(validate.Length(equal=8), validate.Regexp(r"^[A-Z0-9]+$")))

validate=[a, b](リスト)と validate.And(a, b)(合成オブジェクト)は同じ効果ですが、validate.And(...)合成済みバリデータを変数に束ねて使い回せる点で優れます。プロジェクト共通の制約は、名前を付けて 1 箇所で定義しましょう。

from marshmallow import validate

# プロジェクト共通の SKU 制約を「ひとつの再利用可能なバリデータ」として定義する
sku_validator = validate.And(
    validate.Length(equal=8),
    validate.Regexp(r"^[A-Z0-9]+$", error="SKUは英大文字と数字のみ8桁です。"),
)


class ProductSchema(Schema):
    sku = fields.Str(validate=sku_validator)  # 使い回す


class InventorySchema(Schema):
    sku = fields.Str(validate=sku_validator)  # 別スキーマでも同じ制約

自作バリデータ関数:ValidationErrorraise する

組み込みバリデータで表現できない制約は、ValidationError を送出する関数として書きます。

from marshmallow import ValidationError


def validate_even(value: int) -> None:
    # v4 ではバリデータは False を返すのではなく ValidationError を raise する
    if value % 2 != 0:
        raise ValidationError("偶数を指定してください。")


class LotSchema(Schema):
    quantity = fields.Int(validate=[validate.Range(min=0), validate_even])

⚠️ v4 のバリデータは False を返してはいけない:marshmallow 3.x では validate=lambda x: x > 0 のように bool を返すバリデータが使えましたが、v4 では検証失敗時に必ず ValidationErrorraise する必要があります。return False は無視され、検証がすり抜けます。生成 AI が出力するコードや古い記事は 3.x スタイルのことが多いので、移植時は要注意です。

validate=@validates の使い分け

手段適する場面値以外の文脈
validate=(バリデータ/合成)単体で完結する静的ルール(長さ・範囲・正規表現)。再利用が容易使えない(値だけを見る)
@validates("field")スキーマのインスタンスや他の状態を参照する必要があるフィールド検証スキーマの self にアクセスできる
@validates_schema複数フィールドにまたがる不変条件(開始 < 終了 など)入力 dict 全体を参照できる

原則は「値単体の静的ルールは validate= で再利用可能に、文脈が要るなら @validates 系で」です。validate= に寄せられるものを @validates に書くと、再利用が効かず、スキーマごとに同じロジックが重複します。

なぜこれが優れているのか? sku_validator のように制約に名前を付けて合成しておくと、その制約がひとつの再利用可能な部品になります。SKU の桁数仕様が変わったとき、修正は sku_validator の定義 1 箇所で済み、それを使う全スキーマに自動的に反映されます。「検証ルールの単一の真実」を保つ——これは DRY を検証の領域にまで徹底する設計です。


結論:ドメイン型を「境界の部品」として設計する

組み込み fields で足りなくなったとき、その不足は「ドメイン固有の値オブジェクトを設計せよ」というサインです。marshmallow のカスタムフィールドは、その値オブジェクトを境界で再利用可能な部品として表現する、実績ある仕組みです。本記事の要点を再掲します。

  1. fields.Field[T] を継承し _serialize / _deserialize を実装すれば、ドメイン固有の型変換と検証を 1 箇所に閉じ込め、すべてのスキーマで再利用できる。
  2. 不正入力は _deserialize 内で ValidationError へ変換して送出し、生の例外を漏らさない。default_error_messages + make_error で文言を一元管理し、error_messages で i18n・画面別の差し替えに開いておく。
  3. 金額は Decimal・文字列出力・丸めを焼き込んだ MoneyField電話番号は E.164 に正規化する PhoneField として、業務ルールを持つ値を型にする。float で金額を扱わない。
  4. 列挙は fields.Enumby_value=True で値ベース)で、生文字列ではなく型付きメンバーとして境界を通す。
  5. 算出値は fields.Method / Function、固定値は fields.Constant で宣言し、dump_only と併せてビジネスロジックの散在を防ぐ。
  6. 既存フィールドのサブクラス化TrimmedString)と validate.And による合成で、検証ロジックを DRY に組み立てる。バリデータは v4 では必ず ValidationErrorraise する。

「動くコード」と「10 年運用できるコード」の差は、ドメインの値をどこで・どう正規化・検証し、その知識をどれだけ局所化できるかにあります。カスタムフィールドは、境界での 1 度きりの正規化を再利用可能な型へと昇華させ、規約のほころびを構造的に不可能にします。

さらなる探求として、公式ドキュメントの以下を本記事の設計観点を念頭に再読することをお勧めします。


型安全なバックエンド設計のご相談

筆者は、ここで解説した「ドメインの値を境界で 1 度だけ正規化・検証し、内部には型付きの安全な値だけを流す」という規律を、経済産業大臣賞を受賞した B2B SaaS の本番環境で実装・運用してきました。金額・電話番号・各種コードといった業務ルールを持つ値を、再利用可能なカスタムフィールドとして設計することは、課金の正確性・データの一貫性・保守性に直結します。型安全な入力検証・ドメイン型の設計・レスポンス整形・ORM 連携といった、事業の信頼性に直結する基盤を、生成 AI を活用して高速かつ高品質に構築します。Python を用いたバックエンド開発・既存システムの型安全化について、お気軽にご相談ください。

友田

友田 陽大

経済産業大臣賞 受賞プロダクト開発者。TypeScript + Python + AWS で、SaaS・業界DX・ 実用レベルの生成AI(RAG)を、要件定義からインフラ・運用まで一人で完遂します。

この記事で解説した技術の適用事例

経済産業大臣賞受賞 | 木材流通業界のDXを実現したB2BサブスクリプションSaaS

ケーススタディを見る