6
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

[速習] ドメイン駆動設計(DDD) 第2回 値オブジェクトとエンティティ、そして集約の実装

Last updated at Posted at 2025-06-12

image.png

前回の記事では、ドメイン駆動設計(DDD)の基本概念として、レイヤードアーキテクチャと依存性の逆転、そして境界づけられたコンテキストについて解説しました。これらの概念により、ビジネス要件と技術的実装を効果的に結びつけ、保守性の高いソフトウェアを構築する基盤を理解していただけたかと思います。

今回は、DDDの実装において中核となる構成要素である値オブジェクト、エンティティ、そして集約について詳しく解説していきます。これらの概念を適切に理解し実装することで、ドメインモデルをより正確に表現できるようになります。

ドメインモデルの構成要素

ドメイン駆動設計において、ドメインモデルは複数の構成要素から成り立っています。これらの構成要素は、ビジネスロジックを適切に表現し、システムの複雑性を管理するために不可欠です。主要な構成要素として、値オブジェクト1、エンティティ2、集約3があり、それぞれが異なる役割と特性を持っています。

値オブジェクト

値オブジェクトは、ドメインモデルにおいて属性の集合を表現する重要な概念です。値オブジェクトの最大の特徴は、その不変性4と値による等価性5にあります。これらの特性により、ドメインの概念をより安全かつ表現力豊かにモデリングすることが可能となります。

不変性の重要性 値オブジェクトは一度作成されると、その状態を変更することができません。この不変性により、意図しない状態変更を防ぎ、バグの発生を抑制できます。また、マルチスレッド環境6でも安全に使用できるという利点があります。

値による等価性 値オブジェクトは、その内部の値によって等価性が判断されます。つまり、同じ値を持つ二つの値オブジェクトは等しいと見なされます。これは、現実世界の多くの概念と一致します。例えば、1000円という金額は、どの1000円札を使っても同じ価値を持ちます。

自己完結性 値オブジェクトは、それ自体で意味を持つ完結した概念を表現します。例えば、住所、金額、日付などは、それぞれが独立した概念として扱われるべきものです。

// 値オブジェクトの実装例:金額
public final class Money {
    private final BigDecimal amount;
    private final Currency currency;
    private static final int SCALE = 2;
    
    public Money(BigDecimal amount, Currency currency) {
        if (amount == null || currency == null) {
            throw new IllegalArgumentException("金額と通貨は必須です");
        }
        if (amount.compareTo(BigDecimal.ZERO) < 0) {
            throw new IllegalArgumentException("金額は負の値にできません");
        }
        this.amount = amount.setScale(SCALE, RoundingMode.HALF_UP);
        this.currency = currency;
    }
    
    // ゼロ金額の生成
    public static Money zero(Currency currency) {
        return new Money(BigDecimal.ZERO.setScale(SCALE), currency);
    }
    
    // 加算メソッド(新しいインスタンスを返す)
    public Money add(Money other) {
        if (!this.currency.equals(other.currency)) {
            throw new IllegalArgumentException("異なる通貨の加算はできません");
        }
        return new Money(this.amount.add(other.amount), this.currency);
    }
    
    // 乗算メソッド(新しいインスタンスを返す)
    public Money multiply(int multiplier) {
        if (multiplier < 0) {
            throw new IllegalArgumentException("乗数は0以上である必要があります");
        }
        BigDecimal result = this.amount.multiply(BigDecimal.valueOf(multiplier));
        return new Money(result, this.currency);
    }
    
    @Override
    public boolean equals(Object obj) {
        if (this == obj) return true;
        if (obj == null || getClass() != obj.getClass()) return false;
        Money money = (Money) obj;
        // scaleの違いを考慮した比較
        return amount.compareTo(money.amount) == 0 && currency.equals(money.currency);
    }
    
    @Override
    public int hashCode() {
        // stripTrailingZerosで正規化してからハッシュ値を計算
        return Objects.hash(amount.stripTrailingZeros(), currency);
    }
}

値オブジェクトを使用することで、プリミティブ型7の過度な使用を避け、ドメインの概念をより明確に表現できます。また、ビジネスルールを値オブジェクト内にカプセル化8することで、ドメインロジックの散在を防ぐことができます。

エンティティ

エンティティは、ドメインモデルにおいて識別子を持つオブジェクトです。値オブジェクトとは対照的に、エンティティは同一性9によって区別され、ライフサイクル10を通じて状態が変化する可能性があります。

識別子による同一性 エンティティの最も重要な特徴は、一意の識別子を持つことです。この識別子により、属性が変化してもオブジェクトの同一性を保つことができます。例えば、ユーザーエンティティは、名前やメールアドレスが変更されても、ユーザーIDが同じであれば同一のユーザーとして扱われます。

