本章の方針
Value Object(値オブジェクト、以下VO)は、戦術的DDDの8パターンの中で最も費用対効果が高い。導入コストは低く、得られるリターンは大きい。本章では、なぜVOが有効なのか、どう実装するか、そしていつ VO化してはいけない のかを扱う。
なぜVOから始めるべきか
多くのバグは、「意味の違うものを同じ型で扱っている」ことから生まれる。この病気に名前がついている。Primitive Obsession(プリミティブ型病) だ。
SaaS課金管理でよくある光景を見てみよう。
// ❌ プリミティブ型病に感染したコード
function applyPayment(
subscriptionId: string,
customerId: string,
amount: number,
currency: string,
discountAmount: number,
) {
// 割引後の金額を計算
const finalAmount = amount - discountAmount
// ...
}
// 呼び出し側
applyPayment(
'sub_123',
'cus_456',
1000, // ドル?円?
'JPY',
200, // ← ドルの割引を円の金額に引いている!
)
このコードには少なくとも2つのバグが潜む。
amountとdiscountAmountの通貨が異なるケースを防げないsubscriptionIdとcustomerIdを引数順で取り違えても型チェックが効かない
どちらも string と number というプリミティブ型で全てを表現している ことが原因だ。型を分ければ、コンパイル時にバグが止まる。
Value Objectの3要件
Evans青本とVernon赤本の両方が認める、VOの3要件を整理する。
| 要件 | 意味 | 例 |
|---|---|---|
| 等価性(Equality) | 値の内容が同じなら同一とみなす | Money(1000, 'JPY') と Money(1000, 'JPY') は同じ |
| 不変性(Immutability) | 生成後に状態を変更できない | money.add(500) は新しい Money を返す |
| 自己検証(Self-validation) | 生成時点で不正な値を拒絶する | Money(-100, 'JPY') は生成できない |
この3つを満たすクラスがVOだ。追加で 副作用を持たない(メソッドが内部状態を変えない)ことも特徴として挙げられる。
TypeScriptでの実装パターン
2026年時点、TypeScriptでのVO実装には主に3つの流派がある。それぞれの特徴を見ていく。
パターンA:クラスベース(Vernon流)
// [VO] Money
class Money {
private constructor(
readonly amount: number,
readonly currency: Currency,
) {}
static of(amount: number, currency: Currency): Money {
if (amount < 0) throw new Error('Money cannot be negative')
if (!Number.isFinite(amount)) throw new Error('Money must be finite')
return new Money(amount, currency)
}
add(other: Money): Money {
if (!this.currency.equals(other.currency)) {
throw new Error(`Cannot add ${this.currency} and ${other.currency}`)
}
return new Money(this.amount + other.amount, this.currency)
}
equals(other: Money): boolean {
return this.amount === other.amount && this.currency.equals(other.currency)
}
}
// 使用
const price = Money.of(1000, Currency.JPY)
const discount = Money.of(200, Currency.JPY)
const final = price.add(discount.negate()) // 型安全
長所:OOPとしてのオーソドックスさ、メソッドを生やしやすい、Evans/Vernonに忠実 短所:ボイラープレートが多い、ORMや DTO との変換が面倒、JSONシリアライズに一手間かかる
パターンB:Branded Type(TypeScript固有)
// [Branded Type] SubscriptionId / CustomerId
type Brand<K, T> = K & { readonly __brand: T }
type SubscriptionId = Brand<string, 'SubscriptionId'>
type CustomerId = Brand<string, 'CustomerId'>
const SubscriptionId = (value: string): SubscriptionId => {
if (!value.startsWith('sub_')) throw new Error('Invalid SubscriptionId')
return value as SubscriptionId
}
// 使用
const subId: SubscriptionId = SubscriptionId('sub_123')
const cusId: CustomerId = CustomerId('cus_456')
function cancel(id: SubscriptionId) { /* ... */ }
cancel(cusId) // ❌ コンパイルエラー。CustomerIdはSubscriptionIdではない
長所:ゼロコストに近い、プリミティブとしてシリアライズできる、ID型に最適
短所:ランタイムでは string、メソッドを生やせない、リフレクションで型が消える
パターンC:Zod/Valibotスキーマベース
// [Zodによる VO 定義]
import { z } from 'zod'
const MoneySchema = z.object({
amount: z.number().nonnegative().finite(),
currency: z.enum(['JPY', 'USD', 'EUR']),
}).brand<'Money'>()
type Money = z.infer<typeof MoneySchema>
const price: Money = MoneySchema.parse({ amount: 1000, currency: 'JPY' })
長所:APIレスポンスのパースとVO定義を統一できる、型とランタイム検証が同居する 短所:メソッドを生やしにくい、厳密なOOP的VOではない、Zodの学習コスト
2026年の推奨:ハイブリッド
筆者の2026年現在の推奨は ハイブリッド だ。
・ID型(SubscriptionId, CustomerId など):Branded Type(パターンB)
→ メソッドが不要、API境界でシリアライズする頻度が高い
・振る舞いを持つ値(Money, EmailAddress, DateRange):クラスベース(パターンA)
→ メソッド(add, isBefore, domain など)が必要
・API境界の検証:Zod/Valibot(パターンC)
→ 境界でのみ使用、ドメイン層には持ち込まない
1つの流派に固執する必要はない。用途に応じて使い分ける のが2026年の現実解だ。
SaaS課金管理での具体例
通し事例で Money 以外にどんなVOが出現するかを見る。
// [VO] BillingPeriod(請求期間)
class BillingPeriod {
private constructor(
readonly start: Date,
readonly end: Date,
) {}
static of(start: Date, end: Date): BillingPeriod {
if (start >= end) throw new Error('start must be before end')
return new BillingPeriod(new Date(start), new Date(end))
}
// 比例按分の計算に必要な「経過割合」を返す
proratedRatio(asOf: Date): number {
const total = this.end.getTime() - this.start.getTime()
const elapsed = asOf.getTime() - this.start.getTime()
return Math.max(0, Math.min(1, elapsed / total))
}
overlaps(other: BillingPeriod): boolean {
return this.start < other.end && other.start < this.end
}
}
// [VO] Proration(比例按分率)
class Proration {
private constructor(readonly ratio: number) {}
static of(ratio: number): Proration {
if (ratio < 0 || ratio > 1) throw new Error('ratio must be in [0, 1]')
return new Proration(ratio)
}
apply(amount: Money): Money {
return amount.multiply(this.ratio)
}
}
重要なのは、BillingPeriod.proratedRatio() のようにVOがドメインロジックを自然に持つ ことだ。これをEntityやServiceに書いてしまうと、「Moneyに対する按分計算」が散らばる。VOに置くことで、計算ロジックが一箇所に集まる。
VO化すべきでないもの
VOを学ぶと「全てをVO化したい病」にかかる。これは新しい病気だ。治療法を書いておく。
避けるべきVO化の例
// ❌ 過剰なVO化
class BooleanFlag {
private constructor(readonly value: boolean) {}
static of(value: boolean): BooleanFlag { return new BooleanFlag(value) }
isTrue(): boolean { return this.value }
}
// ❌ 意味を持たない数値のVO化
class Count {
private constructor(readonly value: number) {}
static of(value: number): Count {
if (value < 0) throw new Error('count must be >= 0')
return new Count(value)
}
}
判断基準:VO化するのは 「意味のある不変条件」または「振る舞い」を持つ場合 だけだ。
✅ VO化すべき:
・Money(通貨の混同を防ぎたい、四則演算の意味を守りたい)
・EmailAddress(形式の検証、ドメイン部の抽出など振る舞いがある)
・SubscriptionId(他のIDとの混同を防ぎたい)
・BillingPeriod(期間の重なり判定、比例按分などの振る舞いがある)
❌ VO化しないほうがよい:
・単なる真偽値
・表示用のラベル文字列
・UI側の状態フラグ
・意味のない連番(内部的なカウンター)
VOの肯定的な体験
筆者が実際にVOで救われた事例を3つ挙げる。
体験1:通貨混同バグの全滅
以前所属していたチームでは、請求金額を number で扱っていた。ある日、ドル建てプランの顧客に円の請求書を送るバグが発生した。全ての金額を Money VOに置き換えた結果、類似バグは以後ゼロ件 になった。コンパイラが通貨の不整合を検出するためだ。
体験2:IDの取り違えが検出されるようになった
Brand<string, 'CustomerId'> のようなBranded Typeを導入した結果、関数引数の順序ミス が型検査で止まるようになった。「subscriptionId と customerId を入れ替えた」というレビューコメントが激減した。
体験3:ドメイン知識がVOに集約された
BillingPeriod.proratedRatio() のようなメソッドは、かつてはユーティリティ関数として utils/billing.ts に散らばっていた。VO化した結果、ビジネスルールがドメイン層に集まり、UIや通知層から呼ばれてもドメインロジックが外に漏れない 状態になった。
VOの否定的な体験
一方で、VOを盲信すると痛い目に遭う。筆者が実際に後悔した事例を2つ挙げる。
体験1:ボイラープレートで窒息
初期のプロジェクトで、「ドメイン層のフィールドは原則すべてVO化」というルールを設定した。結果、単純なCRUDエンドポイントでも10〜20個のVOインスタンスを生成する ような実装になり、1リクエストあたりのオブジェクト生成コストがプロファイラで目立つレベルに達した。
学び:VO化は「意味のある不変条件/振る舞い」がある場合だけに絞る。
体験2:ORM との摩擦
TypeORM(2026年時点でも広く使われている)でVOを扱うと、@Column() でのプリミティブ変換ロジックが複雑化する。特に Money のような複数フィールドを持つVOは @Embedded() の扱いが面倒で、「マイグレーションのたびに壊れる」状態になった。
学び:Repository層で プリミティブ ⇔ VO の変換 を明示的に行い、ORMのマッピング機能に頼りすぎない。詳細は Ch.6 で扱う。
アンチパターン:VO化のアンチパターン
VOで陥りがちな失敗を3つ整理しておく(Ch.8の総覧でも扱う)。
| アンチパターン | 症状 | 脱出法 |
|---|---|---|
| VO爆発 | 単純な値まで全てVO化してボイラープレート地獄 | 「不変条件または振る舞いがある場合のみ」ルールを徹底 |
| 可変VO | VO内部のフィールドを mutable にしてしまう | TypeScriptなら readonly、言語によっては immutable ラッパーを使う |
| 等価性の未実装 | equals() を書かず、参照等価で比較してしまう | 生成時点で equals() を必ず実装する |
本章のまとめ
・VOは戦術的DDDで最も費用対効果が高い
・3要件は「等価性・不変性・自己検証」
・2026年は「ID型はBranded Type、振る舞いを持つ値はクラス」のハイブリッドが現実解
・VO化は「意味のある不変条件/振る舞い」がある場合だけに絞る
・全てをVO化する病には気をつける
次章では、IDを持つオブジェクトの扱い方── Entity と Factory を扱う。