Skip to content

値オブジェクトとは:DDDで学ぶ設計手法

はじめに

ソフトウェア開発において、システムが複雑になる原因は情報量の多さではありません。「同じものを同じと認識できない」状況こそが、複雑さを生み出す根本的な要因です。

例えば、金額の丸め方が画面ごとに異なったり、日付がAPIではUTC基準なのにデータベースではローカル時刻で扱われたりする状況を経験したことはありませんか。このような値の扱いの不一致は、バグの温床となり、システムの保守性を著しく低下させます。

本記事では、ドメイン駆動設計(DDD)における値オブジェクト(Value Object)の本質を解説します。「値を意味で扱う」という設計思想から、不変性・値比較・置換可能性の3つの性質、そしてKotlinでの具体的な実装方法まで、体系的にご紹介します。

値オブジェクトの基本概念

ドメイン駆動設計における値オブジェクトは、単なるデータではなく「意味を持つ値」として設計される構造です。数値や文字列といったプリミティブ型をそのまま使うのではなく、業務上の意味や制約を型として表現します。

以下の図は、プリミティブ型と値オブジェクトの違いを示しています。


flowchart TD
    A[プリミティブ型] --> B[意味が曖昧]
    A --> C[制約がない]
    A --> D[混同のリスク]

    E[値オブジェクト] --> F[意味が明確]
    E --> G[制約を内包]
    E --> H[型で区別]

    style A fill:#ff6b6b
    style E fill:#51cf66

データではなく意味を扱う仕組み

プログラム上の数値や文字列は、コンピュータにとってはただの記号にすぎません。しかし、現実の業務では値に単位・制約・解釈が存在します。

例えば、以下のようなKotlinコードを見てください。


val price = 100   // 100円? 100ドル?
val stock = 100   // 100個? 100ケース?

このコードでは「何の100か」が曖昧です。同じ整数型でも、金額と在庫数は混ぜてはいけない性質を持ちます。しかし、上記のコードではこれらを同じ数値として足し算できてしまいます。

値オブジェクトは、この曖昧さを型で明示的に区別します。実際に金額を扱う値オブジェクトを見てみましょう。


data class Money(val amount: Int) {
    init {
        require(amount >= 0) { "金額は0以上でなければなりません" }
    }
}

data class Quantity(val count: Int)

この実装により、Money(100)とQuantity(100)は別の意味を持つ値として扱われます。コンパイラが「金額と在庫数を足す」ような誤った操作を防いでくれるのです。

値の一貫性を保証する役割

システムが成長すると、「同じ値なのに扱いが違う」という問題が頻発します。ある画面では税抜、別の画面では税込で表示される。APIはUTC、データベースはローカル時刻で記録される。こうした不整合は、コードの複雑化よりも「意味の不一致」によって起こります。

以下の図は、値オブジェクトが一貫性を保証する仕組みを示しています。


flowchart LR
    A[すべての箇所] --> B[同じ型を使用]
    B --> C[値の意味が統一]
    C --> D[不整合が防止される]

    style D fill:#51cf66

値オブジェクトを使えば、このズレを防げます。すべての箇所でMoney型を使えば、「100」という数字を「金額」としてしか扱えなくなります。


val total = Money(100)
val discount = Money(20)
// val result = total + discount // ← この演算は未定義

このように、「何ができて、何ができないか」を明確にすることで、システム全体が共通の意味体系で動くようになります。

値オブジェクトの3つの重要な性質

値オブジェクトは「小さな完全性」を単位として設計されます。その核心は、不変性・値比較・置換可能性という3つの性質に集約されます。これらの性質を欠くものは、もはや「値」ではなく「状態を持つもの」、すなわちエンティティです。

以下の図は、3つの性質の関係を示しています。


flowchart TD
    A[値オブジェクト] --> B[不変性]
    A --> C[値比較]
    A --> D[置換可能性]

    B --> E[信頼性を保証]
    C --> F[同一性を判断]
    D --> G[安全な合成]

    E --> H[健全な設計]
    F --> H
    G --> H

    style A fill:#4dabf7
    style H fill:#51cf66

不変である