状態の可変性 エンティティは、そのライフサイクルを通じて状態が変化することが前提となっています。ただし、この変化は制御された方法で行われるべきであり、ビジネスルールに従って適切に管理される必要があります。

ビジネスロジックの実装 エンティティは、自身に関連するビジネスロジックを実装する責任を持ちます。これにより、データと振る舞いが一体化され、オブジェクト指向の原則に従った設計が可能となります。なお、複雑なビジネスロジックや複数のエンティティにまたがる処理は、ドメインサービスに委譲することも検討すべきです。

// エンティティの実装例:ユーザー
public class User {
    private final UserId id;
    private UserName name;
    private Email email;
    private UserStatus status;
    private LocalDateTime createdAt;
    private LocalDateTime updatedAt;
    
    public User(UserId id, UserName name, Email email) {
        this.id = Objects.requireNonNull(id, "ユーザーIDは必須です");
        this.name = Objects.requireNonNull(name, "ユーザー名は必須です");
        this.email = Objects.requireNonNull(email, "メールアドレスは必須です");
        this.status = UserStatus.ACTIVE;
        this.createdAt = LocalDateTime.now();
        this.updatedAt = this.createdAt;
    }
    
    // ビジネスロジック:名前の変更
    public void changeName(UserName newName) {
        if (newName == null) {
            throw new IllegalArgumentException("新しい名前は必須です");
        }
        if (this.status != UserStatus.ACTIVE) {
            throw new IllegalStateException("非アクティブなユーザーの名前は変更できません");
        }
        this.name = newName;
        this.updatedAt = LocalDateTime.now();
    }
    
    // ビジネスロジック:アカウントの無効化
    public void deactivate() {
        if (this.status == UserStatus.DEACTIVATED) {
            throw new IllegalStateException("既に無効化されています");
        }
        this.status = UserStatus.DEACTIVATED;
        this.updatedAt = LocalDateTime.now();
    }
    
    @Override
    public boolean equals(Object obj) {
        if (this == obj) return true;
        if (obj == null || getClass() != obj.getClass()) return false;
        User user = (User) obj;
        return id.equals(user.id);  // IDのみで等価性を判断
    }
    
    @Override
    public int hashCode() {
        return Objects.hash(id);
    }
}

エンティティの設計において重要なのは、ビジネスルールを適切にカプセル化し、不正な状態遷移を防ぐことです。これにより、ドメインの整合性が保たれ、バグの発生を抑制できます。

集約

集約は、関連するエンティティと値オブジェクトをグループ化し、データの整合性を保証する境界を定義する概念です。集約は、ドメイン駆動設計において最も重要かつ複雑な概念の一つであり、適切な集約の設計はシステムの品質に大きく影響します。

集約ルート 各集約には、集約ルート11と呼ばれる単一のエンティティが存在します。集約ルートは、集約全体への唯一のエントリーポイントとなり、集約内の他のオブジェクトへのアクセスを制御します。外部からは集約ルートを通じてのみ集約にアクセスできるため、集約の内部状態の整合性が保たれます。

トランザクション境界 集約は、トランザクション12の境界としても機能します。一つのトランザクション内では、原則として一つの集約のみを変更するという方針に従うことで、システムのパフォーマンスと整合性のバランスを取ることができます。複数の集約を跨ぐ更新が必要な場合は、ドメインイベントやSagaパターンなどを活用して結果整合性を実現します。

不変条件の維持 集約の最も重要な責任は、ビジネスルールに基づく不変条件13を維持することです。集約ルートは、集約全体の状態が常に有効であることを保証する必要があります。

// 集約の実装例:注文
public class Order {
    private final OrderId id;
    private final CustomerId customerId;
    private final List<OrderItem> items;
    private OrderStatus status;
    private Money totalAmount;
    private LocalDateTime orderedAt;
    
    public Order(OrderId id, CustomerId customerId) {
        this.id = Objects.requireNonNull(id);
        this.customerId = Objects.requireNonNull(customerId);
        this.items = new ArrayList<>();
        this.status = OrderStatus.DRAFT;
        this.totalAmount = Money.zero();
        this.orderedAt = LocalDateTime.now();
    }
    
