ドメイン駆動設計 (domain-driven design, 以下DDD) はソフトウェアの設計手法である。
ドメインとは、ソフトウェアが問題解決しようとする領域のことである。 DDDでは、ドメインを中心とするモデルベースな抽象化によってソフトウェア品質に寄与するという考え方が基になっている。
ソフトウェア品質の要因はいくつか存在するが、その中でDDDが寄与する5つの要素を列挙する。
参考: List of system quality attributes - Wikipedia
結合度・凝集度はいずれもソースコード品質を表す指標であり、一般的に低結合・高凝集が好ましいとされている。
結合度 (coupling) とは、モジュールやクラスなどのソフトウェアコンポーネント群がどれほど保守されやすいように分割・整理されているかの尺度であり、結合度が低いほど好ましい。 (分割・整理されている)
結合の程度
手続き型プログラミングにおける結合は以下のように分類できる。
参考: 結合度 - Wikipedia
凝集度 (cohesion) とは、モジュールやクラスなどの1つのソフトウェアコンポーネントの構成物間における責務やデータの関連の強さの尺度である。
凝集の程度
凝集の程度は以下のように分類される。
参考: 凝集度 - Wikipedia
DDDでは扱うドメインの関連度による凝集度をドメイン凝集度 (domain cohesion) と定義する。
DDDではドメイン制約を静的型で表現することによりドメイン操作の静的安全を設計するため、静的型付け言語で実装することが好ましい。
また、静的型でnullability (値がnullになりうるか) を表現できることが好ましい。
Javaの型はnullabilityを表現することができないため、「nullableな式は必ず java.util.Optional
でラップする」などの取り決めをしておくか、Kotlinの使用を検討する。
値オブジェクト (value object) は属性値によって同一性が判断されるドメインモデルの実装パターンである。
値オブジェクトへの予期しない副作用を防ぐため、値オブジェクトの各属性は不変 (immutable) とする。
これはJavaの final
やKotlinの val
などで実現できる。
値オブジェクトの等価性は属性値が同じかどうかで判定される。
Kotlinを使用している場合は data class
を使うことで実現できる。
JavaでLombokを使用している場合は @EqualsAndHashCode
あるいはそれを内包するアノテーションで実現できる。
「値オブジェクト」の定義
「値オブジェクト」という呼称はDDD外の文脈においても「値を包んだオブジェクト」の意で用いられるため、その呼称をDDDの概念で上書きしてしまうのは好ましくない。
子クラスの @EqualsAndHashCode
の罠
他クラスを継承する子クラスに対して @EqualsAndHashCode
やそれを内包するアノテーション (例: @Value
) を使用することは推奨されていない。
もし、やむを得ず子クラスに対して @EqualsAndHashCode
を使用する場合は、アノテーションの引数 callSuper
を true
にセットしない限り、親クラスのフィールドが考慮されない equals()
, hashCode()
が生成されてしまうことに注意する。
Primitive Obsession
ドメイン層で扱う値を全て値オブジェクトで表し、型検査によって引数の代入ミスを静的に防止するという考え方がある。 しかし、これは引数の代入ミスのリスクをドメイン層から他層にしわ寄せしているに過ぎない。 (どこかで値オブジェクトに詰める操作が必要になるため)
逆に、状態遷移メソッドをもたない単一の値を値オブジェクトとして扱う典型例として、BtoB領域におけるマルチテナントアプリケーションのテナント識別子がある。 テナント識別子の指定を誤ると、最悪な場合でテナントのデータが他テナントに漏洩する重大インシデントリスクがあるため、テナント識別子を値オブジェクト化して型安全を手に入れることは十分割に合うと考える。
非破壊メソッドの命名規則
値オブジェクトは不変であるため、属性の一部を変更する場合は新しい値オブジェクトに詰め直して返す。 このようなオブジェクトを変更しないメソッドを非破壊的 (non-destructive) メソッドという。
非破壊的メソッドは習慣的に受動態を使って命名することで、開発者が破壊的メソッドと勘違いして戻り値を捨ててしまうことを防ぐ。
updateEmail()
withEmailUpdated()
エンティティ (entity) は識別子 (DBMSが発番するシーケンス番号やUUIDなど) によって同一性が判断されるドメインモデルの実装パターンである。
エンティティは値オブジェクトと異なり、可変であってもよい。 (無論、不変としてもよい) これはエンティティは属性値が多く値の詰替えが困難であることを前提とした妥協案である。
Builderパターンは使わない
エンティティの生成に必要なパラメータが多い場合は、コードの可読性のためにBuilderパターンを使うことが一般的に推奨されている。
しかし、私はBuilderパターンを使うことを推奨しない。 Builderパターンは値がセットされていなくてもビルドできてしまうため、静的安全でない。 また、Kotlinなどのモダンな言語では名前付きパラメータなどで可読性の問題をクリアできるため、わざわざBuilderパターンを使う理由がない。
ORMからエンティティを生成する
一部のORMはエンティティの生成において、エンティティのデフォルトコンストラクタあるいは全フィールドを引数として受け取るコンストラクタやセッターメソッドを要求する。
これらは、「ドメイン制約を満たすオブジェクトのみを存在させる」というDDDの指針と相容れないため、使用は極力避けるべきである。
デフォルトコンストラクタについては、ORM or レポジトリのみが参照できるような可視性制御を行うか、メソッドの命名 (例: fromRepository
) で使用用途を示すべきである。
JavaでLombokを使用している場合、アノテーション @AllArgsConstructor(staticName = "fromRepository")
を付与することで、staticな全引数コンストラクタを生成できる。
参考: @NoArgsConstructor, @RequiredArgsConstructor, @AllArgsConstructor - Project Lombok
楽観ロックのバージョン管理
永続化データが楽観ロックを採用している場合は、習慣的にロックバージョンをエンティティにもつ。 ロックバージョンは明らかにドメイン都合のものでないが、これは実装コストとのバランスを考えた妥協策である。
集約 (aggregate) はいくつかのエンティティや値オブジェクトをひとまとめにしたモデルの実装パターンである。 複数の値オブジェクト/エンティティを跨ぐドメイン制約を課すことができ、その制約を満たすことを保証するために必ず単一のトランザクション内でまとめて永続化する。
ドメインオブジェクトの状態遷移メソッドは、必ずドメイン制約を満たすような単位で実装する。 ユースケースに依って不整合を生み出してしまうようなメソッドの公開は避け、操作の前に必要なバリデーションを行う。
無駄なゲッター/セッターを生やさない
Lombokのアノテーション @Value
や @Data
を使うと、エンティティの属性値のゲッター/セッターを簡単に生成することができる。
しかし、これはドメイン制約を満たさないメソッドを外部に公開することになり、「ドメイン制約を満たすオブジェクトのみを存在させる」というDDDの指針に反する。 よって、エンティティクラスの全フィールドのゲッター/セッターを自動生成するこれらのアノテーションの使用は推奨しない。
ドメインサービス (service) はドメイン制約をエンティティ or 値オブジェクト or 集約に実装することが難しい場合に使うドメイン手続きの実装パターンである。
例えば、ユーザーのメールアドレスの重複チェックを行う度に、全ユーザーのメールアドレスをフェッチして集約に格納し集約の中でチェックするとパフォーマンス的に大惨事になる。
原則として値オブジェクト or エンティティ or 集約などのドメインオブジェクトのクラスでドメイン制約を実装し、パフォーマンスなどの都合でドメインオブジェクトのクラスによる実装が難しい場合にのみドメインサービスを使うに留める。
抽象化 vs. パフォーマンス
前述のメールアドレスの重複チェックのように、抽象化はしばしばパフォーマンスとトレードオフの関係にある。
「〜Service」接尾辞
多くのDDD本では、習慣と反してドメインサービスのクラス名には「〜Service」を接尾辞につける必要はないとしている。 これは、クラスがサービスであることを区別する必要性がなく、サービスのクラス名は責務を極力簡潔に表すことが好ましいからである。
また、サービスクラスに「エンティティ名 + Service」のような命名をしてしまうと、クラス名が表す責務範囲が広いゆえに改修毎にサービスクラスが肥大化しがちである。
しかし、私は「〜Service」接尾辞は必要だと考えている。 理由は、ドメインサービスはレポジトリを呼ぶことが多く、開発者にそのことを隠蔽すると、N+1のようなパフォーマンス問題に繋がりうるからである。 (つまり、永続層へのアクセスの完全な透過性を提供することは不可能と考える)
ファクトリ (factory) は、複雑なロジックを含むエンティティ or 集約の生成メソッドである。
ファクトリがレポジトリやドメインサービスを呼び出さない場合は、生成メソッドはファクトリクラスのstaticなメソッドとして実装する。 ファクトリがレポジトリやドメインサービスなどを呼び出す (DIを必要とする) 場合は、生成メソッドはドメインサービスのメソッドとして実装する。
予期しないインスタンス化を防ぐ
ファクトリクラスのようなstaticなメソッドのみを提供するクラスは、予期せずインスタンス化されることを防ぐことが好ましい。
Kotlinを使用している場合は object
を使うことにより実現できる。
JavaでLombokを使用している場合は @NoArgsConstructor(access = AccessLevel.PRIVATE)
アノテーションをクラスに付与し、デフォルトコンストラクタの可視性を private
にすることで実現できる。
参考: @NoArgsConstructor, @RequiredArgsConstructor, @AllArgsConstructor - Project Lombok
レポジトリ (repository) は永続化や外部サービスなどの副作用を隠蔽し、アプリケーションのインメモリにあるコレクション or ハッシュマップのように振る舞い、データアクセスへの透過性を提供する。
エンティティの識別子
DBMSが発番するシーケンス番号 (MySQLの AUTO_INCREMENT
やPosgreSQLの SERIAL
など) を識別子に使用しているエンティティを新規に作成するとき、DBMSがレコード挿入の要求を完了してシーケンス番号を返すまでエンティティの識別子が確定しない (nullableになってしまう) ことになる。
エンティティの識別子がnullableになると、識別子を参照する都度null/non-nullでの分岐が必要になり、操作が煩雑になってしまう。
これを避けるには
などを検討する。
参考: ID生成大全 - Qiita