値オブジェクトは生成された瞬間に完結します。内部の値が外部操作によって変更されることはなく、必要であれば新しいインスタンスを生成します。この「不変性(immutability)」が守られることで、値の信頼性と再利用性が保証されます。


data class FullName(val first: String, val last: String)

// FullNameは不変。変更ではなく新しい値を生成する
val name1 = FullName("Masanobu", "Naruse")
val name2 = name1.copy(last = "Sato")

ここで起きているのは代入ではなく置換です。name1は生成時のまま、永遠に「Naruse」であり続けます。「Sato」への変更は新しいFullNameを生み出す行為であって、既存の値を変質させるものではありません。

不変であることは単なる美徳ではありません。状態を変えないことで、スレッドセーフな操作・キャッシュの安定性・予測可能な動作が得られます。逆に、値が変わり得る場合、すべての利用箇所で同期・整合・再検証が必要になり、そのコストはシステムの複雑性として跳ね返ります。

値で比較される

値オブジェクトは「誰か」ではなく「何であるか」で同一性を判断します。つまり、参照の等価性ではなく構成する値の一致によって比較されます。


data class FullName(val first: String, val last: String)

val a = FullName("Masanobu", "Naruse")
val b = FullName("Masanobu", "Naruse")
println(a == b) // true

このコードにおいてaとbは別々のインスタンスですが、その属性がすべて一致しているため「同じ値」として扱われます。この比較原理が、オブジェクト指向の「識別子による同一性」とは異なる点であり、DDDにおける値オブジェクトとエンティティの境界線を明確にします。

以下の図は、値比較の仕組みを示しています。


sequenceDiagram
    participant A as インスタンスA
    participant B as インスタンスB
    participant C as 比較処理

    A->>C: first="Masanobu", last="Naruse"
    B->>C: first="Masanobu", last="Naruse"
    C->>C: 値が一致するか確認
    C-->>A: 同じ値として判定
    C-->>B: 同じ値として判定

もし値が参照で比較されるならば、たとえ内容が同じでも「別のもの」として扱われ、ドメイン上の一貫性が失われます。したがって、等価比較は意味的同一性を保証するための最低条件です。

置き換えが可能である

値オブジェクトのもう一つの重要な性質は、いつでも同値の別インスタンスに置き換えられることです。値そのものが独立しており、外部との結合を持たないからこそ、代替が容易です。


val current = FullName("Masanobu", "Naruse")
val updated = FullName("Masanobu", "Sato")

val employee = Employee(id = 1, name = current)
val changed = employee.copy(name = updated)

ここでEmployeeが依存しているのは「FullNameの中身」ではなく、「FullNameという値の単位」です。したがって、nameをupdatedに差し替えても、他の構造には何の副作用も生じません。

この置換可能性こそが、値オブジェクトを安全に合成できる構成要素たらしめています。言い換えれば、置換可能性とは「局所的完全性」の証明です。ひとつの値が別の同値な値に差し替えられても、全体の意味体系が崩れないよう設計されていること──それがドメインモデリングにおける健全性の指標です。

値オブジェクトを使う3つの理由

システムが大きくなるほど、値の扱いは細部でばらつき始めます。このばらつきは、バグの発生源であると同時に、設計の劣化を静かに進行させる原因でもあります。値オブジェクトは、その混乱を未然に防ぐための最も単純で、最も強力な構造です。

以下の図は、値オブジェクトがもたらす3つの効果を示しています。


flowchart TD
    A[値オブジェクトの導入] --> B[不正な状態を防ぐ]
    A --> C[意図を明確に表す]
    A --> D[複雑さを抑える]

    B --> E[型で制約を保証]
    C --> F[コードが自己説明的]
    D --> G[ロジックを集約]

    E --> H[保守性の向上]
    F --> H
    G --> H

    style A fill:#4dabf7
    style H fill:#51cf66

不正な状態を防ぐ

不正なデータがシステムに侵入する経路の多くは、プリミティブ型の乱用にあります。文字列や数値がそのまま使われると、意味や制約が失われ、どの値が正しいのかを判断するために多くの条件分岐が必要になります。

値オブジェクトを用いれば、その制約を型に閉じ込められます。


data class EmailAddress(val value: String) {
    init {
        require(value.contains("@")) { "メールアドレスの形式が不正です" }
    }
}