    // ビジネスロジック:商品の追加
    public void addItem(ProductId productId, Money unitPrice, int quantity) {
        if (status != OrderStatus.DRAFT) {
            throw new IllegalStateException("確定済みの注文には商品を追加できません");
        }
        if (quantity <= 0) {
            throw new IllegalArgumentException("数量は1以上である必要があります");
        }
        
        // 同じ商品が既に存在する場合は数量を更新
        Optional<OrderItem> existingItem = items.stream()
            .filter(item -> item.getProductId().equals(productId))
            .findFirst();
            
        if (existingItem.isPresent()) {
            existingItem.get().increaseQuantity(quantity);
        } else {
            items.add(new OrderItem(productId, unitPrice, quantity));
        }
        
        recalculateTotalAmount();
    }
    
    // ビジネスロジック:注文の確定
    public void confirm() {
        if (status != OrderStatus.DRAFT) {
            throw new IllegalStateException("下書き状態の注文のみ確定できます");
        }
        if (items.isEmpty()) {
            throw new IllegalStateException("商品が含まれていない注文は確定できません");
        }
        
        this.status = OrderStatus.CONFIRMED;
    }
    
    // 内部メソッド:合計金額の再計算
    private void recalculateTotalAmount() {
        this.totalAmount = items.stream()
            .map(OrderItem::getSubtotal)
            .reduce(Money.zero(), Money::add);
    }
    
    // 集約内のエンティティ
    public static class OrderItem {
        private final ProductId productId;
        private Money unitPrice;
        private int quantity;
        
        private OrderItem(ProductId productId, Money unitPrice, int quantity) {
            this.productId = Objects.requireNonNull(productId);
            this.unitPrice = Objects.requireNonNull(unitPrice);
            this.quantity = quantity;
        }
        
        private void increaseQuantity(int additional) {
            this.quantity += additional;
        }
        
        private Money getSubtotal() {
            return unitPrice.multiply(quantity);
        }
        
        private ProductId getProductId() {
            return productId;
        }
    }
}

集約の設計においては、以下の点に注意する必要があります:

  1. 集約は小さく保つ - 大きすぎる集約はパフォーマンスの問題を引き起こす可能性があります
  2. 集約間の参照はIDで行う - 直接的なオブジェクト参照は避け、IDによる参照を使用します
  3. 結果整合性を受け入れる - すべてを即座に整合させる必要はなく、ビジネス要件に応じて結果整合性14を許容します

おわりに

今回は、ドメイン駆動設計における中核的な構成要素である値オブジェクト、エンティティ、集約について解説しました。これらの概念を適切に理解し実装することで、ビジネスロジックをより正確に表現し、保守性の高いドメインモデルを構築することができます。

値オブジェクトの不変性、エンティティの同一性、集約による境界の保護という各概念の特性を活かすことで、複雑なドメインロジックを管理可能な形で実装できます。次回は、これらの構成要素を永続化するためのリポジトリパターンと、ドメインイベントについて解説していく予定です。

  1. 値オブジェクト - 属性の集合で表現され、不変で値による等価性を持つオブジェクト。金額、住所、日付などが代表例。

  2. エンティティ - 一意の識別子を持ち、ライフサイクルを通じて同一性を保つオブジェクト。ユーザー、注文、商品などが代表例。

  3. 集約 - 関連するエンティティと値オブジェクトをグループ化し、整合性を保証する境界。トランザクションの単位としても機能する。

  4. 不変性 - オブジェクトの状態が作成後に変更できない性質。スレッドセーフ性やバグの防止に貢献する。

  5. 値による等価性 - オブジェクトの同一性ではなく、含まれる値によって等しさを判断する性質。

  6. マルチスレッド環境 - 複数のスレッドが同時に実行される環境。並行処理において、データの整合性を保つことが課題となる。

  7. プリミティブ型 - プログラミング言語が提供する基本的なデータ型。int、string、booleanなど。

  8. カプセル化 - オブジェクト指向プログラミングの原則の一つ。データと振る舞いを一つの単位にまとめ、内部実装を隠蔽すること。

  9. 同一性 - オブジェクトが他のオブジェクトと区別される性質。エンティティでは識別子によって保証される。

  10. ライフサイクル - オブジェクトの生成から消滅までの一連の過程。エンティティは、この期間中に状態が変化する。

  11. 集約ルート - 集約の境界を定義し、外部からのアクセスポイントとなるエンティティ。集約全体の整合性を保証する責任を持つ。

  12. トランザクション - データベースにおける一連の操作をまとめた処理単位。ACID特性(原子性、一貫性、独立性、永続性)を保証する。

  13. 不変条件 - オブジェクトやシステムが常に満たすべき条件。ビジネスルールによって定義され、違反してはならない制約。

  14. 結果整合性 - 即座にではなく、最終的に整合性が保たれる性質。分散システムやマイクロサービスアーキテクチャで重要な概念。

6
2
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
6
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?