導入:組み込み 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 |
_serializeのobjは変換元のオブジェクト全体、_deserializeのdataは入力 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]] という型引数のおかげで、「このフィールドの _deserialize は list[int] を返す」という契約が型として明示され、ETC(Easy To Change)にも効きます。変換ルールが変わっても、修正箇所は PinCode の中だけです。
2. エラー設計と i18n:make_error でメッセージを一元管理する
PinCode ではエラーメッセージを _deserialize 内にベタ書きしました。動きはしますが、メッセージが実装ロジックに埋め込まれているため、多言語対応や文言の統一が困難です。marshmallow は、この問題を default_error_messages と make_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]はvalueがNoneやintだと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.2が0.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-5678、09012345678、+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.Function は serialize 用の呼び出し可能オブジェクトを取り、必要なら 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) # 別スキーマでも同じ制約
自作バリデータ関数:ValidationError を raise する
組み込みバリデータで表現できない制約は、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 では検証失敗時に必ずValidationErrorをraiseする必要があります。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 のカスタムフィールドは、その値オブジェクトを境界で再利用可能な部品として表現する、実績ある仕組みです。本記事の要点を再掲します。
fields.Field[T]を継承し_serialize/_deserializeを実装すれば、ドメイン固有の型変換と検証を 1 箇所に閉じ込め、すべてのスキーマで再利用できる。- 不正入力は
_deserialize内でValidationErrorへ変換して送出し、生の例外を漏らさない。default_error_messages+make_errorで文言を一元管理し、error_messagesで i18n・画面別の差し替えに開いておく。 - 金額は
Decimal・文字列出力・丸めを焼き込んだMoneyField、電話番号は E.164 に正規化するPhoneFieldとして、業務ルールを持つ値を型にする。floatで金額を扱わない。 - 列挙は
fields.Enum(by_value=Trueで値ベース)で、生文字列ではなく型付きメンバーとして境界を通す。 - 算出値は
fields.Method/Function、固定値はfields.Constantで宣言し、dump_onlyと併せてビジネスロジックの散在を防ぐ。 - 既存フィールドのサブクラス化(
TrimmedString)とvalidate.Andによる合成で、検証ロジックを DRY に組み立てる。バリデータは v4 では必ずValidationErrorをraiseする。
「動くコード」と「10 年運用できるコード」の差は、ドメインの値をどこで・どう正規化・検証し、その知識をどれだけ局所化できるかにあります。カスタムフィールドは、境界での 1 度きりの正規化を再利用可能な型へと昇華させ、規約のほころびを構造的に不可能にします。
さらなる探求として、公式ドキュメントの以下を本記事の設計観点を念頭に再読することをお勧めします。
型安全なバックエンド設計のご相談
筆者は、ここで解説した「ドメインの値を境界で 1 度だけ正規化・検証し、内部には型付きの安全な値だけを流す」という規律を、経済産業大臣賞を受賞した B2B SaaS の本番環境で実装・運用してきました。金額・電話番号・各種コードといった業務ルールを持つ値を、再利用可能なカスタムフィールドとして設計することは、課金の正確性・データの一貫性・保守性に直結します。型安全な入力検証・ドメイン型の設計・レスポンス整形・ORM 連携といった、事業の信頼性に直結する基盤を、生成 AI を活用して高速かつ高品質に構築します。Python を用いたバックエンド開発・既存システムの型安全化について、お気軽にご相談ください。