関連のモデリング
ドメインモデリングにおいて、エンティティ間の関連(リレーションシップ)をどのように表現するかは、システムの保守性や拡張性に大きく影響する重要な設計判断となる。
なぜ関連のモデリングが重要か
関連の設計が不適切だと、以下のような問題が発生する:
不変条件の検証が困難になり、データ整合性が保てない
パフォーマンスの問題(N+1問題、過剰なメモリ使用)
ドメインロジックの複雑化と保守性の低下
集約境界の破壊によるトランザクション管理の困難
データベースレベルでは、多対多の関連は交差テーブルを用いて表現されるが、ドメインモデルではより多様な表現方法が存在する。それぞれの設計選択は、パフォーマンス、メモリ使用量、コードの複雑性、ドメインロジックの表現力などに異なる影響を与える。
スキーマの例
まず、典型的な多対多リレーションのデータベーススキーマを見てみよう:
code:sql
-- 学生
CREATE TABLE students (
id SERIAL NOT NULL,
name VARCHAR(100) NOT NULL,
PRIMARY KEY(id)
);
-- 講座
CREATE TABLE courses (
id SERIAL NOT NULL,
name VARCHAR(100) NOT NULL,
PRIMARY KEY(id)
);
-- 履修
CREATE TABLE enrollments (
student_id BIGINT NOT NULL,
course_id BIGINT NOT NULL,
PRIMARY KEY(student_id, course_id)
)
code:mermaid
classDiagram
direction LR
class students {
<<table>>
+id : SERIAL PK
+name : VARCHAR(100)
}
class courses {
<<table>>
+id : SERIAL PK
+name : VARCHAR(100)
}
class enrollments {
<<table>>
+student_id : BIGINT PK,FK
+course_id : BIGINT PK,FK
}
students "1" <-- "*" enrollments : FK
enrollments "*" --> "1" courses : FK
この例を題材に関連のモデリングにおける論点は以下の通りである。
1. 関連のパリティ
2. 関連付けるものはIDか実体か?
3. いつ関連を取得するか?
4. どうやって関連の変更を永続化するか?
4は本来ドメインモデルの外にある論点だが、方式によってはドメインモデルの設計に影響を与えるため、ここで取り扱う。
順を追ってみていこう。
論点1: 関連のパリティ
例では「学生」と「講座」は多対多の関連があるが、このとき学生の属性に講座のコレクションを持つのか? 講座の属性に学生のコレクションを持たせるのか? 双方向にコレクションを持ち合うのか? はたまた「学生」「講座」ともに属性として関連を表現するべきではないのか? といった検討ポイントがある。この選択は、ドメインロジックの表現力とシステムの複雑性のバランスに直接影響する。
参考文献:
Modeling Relationships in a DDD way - DDDでの関連モデリングの実践的アプローチ
Change Bidirectional Association to Unidirectional - Martin Fowlerによる双方向関連のリファクタリング手法
① 双方向
code:java
record Student(
Identity id,
String name,
List<Course> courses) {
}
record Course(
Identity id,
String name,
List<Student> students) {
}
code:bidirectional.mermaid
classDiagram
direction LR
class Student {
+Identity id
+String name
+List~Course~ courses
}
class Course {
+Identity id
+String name
+List~Student~ students
}
Student "0..*" <--> "0..*" Course : enrolls
Pros:
どちらのエンティティからでも関連先にアクセスできるため、ナビゲーションが直感的
ドメインロジックを自然に表現できる(例:student.getCourses()、course.getStudents())
メモリ上にロードされていれば、追加のデータアクセスなしに関連を辿れる
Cons:
両側の整合性を維持する必要があり、更新ロジックが複雑になる
循環参照によりJSON/XMLシリアライゼーション時に無限ループのリスク
循環参照によりEagerにロードするのが難しい
メモリ使用量が増加する(両側で参照を保持するため)
Ruby on RailsのActiveRecordやJavaのJPAなどの遅延ロードの仕組みが備わっている場合や、GraphQLのようにどの階層までを取得するか? をクエリ時に明示的に指定する場合は、双方向のモデルで作られることがある。
② 単方向
単方向の関連では、一方のエンティティのみが他方への参照を持つ。この例では、StudentがCourseのリストを持つが、CourseはStudentへの参照は持たない。
code:java
record Student(
Identity id,
String name,
List<Course> courses) {
}
record Course(
Identity id,
String name) {
}
// ある学生が履修している講座の一覧を取得する
student.courses()
code:unidirectional.mermaid
classDiagram
direction LR
class Student {
+Identity id
+String name
+List~Course~ courses
}
class Course {
+Identity id
+String name
}
Student "0..*" --> "0..*" Course : enrolls
Pros:
実装がシンプルで、整合性の維持が容易
循環参照が発生しないため、シリアライゼーションが簡単
メモリ使用量が少ない(一方向のみの参照)
依存関係が明確で、モジュール間の結合度が低い
Cons:
逆方向のナビゲーションには追加のクエリが必要(例:特定のCourseを履修している学生一覧)
関連が必ずしも必要ない場合でも、関連がロードされる (論点3で議論)
どちらの方向を残すか? すなわちどちらが集約ルートになるかが明確でないことがある
③ 関連の型を作る
関連そのものを独立したエンティティ(この例ではEnrollment)として表現するパターンである。StudentとCourseは互いに直接参照を持たず、Enrollmentを通じて関連付けられる。
code:java
record Student(String name) {
}
record Course(String name) {
}
record Enrollment(Student student, Course course) {
}
code:intersectional.mermaid
classDiagram
direction LR
class Student {
+String name
}
class Course {
+String name
}
class Enrollment {
+Student student
+Course course
}
Student <-- Enrollment
Enrollment --> Course
Pros:
関連に追加の属性を持たせやすい(例:履修日時、成績、出席状況など)
関連が必要ないユースケースを自然に表現できる
Cons:
モデルの数が増える
不変条件が守れない?
Modeling Relationships in a DDD wayでは、交差エンティティにキー以外の属性があるならば、このパターンを使い、そうでなければ集約ルートを決めて単方向にしようという基準を示している。だが、集約ルートを決めきれない(ユースケースによってどちらも集約ルートになり得る)場合にどうするかの悩みが残る。
論点2: 実体かIDか?
関連の参照をIDのみで持つこともある。ここでは実体を参照するのか、IDを参照するのかについて検討する。
参考文献:
Link to an aggregate: reference or Id? - 集約間の参照におけるID参照の利点と実装方法
Rule: Reference Other Aggregates by Identity - Vaughn Vernonによる集約間のID参照の原則
Vaughn Vernon - Effective Aggregate Design Part II - 効果的な集約設計におけるID参照の詳細
① 実体参照
関連先のエンティティを直接参照として保持するパターンである。
code:mermaid
classDiagram
direction LR
class Student {
+Identity id
+String name
+List~Course~ courses
}
class Course {
+Identity id
+String name
+List~Student~ students
}
Student "0..*" <--> "0..*" Course : enrolls
Pros:
ドメインロジックが自然に表現できる(例:student.courses.forEach(...))
関連先の情報に即座にアクセスできる
オブジェクト指向的な設計として直感的
不変条件の検証が容易(関連エンティティの状態を直接確認可能)
Cons:
集約境界を越えた参照により、トランザクション整合性の管理が複雑
メモリ使用量が大きい(関連エンティティ全体をロードする必要がある)
② ID参照
関連先のエンティティをIDのみで保持するパターンである。
code:mermaid
classDiagram
direction LR
class Student {
+Identity id
+String name
+List~Identity~ courseIds
}
class Course {
+Identity id
+String name
+List~Identity~ studentIds
}
code:java
record Student(
Identity id,
String name,
List<Identity> courseIds) {
}
record Course(
Identity id,
String name,
List<Identity> studentIds) {
}
void enroll(Student student, Identity courseId) {
}
// ある学生が受講している講座一覧を取得する
// 必要に応じて実体を取得する
List<Course> listCourse(Long studentId) {
return select().from("courses")
.join(table("enrollments"))
.on(table("enrollments").field("course_id").eq(table("courses").field("id")))
.where(table("enrollments").field("studentId").eq(studentId))
.fetch()
.map(courseMapper::toDomain)
}
Pros:
集約境界が明確になり、トランザクション整合性が保ちやすい
メモリ効率が良い(IDのみを保持)
Cons:
関連エンティティの情報が必要な場合、追加のクエリが必要
ドメインの不変条件を表現できないことがある
ID参照か実体参照かどちらを使うかは、割と共通した基準が流布している。
マイクロサービス、DDDのBounded Context、あるいは集約が異なるところでの関連にはID参照を使い、そうでないところでは実体参照を使う。
論点3: いつ関連を取得するか?
関連を属性として持つとき、いつそれを取得するかのタイミングも設計の論点になる。
参考文献:
Domain model purity and lazy loading - ドメインモデルの純粋性とローディング戦略の関係性
Loading Related Data - EF Core - 各手法のパフォーマンス特性と使い分け
Patterns of Enterprise Application Architecture - Lazy Load - Martin Fowlerによるレイジーロードパターンの詳細
① 全部取得する(Eager Loading)
ドメインモデル生成時に関連エンティティを全て実体化する。
code:java
record Course(
Identity id,
String name,
List<Student> students) {
}
code:mermaid
classDiagram
direction LR
class Course {
+Identity id
+String name
+List~Student~ students
}
class Student {
+Identity id
+String name
}
Course "1" --> "0..*" Student : has (loaded)
note for Course "studentsは常に実体化されている"
Pros:
不変条件を確実に検証できる(全データがメモリ上にあるため)
関連データへのアクセスが高速(追加のDBアクセス不要)
N+1問題を回避できる
ドメインロジックがシンプルに記述できる
Cons:
大量データの場合、メモリ使用量が問題になる
初期ロード時のパフォーマンスが劣化する
使用しない関連データも読み込むため無駄が多い
循環参照がある場合、無限ループのリスク
② 必要な時に取得する(Nullable)
関連データを必要に応じて取得し、未取得の場合はnullとする。
code:java
record Course(
Identity id,
String name,
List<Student> students) { // null or loaded
}
code:mermaid
classDiagram
direction LR
class Course {
+Identity id
+String name
+List~Student~? students
}
class Student {
+Identity id
+String name
}
Course "1" --> "0..*" Student : has (nullable)
note for Course "studentsはnullの可能性がある"
Pros:
必要な時だけメモリを使用する
初期ロードが高速
柔軟な使い分けが可能
Cons:
不変条件の検証が困難(nullチェックが必要)
nullと空リストの区別がつきにくい(データがないのか、未ロードなのか)
NullPointerExceptionのリスク
ドメインロジックが複雑になる(null処理が必要)
テスト時にnullケースを考慮する必要がある
③ 必要な時に取得する(Lazy Load)
初回アクセス時に関連データをロードする。
Martin FowlerのPatterns of Enterprise Application Architecture - Lazy Loadで詳しく説明されている。
code:java
record Course(
Identity id,
String name,
List<Student> students) {
public List<Student> students() {
if (this.students instanceof VirtualList vl) {
vl.load();
}
return this.students;
}
)
code:mermaid
classDiagram
direction LR
class Course {
+Identity id
+String name
+VirtualList~Student~ students
+students() List~Student~
}
class Student {
+Identity id
+String name
}
Course "1" --> "0..*" Student : has (lazy)
note for Course "studentsは初回アクセス時にロード"
Pros:
透過的なアクセス(利用側はロードタイミングを意識しない)
必要な時だけロードするため効率的
nullチェックが不要
不変条件の検証が可能(アクセス時には必ずデータが存在)
Cons:
実装が複雑(プロキシやORMの機能が必要)
予期しないタイミングでDBアクセスが発生する
トランザクション外でのアクセスでエラーになる可能性
デバッグが困難(いつロードされるか分かりにくい)
ドメインモデルが永続化層に依存する
論点4: 関連の更新
双方向にしろ、単方向にしろ関連をプロパティとして持つ場合、ドメインモデルとしてはそれらコレクションに対して追加・削除を行い、これをデータベースに反映させることになる。この反映方法にはいくつかのパターンが存在し、それぞれに特徴がある。
参考文献:
The Unit Of Work Pattern And Persistence Ignorance - Unit of Workパターンの実装詳細と永続化の無知
Domain modeling with Entity Framework scorecard - Entity FrameworkとNHibernateでのドメインモデリングの実践的比較
① 即時反映方式(追加・削除と同時にSQLを発行)
Ruby on RailsのActive Recordに代表される方式で、関連を追加・削除した瞬間に対応するSQLを実行する。
Pros:
実装がシンプルで直感的
デバッグが容易(操作とSQLが1対1で対応)
Cons:
ドメインモデルが永続化ロジックと密結合する
細かい操作ごとにDBアクセスが発生し、パフォーマンスが劣化する可能性
② Unit of Workパターン
JPAやHibernateで採用されている方式で、Entityクラスに対する変更を記録しておき、flush/commitのタイミングでまとめてSQLを実行する。
Pros:
複数の変更をまとめて実行でき、パフォーマンス向上も期待できる
トランザクション管理が容易
変更の取り消しが可能
Cons:
ドメインモデルがORMフレームワークと密結合する
実装が複雑(変更追跡機構が必要)
メモリ使用量が増加する
予期しないタイミングでのflushに注意が必要
LazyLoadingなどの魔法的な振る舞いがドメインロジックを複雑にする
③ 差分検出方式(保存時に差分を抽出)
ドメインモデルを永続化する際に、データベースの現在の関連をクエリして比較し、差分を抽出して必要な分だけINSERT/DELETEする。
Pros:
ドメインモデルが永続化を意識する必要がない
楽観的ロックとの相性が良い
無駄な更新を避けられる
Cons:
保存時に追加のクエリが必要(パフォーマンスへの影響)
差分検出ロジックが複雑になりやすい
④ DEL/INS方式
既存の関連を一旦全てDELETEしてから、現在のドメインモデルの状態を全てINSERTする。
Pros:
実装が最もシンプル
データの整合性が保証される
複雑な差分検出が不要
Cons:
大量のDELETE/INSERTによるパフォーマンスの問題
履歴管理やトリガーとの相性が悪い
外部キー制約がある場合、削除順序に注意が必要
並行処理時にロックの問題が発生しやすい
パターンの組み合わせ
ここまで見てきた4つの論点についての各ソリューションを自由に組み合わせられるものではない。双方向モデルを使うには、Eager Loadを使えない。関連プロパティがNullableだと不変条件が保てずドメインモデルの意味が薄れる。また関連プロパティがLazy Loadだと永続化層との結合が強くなる。
したがって、現実的な組み合わせは数パターンに限られる。
1. 双方向 - 実体参照 - Lazy Load - 即時反映 or Unit of Work
永続化層と密結合することを許容すれば、自然な表現になる。が、Lazy Loadだけでは性能問題を起こすことが多く、一部Eagerにするなどで結局難しいコードになりがち。
組合せ例: RailsのActiveRecord や JavaのJPAなど
2. 双方向 - 実体参照 - Nullable - *
GraphQLのように柔軟なクエリをアプリケーションレイヤで組み立てる場合に見られる構成である。
クエリによって関連がある/なしが変わるので、ビジネス不変条件を表現できない。
ドメインモデルではなく、リードモデルとして構成するなら全然アリ。
3. 単方向 or 関連の型 - * - Eager Load - 差分検出 or DEL/INS
ドメインモデルがビジネス不変条件を完全に表現し、永続化層に依存しない純粋さを保ちたければ、この組合せ。
性能を確保しやすいが、型の設計は難しい
以降、3の組合せで、効果的に関連をモデリングする方法を示す。
イミュータブルデータモデルから考えるパターンの組合せ
エンティティをイミュータブルデータモデルの考え方に従い、リソースとイベントに分類して関連を考えると、どの設計パターンが適切かが見えてくる。
リソースとリソース
リソースとリソースの関連には、そのライフサイクルが片方に依存するかどうかに応じて、さらに2つに分類できる。二つのリソースエンティティが互いに独立して生成、削除できるものを非依存リレーションシップ、そうではなく片方が存在していない限りもう片方が生成できないものを依存リレーションシップと呼ぶ。
依存リレーションシップ
リソース間の依存リレーションシップは、1対1または1対多の関係になる。依存される側を「親」、依存する側を「子」と表現する。単方向パリティの場合は、親が子の集合を持つ。
これはRDBの外部キーの参照とは逆向きであることに注意しよう。したがって、親子間の関連を変更する場合には、親を集約として全ての子を変更することが強制される。
code:java
record Restaurant(
Identity id,
String name,
List<Table> tables
) {}
record Table(
Identity id,
int tableNumber,
int capacity
){}
void save(Restaurant restaurant) {
updateRestaurant(restaurant);
//
save
}
非依存リレーションシップ
リソース同士の非依存リレーションシップは、イミュータブルデータモデルでは必ず交差エンティティをおく。
code:mermaid
classDiagram
direction LR
class users {
<<table>>
+id : SERIAL PK
+name : VARCHAR(100)
}
class groups {
<<table>>
+id : SERIAL PK
+name : VARCHAR(100)
}
class memberships {
<<table>>
+user_id : BIGINT PK,FK
+group_id : BIGINT PK,FK
}
users "1" <-- "*" memberships : FK
memberships "*" --> "1" groups : FK
userとgroupは独立したライフサイクルを持ち、ある時点でmembershipという関係で関連づけられる、と考えられる。つまりリソース間の非依存リレーションシップはどちらかが集約ルートとは決められない、または今決めることができても、将来的に変わる可能性が高いといえる。
また非依存であるため、関連を必要としないユースケースが比較的多くなる。
以上のことから、リソース間の非依存リレーションシップの場合は、どちらかに関連を属性として持たせるのではなく、独立した型として別途作ることをお勧めする。
code:java
record User(Identity id, String name) {}
record Group(Identity id, String name) {}
record Membership(User user, Group group) {}
このパターンのデメリットは関連の不変条件、例えば「1つのgroupには、最大10 users までしか参加できない」のような業務ルールを型でチェックできないところにある。
関連を属性として持っていれば、以下のようにuserを追加するときに不変条件を守れるというわけである。
code:java
record Group(List<User> users) {
void join(User user) {
if (users.size() > 10) {
throw new OverException(10);
}
users.add(user);
}
}
ただし、これはコンパイル時ではなく、型に対する振る舞いを実行した時に不変条件がチェックされることに留意しよう(依存型がない言語を想定)。
であれば、同等の効果は Membership が生成される振る舞いにおいて、不変条件をチェックすることによって達成できる。
code:java
Membership join(Group group, User user) {
int numOfMembers = groupRepository.countMembers(group.id());
if (numOfMembers > 10) {
throw new OverException(10);
}
return Membership.of(user, group);
}
イベントとリソース
「注文」と「顧客」のようなイベントとリソースの関連は、イベント側(ここでは「注文」)にリソースの参照を持たせるように設計すると良い。
code:java
record Order(Identity id,
Customer customer,
Product product,
LocalDateTime orderedAt) {}
こうしておくと特にイベントエンティティを登録するときの、不変条件としてありがちな「マスタの存在チェック」が、リソースのロード時に保つことができる。
code:java
if (!productRepository.exists(req.productId())) {
throw new ProductNotFoundException(req.productId);
}
// ...
のようなチェックではなく、次のコードのようにロードしたリソースのドメインオブジェクトをそのまま使う (Parse don't validate)。
code:java
Optional<Product> product = productRepository.findById(req.productId());
product.ifPresent(p -> {
Order order = new Order(
id, customer, product, orderedAt
);
orderRepository.save(order);
});
イベントの明細
「注文」と「注文明細」のように、イベントの明細を同時に記録するみたいなことはよくある。詳細はイベントに依存する関係性なので、リソース-リソースの依存リレーションシップと同じようにイベントが明細の集合を持つ。
code:java
record Order(Identity id,
Customer customer,
List<OrderLine> lines
LocalDateTime orderedAt) {}
record OrderLine(
Product product,
int amount
) {}
これは、DDDの集約の説明でよく例示に使われるパターンである。
イベントの横断検索
イベントは多く(10個以上)のリソースとの関連を持つこともある。この時イベントをこのドメインモデルでロードすると使わないデータをたくさんEager Loadしてしまう可能性があるので、リードモデルを別に設計すると良い。
イミュータブルデータモデルでは、イベントに対して更新操作をすることはないので、そもそも更新のためのロードをする必要はない。
イベントとイベント
「注文」と「請求」のようにイベント間に関連がある場合、依存する側が依存される側の参照を持つ。時系列的に後で発生するイベントは先行するイベントに依存することが多いため、結果として後発イベントが先行イベントのIDを持つことになる。
イベント間の関連では、カーディナリティではなく依存の方向性で参照方向を決める:
請求は注文に依存している(注文なしに請求は存在し得ない)
注文は請求に依存していない(請求されていない注文も存在できる)
この依存関係は1対1、1対多、多対1のいずれの場合でも変わらない。
code:mermaid
classDiagram
direction LR
class 注文 {
+注文ID
+注文日時
}
class 請求 {
+請求ID
+注文
+請求日時
}
注文 "1" --> "0..1" 請求 : 請求する
イベント間の関連は、それらが同時に発生することはなく、集約を跨ぐことがほとんどなので、ID参照にすると良い。
code:java
record Order {
Identity id,
LocalDateTime orderedAt
}
record Invoice {
Identity id,
Identity orderId, // 依存する側が参照を持つ
LocalDateTime invoicedAt
}
複数の注文をまとめて請求するような場合
code:java
record Order {
Identity id,
LocalDateTime orderedAt
}
record Invoice {
Identity id,
List<Identity> orderIds, // 請求が複数の注文に依存
LocalDateTime invoicedAt
}
請求は複数の注文に依存しているため、請求側が注文IDのリストを持つ。注文側に請求IDを持たせる必要はない。