はじめに (対象読者・この記事でわかること)
本記事は、Javaでドメイン駆動設計(DDD)を実践したいエンジニア、特に 集約(Aggregate) の境界を適切に定義し、データベースへの永続化を安全かつ拡張しやすく行いたい方を対象としています。
この記事を読むことで、以下ができるようになります。
- ドメインモデルにおける「集約」と「集約ルート」の概念を正しく理解する
- 集約を分解し、エンティティと値オブジェクトに分ける設計手順を身につける
- JPA(Hibernate) を用いた永続化戦略と、リポジトリパターンの実装方法を具体的なコード例で習得できる
背景として、プロジェクトで「集約が肥大化し、トランザクション管理が困難になる」ケースが増えており、設計の見直しと永続化のベストプラクティスを共有しようと考え、本記事を書きました。
前提知識
この記事を読み進める上で、以下の知識があるとスムーズです。
- Java 基礎(クラス、インターフェース、ジェネリクス)
- JPA(エンティティ、リポジトリ、トランザクション)の基本的な使い方
- DDD の概念(エンティティ、値オブジェクト、集約、ドメインサービス)
集約分解の概要と設計上の課題
DDD において 集約 は、ビジネス上の一貫性を保つための境界です。集約ルートが外部から唯一の入口となり、内部のエンティティや値オブジェクトは直接参照できません。
しかし、実務で 「集約が大きくなりすぎて」 以下のような問題が顕在化します。
- トランザクションの粒度が大きくなり、ロック競合が頻発
- 永続化時に N+1 問題が発生し、パフォーマンス低下
- テストが困難になり、変更に対するリグレッションリスクが増大
このような課題を解決するために、集約を論理的に分解し、永続化単位を最適化 する設計が必要です。本節では、分解の指針と設計の全体像を示します。
分解の指針
| 判断基準 | 具体例 |
|---|---|
| ビジネス上の一貫性が独立できるか | 注文(Order)と配送(Shipment)は別のライフサイクルを持つ → 別集約に分割 |
| 変更頻度 | 商品価格は頻繁に変わるが、注文履歴は変更されない → 価格は別エンティティに切り出す |
| サイズ | 集約内に 30 以上のエンティティがある → サブ集約に分割 |
永続化設計のポイント
- エンティティは集約ルートだけが
@Entityとし、サブエンティティは@Embeddable(値オブジェクト)や@OneToMany(別テーブル)で表現する - リポジトリは集約ルート単位で提供し、サブエンティティへの直接アクセスは禁止
- トランザクション境界は集約ルートのメソッドに合わせ、
@Transactionalを付与
以下に、実際のコード例を交えて具体的な設計手順を示します。
集約分解と永続化の実装手順
ステップ1:ドメインモデルの定義と集約ルートの抽出
まずはビジネス要件からエンティティ/値オブジェクト を洗い出し、集約ルートを決定します。例として「受注(Order)システム」を扱います。
Java// Order は集約ルート @Entity public class Order { @Id @GeneratedValue private Long id; private LocalDateTime orderDate; // OrderItem は Order の子エンティティだが、別テーブルで永続化 @OneToMany(mappedBy = "order", cascade = CascadeType.ALL, orphanRemoval = true) private List<OrderItem> items = new ArrayList<>(); // 配送先は値オブジェクト @Embedded private Address shippingAddress; // ビジネスロジック例 public void addItem(Product product, int quantity) { OrderItem item = new OrderItem(this, product, quantity); items.add(item); } }
Java// OrderItem は Order に属するエンティティ @Entity public class OrderItem { @Id @GeneratedValue private Long id; @ManyToOne(fetch = FetchType.LAZY) private Order order; @ManyToOne(fetch = FetchType.LAZY) private Product product; private int quantity; protected OrderItem() {} // JPA 用 public OrderItem(Order order, Product product, int quantity) { this.order = order; this.product = product; this.quantity = quantity; } }
Java// Address は不変の値オブジェクト @Embeddable public class Address { private String street; private String city; private String zipCode; protected Address() {} // JPA 用 public Address(String street, String city, String zipCode) { this.street = street; this.city = city; this.zipCode = zipCode; } }
ポイント
- Order が唯一のエントリポイントであり、外部からは OrderRepository を通じて操作する。
- OrderItem は 子エンティティ として Order に従属し、cascade = ALL によって Order の永続化と同時に自動保存される。
- Address は 値オブジェクト として埋め込み (@Embedded) し、別テーブルは生成しない。
ステップ2:リポジトリインタフェースの定義
集約ルート単位でリポジトリを提供します。Spring Data JPA を利用した例です。
Javapublic interface OrderRepository extends JpaRepository<Order, Long> { // 集約外からの検索は基本的に ID で取得 Optional<Order> findById(Long id); // ビジネス要件に応じたクエリ例 List<Order> findByOrderDateBetween(LocalDateTime from, LocalDateTime to); }
ポイント
- OrderRepository は Order のみを対象にし、OrderItem への直接クエリは提供しない。
- 必要に応じて カスタムリポジトリ を実装し、集約内部の検索ロジックを隠蔽できる。
ステップ3:サービス層でのトランザクション管理
ビジネスロジックはサービス層で実装し、トランザクション境界は集約ルートメソッドに合わせます。
Java@Service @RequiredArgsConstructor public class OrderService { private final OrderRepository orderRepo; private final ProductRepository productRepo; // 参照のみ @Transactional public Order createOrder(Address shippingAddress, List<OrderLineDto> lines) { Order order = new Order(); order.setShippingAddress(shippingAddress); for (OrderLineDto line : lines) { Product product = productRepo.findById(line.getProductId()) .orElseThrow(() -> new IllegalArgumentException("Product not found")); order.addItem(product, line.getQuantity()); } return orderRepo.save(order); // cascade により OrderItem も保存 } @Transactional(readOnly = true) public Order getOrder(Long orderId) { return orderRepo.findById(orderId) .orElseThrow(() -> new EntityNotFoundException("Order not found")); } }
ポイント
- @Transactional が付与されたメソッドは 集約全体の一貫性 を保証。
- order.addItem で子エンティティ OrderItem を生成し、orderRepo.save に任せて自動永続化させる。
ハマった点やエラー解決
1. N+1 問題が頻発した
症状
order.getItems() をループで取得した際に、SELECT が 1 件ずつ実行され、パフォーマンスが激減。
原因
OrderItem の @ManyToOne(fetch = FetchType.LAZY) がデフォルトで遅延ロードされ、ループ内で都度取得されていた。
解決策
OrderRepository にフェッチジョインを追加し、一括取得する。
Java@Query("SELECT o FROM Order o LEFT JOIN FETCH o.items WHERE o.id = :id") Optional<Order> findByIdWithItems(@Param("id") Long id);
2. @Embeddable のフィールドが null になる
症状
Order を保存した直後、shippingAddress が null になる。
原因
Address のコンストラクタが protected で JPA がリフレクションで呼び出せなかった。
解決策
@Embeddable のクラスは 引数なしのコンストラクタを public または protected にする必要がある。上記コードでは protected Address() を追加して解決。
3. CascadeType.ALL が意図せず削除を伝播
症状
orderRepo.delete(order) を実行したら、OrderItem だけでなく、参照している Product まで削除されてしまった。
原因
OrderItem の product フィールドに cascade = ALL を付与していた。
解決策
product は 集約外の参照 なので、カスケードは 設定しない。@ManyToOne(fetch = FetchType.LAZY) のみで十分。
まとめ
本記事では、Java で DDD の 集約を分解し、永続化を安全に設計 する手順を実装例と共に解説しました。
- 集約分解の指針:ビジネス上の一貫性、変更頻度、サイズで分割判断
- 永続化設計:集約ルートだけを
@Entityにし、サブエンティティは@OneToMany、値オブジェクトは@Embeddable - リポジトリとトランザクション:集約単位でリポジトリを提供し、サービス層で
@Transactionalを明示
これらを実践することで、トランザクションの粒度が適切になり、パフォーマンスと保守性が向上 します。今後は、マイクロサービス間での集約境界の分割 や CQRS パターンへの拡張 についても取り上げる予定です。
参考資料
- Spring Data JPA 公式ドキュメント
- Domain-Driven Design: Tackling Complexity in the Heart of Software (Eric Evans)
- JPA/Hibernate 5.6 Reference Guide - Embeddable Types
- Java Persistence with Hibernate (Christian Bauer, Gavin King)
