はじめに
仕様変更の際、ある箇所は修正したが、別ファイルにある同様のロジックを見落としていた。
あるいは、バリデーションがコントローラとバッチで二重に定義され、微妙な条件の差がバグに繋がった。
こうした 修正漏れ や 仕様の所在不明 は、個人の注意力の問題というより、ロジックの 置き場所がコード上で自明でない ことが原因になりやすいです。
ドメインモデルを活用する目的は、単に DDD の作法を踏襲することではありません。
どこを変えればよいか をコードから読み取れるようにし、型やオブジェクトの境界によって 不正な操作や取り違えを早期に検出できる 構造を用意することが中心です。
本稿では、複雑なルールを整理するためのパーツとしての 値オブジェクト・エンティティ・集約・ドメインサービス の役割と使い分けを整理します。
この記事で得られる視点
- 各パターンの役割と、それによって どのリスク を抑えられるかの整理
- 基本型に依存した実装と、モデル化した実装の対比
- 現場で線引きに迷いやすい 集約 と ドメインサービス の判断材料
ドメインモデルにおける主要概念
ドメインモデルは、業務上のルールをソフトウェアが理解できる形で表現したものです。
ロジックを適切に分散させるために、以下の4つの概念を使い分けます。
| 概念 | 役割 | メリット |
|---|---|---|
| 値オブジェクト | 値そのものに名前と制約を与える | 不正な値の混入を生成時点で検知しやすくする |
| エンティティ | 識別子で個体を識別し、状態を管理する | ライフサイクルを通じたデータの整合を保ちやすくする |
| 集約 | 関連するオブジェクトをひとつの境界にまとめる | 外部からの直接操作を抑え、不変条件を保証しやすくする |
| ドメインサービス | 複数オブジェクトにまたがるロジックを扱う | 無理な責務の押し付けによるモデルの肥大化を抑える |
値オブジェクト(Value Object)
値オブジェクトは、単なる数値や文字列などの 基本型 に名前を付け、業務上の制約を型として閉じ込めるものです。
基本型に依存した場合の問題
たとえば「注文数量」を number 型だけで扱うと、負の数や小数が混入する余地が残ります。
各レイヤーや各ファイルでバリデーションを書くことになり、条件の微妙な差や修正漏れが蓄積しやすくなります。
値オブジェクトを導入する利点
「1以上の整数である」というルールを オブジェクトの生成時 に強制できます。
その結果、妥当な経路で生成されたインスタンスは、業務上の前提を満たしていることが型と生成処理から追いやすくなります。
サンプルコード
/** 注文数量:1以上の整数であることを保証する */
export class OrderQuantity {
private constructor(public readonly value: number) {}
static create(raw: number): OrderQuantity {
if (!Number.isInteger(raw) || raw < 1) {
throw new Error('数量は1以上の整数である必要があります');
}
return new OrderQuantity(raw);
}
add(other: OrderQuantity): OrderQuantity {
return OrderQuantity.create(this.value + other.value);
}
}
アンチパターン
Math.max(0, quantity) のように場所ごとに前提を補うだけでは、業務ルールがコードベースに分散し、レビューで全体整合を確認するコストが高くなります。
エンティティ(Entity)
エンティティは、属性が変わっても 同じ個体 として識別し続けたい対象を表します。
例として、顧客や注文など、業務がライフサイクルとして追う対象が該当します。
識別子を型で表す理由
id: string のような汎用型だけでは、CustomerId と OrderId を誤って代入しても検知しにくいです。
業務が発行する 識別子 を専用の型で表すと、別種の ID の取り違えをコンパイルやコードレビューの段階で潰しやすくなります。
| 観点 | エンティティ | 値オブジェクト |
|---|---|---|
| 識別の仕方 | 識別子が同じなら同一個体 | 属性の組が同じなら同一とみなせる |
| 性質 | 状態が変化しうる | 不変であることが基本 |
| 例 | 会員、プロジェクト、注文 | 金額、日付範囲、数量 |
サンプルコード
// 値オブジェクト
export class CustomerId {
private constructor(public readonly value: string) {}
static parse(raw: string): CustomerId {
const v = raw.trim();
if (!v) throw new Error('顧客IDが空です');
return new CustomerId(v);
}
}
// エンティティ
export class Customer {
constructor(
private readonly id: CustomerId,
private displayName: string,
) {}
getId(): CustomerId {
return this.id;
}
rename(next: string): void {
const name = next.trim();
if (!name) throw new Error('表示名が空です');
this.displayName = name;
}
}
集約(Aggregate)
集約は、関連するオブジェクト群をひとつの 整合性の境界 としてまとめる単位です。
境界を守るルール
集約の外部からは、原則として 集約ルート と呼ばれる代表となるエンティティだけを経由して操作します。
内部のオブジェクトを外部から直接書き換える経路を増やすと、不変条件の維持が難しくなります。
- 整合性の維持:変更の入口をルートに限定すると、ビジネスルールの適用漏れを防ぎやすい
- 保守性:変更の入り口が限定されるため、影響範囲の調査がしやすい
オブジェクト指向で語られる aggregation と、DDD の集約は焦点が異なります。
前者は構造関係としての「所有や合成」を説明する場面が多く、後者は 整合性・変更単位・トランザクション境界 を決める単位です。
UML の構造だけから DDD の境界を機械的に決めることはできません。
関係のイメージ
サンプルコード
export class OrderLine {
constructor(
public readonly productId: string,
private quantity: OrderQuantity,
) {}
// 値オブジェクト活用
changeQuantity(next: OrderQuantity): void {
this.quantity = next;
}
// 値オブジェクト活用
getQuantity(): OrderQuantity {
return this.quantity;
}
}
// 集約(エンティティ)
export class Order {
private constructor(
private readonly id: string,
private readonly lines: OrderLine[],
) {}
static createEmpty(orderId: string): Order {
return new Order(orderId, []);
}
addLine(line: OrderLine): Order {
const exists = this.lines.some((l) => l.productId === line.productId);
if (exists) {
throw new Error('同一商品の明細は二重追加できません');
}
return new Order(this.id, [...this.lines, line]);
}
getLinesSnapshot(): readonly OrderLine[] {
return [...this.lines];
}
}
アンチパターン
明細だけをサービス層から直接生成してリストへ結合すると、ルート側のルールを迂回しやすくなります。
また、無関係な責務まで同一の集約に詰め込むと、境界が肥大化しトランザクションやロックの設計が難しくなります。
ドメインサービス(Domain Service)
送金のように 複数のエンティティを協調させる 処理は、どちらか一方のモデルにメソッドとして押し込めると不自然になりやすいです。
そのような場合に、ドメインサービスとして独立した処理として定義します。
活用しやすい場面の例
- 複数の集約やエンティティにまたがる計算や資産の移動
- 単一のエンティティに閉じると依存関係や責務の説明が難しくなる協調処理
無理にエンティティへメソッドを足すのではなく、ドメインサービスへ寄せることでモデルを単純に保てる場合があります。
一方で ロジックをすべてサービスへ寄せる と、エンティティや値オブジェクトが薄くなる ドメインモデルの貧血化 を招きやすいです。
まずは値オブジェクトやエンティティ側へ閉じられないかを検討し、協調だけをサービスへ残すことが現実的です。
ドメインサービスは ユースケースの手順書 ではなく、業務規則そのものを表す層に置く点が、アプリケーションサービスとの一般的な役割分担です。
サンプルコード
// 値オブジェクト
export class Money {
constructor(public readonly amount: number, public readonly currency: string) {}
add(other: Money): Money {
if (this.currency !== other.currency) {
throw new Error('通貨が異なるため加算できません');
}
return new Money(this.amount + other.amount, this.currency);
}
subtract(other: Money): Money {
if (this.currency !== other.currency) {
throw new Error('通貨が異なるため減算できません');
}
const next = this.amount - other.amount;
if (next < 0) throw new Error('残高が不足しています');
return new Money(next, this.currency);
}
}
// エンティティ
export class Wallet {
constructor(private balance: Money) {}
withdraw(amount: Money): void {
this.balance = this.balance.subtract(amount);
}
deposit(amount: Money): void {
this.balance = this.balance.add(amount);
}
}
/** 単一の Wallet に閉じにくい移転をドメイン側で実行する */
// ドメインサービス
export class WalletTransferDomainService {
transfer(from: Wallet, to: Wallet, amount: Money): void {
from.withdraw(amount);
to.deposit(amount);
}
}
まとめ
DDD のパターンは目的ではなく 手段 です。
重要なのは、何がドメインのルールであり、その責任をどこに置くか をコード上で追えるようにすることです。
境界と名前が整理されたモデルは、作成者以外のメンバーが読んだときにも仕様の意図を伝えやすくなります。
半年後の自分や、新しく参加したメンバーが変更点を特定しやすい構造を、優先して検討する価値があります。
参考文献
- ドメイン駆動設計をはじめよう(著:Vlad Khononov 訳:増田 亭、綿引 琢磨)