このように、生成時点でドメインのルールを保証できます。以降の処理では「検証済みのEmailAddress」として安心して扱えるため、不正状態を事前に遮断できます。

意図を明確に表す

「値が何を意味しているのか」が明確であれば、コードの理解は格段に容易になります。プリミティブ型ではその意図が曖昧になりやすく、変数名やコメントに頼らなければ意味を読み取れません。

値オブジェクトは、名前そのものが意味を伝えます。


data class Date(val value: String)
data class Period(val start: Date, val end: Date)

val period = Period(start = Date("2025-10-01"), end = Date("2025-10-31"))

この一行だけで、期間を表す構造であることが明確になります。型がドキュメントの代替となり、コードが自己説明的になります。

コードの複雑さを抑える

値オブジェクトを導入することで、共通の検証・変換・計算処理を一箇所に集約できます。これにより、ロジックの重複や分散を防ぎ、システム全体の保守性を高めることができます。


data class Money(val amount: Int, val currency: String) {
    fun add(other: Money): Money {
        require(currency == other.currency) { "通貨単位が異なります" }
        return Money(amount + other.amount, currency)
    }
}

「金額の加算」に関するルールは、Moneyの内部に閉じ込められます。この設計により、同じロジックを複数の箇所に重複して書く必要がなくなります。結果として、変更の影響範囲が明確になり、システムが予測可能な形で進化できます。

値オブジェクトの実装における注意点

値オブジェクトを実装する際には、いくつかの重要な注意点があります。これらを理解することで、より堅牢で保守性の高い設計が実現できます。

バリデーションは生成時に行う

値オブジェクトの制約は、インスタンスの生成時に検証します。一度生成された値オブジェクトは不変であるため、生成後の検証は不要です。


data class Age(val value: Int) {
    init {
        require(value >= 0) { "年齢は0以上でなければなりません" }
        require(value <= 150) { "年齢は150以下でなければなりません" }
    }
}

このアプローチにより、「Ageインスタンスが存在する = 有効な年齢である」という保証が得られます。

単一責任の原則を守る

値オブジェクトには、その値に関連する操作のみを定義します。関係のない処理を詰め込むと、責任範囲が曖昧になり、保守性が低下します。


// 良い例:Moneyに関連する操作のみ
data class Money(val amount: Int, val currency: String) {
    fun add(other: Money): Money {
        require(currency == other.currency) { "通貨単位が異なります" }
        return Money(amount + other.amount, currency)
    }

    fun multiply(factor: Int): Money {
        return Money(amount * factor, currency)
    }
}

// 悪い例:無関係な操作を含める
// fun formatForDisplay(): String // 表示はプレゼンテーション層の責任

等価性の実装を忘れない

Kotlinのdata classは自動的に等価性メソッドを実装してくれますが、他の言語では明示的に実装する必要があります。値オブジェクトは必ず値による比較を行うよう実装してください。

終わりに

本記事では、ドメイン駆動設計における値オブジェクトの本質と実装方法をご紹介しました。

値オブジェクトは、プリミティブ型の曖昧さを排除し、システム全体で値の意味を統一する強力な設計手法です。不変性・値比較・置換可能性の3つの性質を理解し、適切に実装することで、バグの少ない保守性の高いコードを実現できます。

実際のプロジェクトでは、まず小さな値オブジェクトから始めることをお勧めします。EmailAddressやMoneyといった明確な意味を持つ値から導入し、徐々に適用範囲を広げていくことで、チーム全体が値オブジェクトの効果を実感できるでしょう。

次のステップとして、値オブジェクトと対をなす概念であるエンティティについて学ぶことで、DDDの全体像がより明確になります。ぜひ実践を通じて、意味で動くシステム設計を体験してください。

ドメイン駆動設計についてさらに学びたい方は、以下のリソースをご参照ください。

エリック・エヴァンスのドメイン駆動設計

エリック・エヴァンスのドメイン駆動設計 | 翔泳社 Shoeisha

Kotlin公式ドキュメント

Kotlin Docs | Kotlin Kotlin Help

Martin Fowler – Value Object

bliki: Value Object martinfowler.com

コメントを残す