ドメイン駆動設計を実装に落とし込むための設計方針
ドメイン駆動設計を実践する際に、ドメイン知識を具体的にソースコードに落とし込む方針を簡単にまとめます。1つの例であって絶対的な基準ではありませんが、参考となる設計方針を提供するためにこの記事を書きました。プログラミング言語はJavaを前提とします。
Value Object
Javaにおける値のように振る舞うオブジェクトを Value Object と呼びます。取り得る値の範囲が限定されていたり、固有の演算やロジックを持ちます。Value Object は単なる型安全ラッパーだけでなく、複数の値や、それらに関する制約・演算を1つの概念としてまとめるために利用します。
単純な例として String や UUID をラップした UserId のような型安全ラッパーがあります。
複数の値をまとめて1つのオブジェクトとして扱いたい場合も Value Object として1つにまとめることができます。例えば、ユーザーの氏名を別々のフィールドに持つクラスが考えられます。2つのフィールド familyName と givenName を持つ Username クラスを作ることができるでしょう。[1]
日付の区間を表現する DateRange のようなクラスも Value Object です。期間の開始日と終了日を持ち、開始日が終了日の後になってはならないという制約を持ちます。また、与えられた日付が DateRange に含まれるかどうか判定するロジックや DateRange 同士が重複する期間を持つかどうか判定するロジックを持つでしょう。[2]
有効なメールアドレスを表現する EmailAddress クラスも Value Object として考えることができます。このクラスは受け入れ可能なメールアドレスをインスタンスの構築時に検証するようにできます。[3]
ところで、なぜ Value Object という概念が必要になるかというと、Javaではオブジェクトを比較する際にデフォルトでは参照を比較するため、すべてのフィールドの値が同じである2つのインスタンスがあっても、そのままでは比較しても同じものとは扱われないからです。Value Objectとして扱うためには equals や hashCode メソッドを適切に定義する必要があります。equals や hashCode を定義しなければ、2つのインスタンスの各フィールドの値が同じときに、それらのインスタンスは同じものとして扱えません。Javaであれば Lombok を使えばアノテーションを使って自動で定義できるので、実装に手間はかかりません。[4]
Entity
値ではなく一意性で識別されるオブジェクトです。複数のValue Objectの組み合わせで表現されます。Entityは他のEntityを内部に持つこともありますが、これはAggregateのセクションで後述します。通常は時間とともに状態が変化し、作成・更新・削除といったライフサイクルを持ちます。多くの場合、データベースに永続化されます。
例えば、ユーザーのプロフィールを表現するクラス UserProfile であれば、次のようなフィールドを持つでしょう。
class UserProfile {
UserId userId;
Username username;
Date birthDate;
Occupation occupation; // 職業
}
もし、ある UserProfile のインスタンスの occupation の値を更新しても、同じユーザーとして扱うのであれば、それは Entity として扱うのが良いでしょう。
多くの場合、Entity をデータベースに永続化する都合で、Entity は一意なIDを持ちます。ただし、常にそうだとは限らず、時にはフィールドの値を組み合わせて一意性を判断することもあります。もちろん、Javaで実装するのであれば、一意性の判断方法をもとにして equals と hashCode を適切に実装する必要があります。
Value Object は値が同じなら同じものですが、Entity は値が変わっても同じものとして扱われます。
この例では UserProfile を更新履歴や独立したライフサイクルを持つ Entity として扱いましたが、Value Object として設計することも可能です。
Aggregate
Aggregateは整合性を保ちながら一緒に管理するEntityのまとまりです。Aggregateの外部から内部のEntityを直接操作せず、ルート(root)となるEntityを通して操作します。また、Aggregateはデータベーストランザクションの境界です。Aggregate内のEntityは他のEntityを保持するだけでなく、別のAggregateへの参照を持つことがあります。
例えば、ユーザーのアカウントを User という Entity で表現するとしましょう。このとき、User は以下のフィールドを持つでしょう。
class User {
UserId userId;
EmailAddress emailAddress;
UserProfile userProfile;
Status status;
List<AuthenticationMethod> authenticationMethods; // 認証方法 e.g. パスワード, パスキー
public void updateUserProfile(UserProfile newUserProfile) {}
public void addAuthenticationMethod(AuthenticationMethod authMethod) {}
// and other methods
}
User は UserId で識別されます。そして、それぞれ1つだけの EmailAddress と UserProfile を持ちます。ただし、 UserProfile は作成されていない場合に null を許容します。また、ユーザーの状態(メールアドレス未確認、メールアドレス確認済み、凍結中、退会済み)を1つだけ持ちます。
一方、認証方法(パスワード認証やパスキー)は複数紐付けることが可能なので、複数の AuthenticationMethod を持ちます。
そして User は内部の Entity を操作するメソッドを持ちます。例えば User.updateUserProfile はプロフィール情報を更新します。addAuthenticationMethod は認証方法を追加します。
トランザクションの観点からはこの程度の粒度のAggregateで妥当でしょう。Userを削除したのに UserProfile や AuthenticationMethod がデータベースに残っていてもゴミ収集が面倒なので、Aggregateを細かく分けても実務的なメリットがないかもしれません。また、 Status が退会済み以外の状態なのに User が削除されてしまった状況も悩ましいので起きては欲しくないでしょう。[5]
本来は不変条件をアトミックに守る必要があるかどうかでAggregateとして1つにまとめるか判断しますが、不変条件を最初から見つけるのは難しいため、実務的なご利益を考えた方がわかりやすいかもしれません。
他にもパフォーマンスの観点から大きすぎる集約は望ましくないかもしれません。あるAggregateが持つEntity全体が一度に読み書きされる実装の場合、データベースのスループットやアプリケーションサーバーのメモリ使用量に悪影響を与える可能性があります。
ただし、金融のようなトランザクション整合性が重要な領域ではAggregateが大きくなることを許容しないといけないかもしれません。ソースコード上ではAggregateを分割していても、実装上は常に同じデータベーストランザクションで操作する設計を採用することもあります。複数のデータベースにまたがる操作は1つのトランザクションで更新できなくなります。[6]
そのため、transactional outbox patternのような手法を利用して整合性を維持する必要があります。[7]
外部Aggregateの参照
Aggregate内のEntityは外部Aggregateを参照することができます。例えば、ユーザーが配送先として住所を複数登録できるようにすることを考えます。配送先住所を表現するEntityは以下のようなフィールドを持つでしょう。
class Address {
AddressId id; // 一意キー
UserId userId; // 紐付くユーザーのID
String name; // 受取人の名前
Country country; // 配送先の国
String phoneNumber; // 連絡先電話番号
String zipCode; // 郵便番号
String addressLine; // 住所
}
先ほど紹介した User は複数の Address への参照を持つことができます。概念図の上では箱から矢印を引くことになりますが、より実装に近いレイヤーではどのようなインターフェースにするか考える必要があります。例えば、 User に List<AddressId> addressIds というフィールドを追加することができます。この場合、User をデータベースからロードする際に addressIds をロードできるように実装する必要があります。
あるいは、Address の Repository から userId を指定してそれに紐付く配送先住所のリストを取得するインターフェースも考えられます。ユーザーが配送先住所変更画面にアクセスする際に配送先住所のリストを取得するようなアクセスパターンであればこれで十分でしょう。
マスタデータへの参照も外部Aggregateへの参照として設計することができます。例えば、UserProfile が持つ Occupation はマスターデータとして管理したいかもしれません。
EntityやAggregateの設計の段階で、データベーストランザクション、性能、アクセスパターンを考える必要性が出てきます。非機能要件に応じて適切な設計を選択する必要があります。
Repository
RepositoryはAggregateの取得・保存等の永続化を担当するインターフェースです。データベースを操作するため、テーブル設計やアクセスパターンのような具体的な設計を知っている必要があります。
データベースに対するインターフェースとして、複数のクエリ方法を提供する必要があるでしょう。クエリ方法は基本的にユースケースの要求によって決まります。主キーによるルックアップのみなのか、あるいはフィールドの値で検索できるようにするか、最新N件だけ取得できるようにするか、いろいろな選択肢があります。N+1問題の回避のような効率的なクエリのためのメソッドを用意することもあるでしょう。
Entityを永続化するテーブルが外部キー参照を持つ場合に、eager loadするかlazy loadするかのような設計判断を表現する必要があります。また、Entityの状態変化をデータベース上でどのように表現するかも設計判断に依存します。テーブルが状態を表現するカラムを持つこともありますし、削除等の一部の状態のレコードは別のテーブルに移動することもあります。
永続化に利用するデータベースもリレーショナルデータベースなのかDynamoDBなのかで実現可能な機能が変わってきます。あるいは単なるオブジェクトストレージの可能性もあります。Repositoryの設計にはかなり実装寄りの情報が必要です。
Domain Service
Domain Serviceはある1つのEntityやAggregateだけでは判断できないロジックを提供します。しばしば、データベースの制約だけでは業務ルールを十分に表現できないケースで利用されます。業務ドメインに固有のロジックがDomain Serviceに実装されます。
Domain Serviceは他の概念と異なりあやふやなものだと思います。複数のEntityやAggregateを受け取って純粋に計算するサービスもあれば、複数のAggregateや Entity にまたがる状態変更を伴う操作を実装することもあるでしょう。また、データを外部のサービスに渡して何らかの計算を行うことも考えられます。
例えば不正利用されている可能性があるユーザーを探す機能はDomain Serviceとして実装されるでしょう。この機能の名前を FraudCheckServiceとします。FraudCheckServiceはユーザーを表現するEntityの他にセッション履歴を表現するEntityを入力として、アクセス元のIPアドレス等の情報から不正ログインの可能性を判断します。
class FraudCheckService {
public RiskScore performFraudCheck(User user, List<SessionHistory> sessionHistories) {}
}
FraudCheckServiceはルールベースでリスクスコアを計算する実装も考えられますし、機械学習で判定する外部サービスを呼び出してリスクスコアを計算することも考えられます。
Use Case / Application Service
人によって呼び方が変わるレイヤーですが、ユースケースを表現するレイヤーとしてUse Case または Application Service があります。ここではUse Case と呼ぶことにしましょう。
Use Caseは業務ワークフローを実装するため、Repositoryの操作、Aggregateの操作、Domain Service の呼び出し、トランザクション管理を担います。
外部サービスの呼び出しでトランザクションの整合性を考慮する必要がある場合、Use Caseで実装すると良いでしょう。
Use CaseにはHTTPハンドラーのような外部とのインターフェースやアダプターは実装しません。Use Caseはワークフローに集中します。
さいごに
ドメイン駆動設計を実装に落とし込む際の中核的な概念を説明してきました。Factory や Specification、AdaptorやFacadeのようなパターンもありますが、周辺的な概念なのでこの記事では省略しました。
実装上の注意点として、ディレクトリ構成を valueobject / entity / repository / service / usecase のように切って、それぞれのディレクトリに各レイヤーを実装するクラスをまとめて配置することは避けましょう。凝集性が失われてしまい、保守や拡張で困ることになります。
関連記事
-
この設計自体どうかと言われるかも知れませんが… ↩︎
-
DateRangeが半開区間か閉区間かは設計判断に依存します。 ↩︎ -
どのようなメールアドレスを受け入れるかという問題は本論から外れるためここでは説明しません。また、現実には過去にバリデーションが存在しなかったというような都合で、データベースからロードするときはバリデーションをスキップし、ユーザー入力はバリデーションするというような振る舞いをサポートする必要がある可能性があります。 ↩︎
-
equalsやhashCodeのようなObjectのメソッドは非常に基礎的な知識です。ドメイン駆動設計の前にJavaの基礎を知っている必要があります。 ↩︎ -
もちろん、状態変化自体を表現する方法は物理削除以外にもあります。 ↩︎
-
Domain Eventsによる結果整合性でAggregateを小さく保つアプローチもありますが、強整合性が要求される分野で結果整合性を導入すると複雑さを増して扱いきれなくなる可能性が高まります。 ↩︎
-
ただし、多くの企業では、分離したデータベース間のデータの整合性を突合バッチで検出して修正しているようです。この方法ではリアルタイム性を実現することが困難です。 ↩︎
Discussion