mcpField
D
domain-driven-design

domain-driven-design

github.com/rrezartprebreza開発345110 回インストール1 日前に更新
リポジトリ

domain-driven-design について

Spring Data を使う Java 開発者向けのドメイン駆動設計スキルです。集約のモデリング、イベント公開、外部システムとの境界を扱います。

主な機能

  1. 01集約ルートと整合性境界のルール
  2. 02不変な値オブジェクトのモデリング
  3. 03ドメインイベント公開と永続化の指針
  4. 04複雑なクエリ向け仕様パターン
  5. 05腐敗防止層による統合の指針
  6. 06検証項目とよくある落とし穴

活用シーン

  1. 01集約と境界をまたぐ参照の設計
  2. 02ドメインイベントと永続的な外部処理の実装
  3. 03サードパーティ API のモデルをドメインから隔離

インストール

コマンドをコピーしてターミナルで実行します。

$npx skills add https://github.com/rrezartprebreza/spring-boot-skills --skill domain-driven-design

インストール後、Claude Code で/domain-driven-designを入力して読み込まれたか確認します。

同梱ファイル

SKILL.md と一緒にインストールされる参考資料やスクリプト。

agents/
openai.yaml
examples/
bad-aggregate.java
good-aggregate.java
templates/
DomainEvent.java
Money.java

詳細

ドメイン駆動設計

集約のルール

  • 集約ルートごとにリポジトリを1つ設けます
  • 外部コードは集約ルートを通じてのみ集約にアクセスします。子エンティティに直接アクセスしてはいけません
  • 集約境界をまたぐ場合は、IDによる参照を優先し、意図的なマッピングを維持します。
  • 集約境界は、原子的にコミットする必要がある不変条件と予想される競合に基づいて決めます。子エンティティの数を固定して決めてはいけません。分割する場合は、明示的な整合性計画が必要です。

以下のコード例は説明用です。アプリケーションのドメイン型とインポートが必要です。 タスクに移行が含まれない限り、既存の公開識別子と永続化マッピングは変更しないでください。

// ✅ Aggregate root controls all access to children
order.addItem(productId, quantity); // through root
order.removeItem(itemId);           // through root

// ❌ Direct child access from outside
order.getItems().add(new OrderItem(...)); // bypasses invariants

値オブジェクト

不変で、識別子を持たず、値によって等価性を判定します。

public record Money(BigDecimal amount, Currency currency) {
    public Money {
        if (amount.compareTo(BigDecimal.ZERO) < 0)
            throw new IllegalArgumentException("Amount cannot be negative");
        Objects.requireNonNull(currency);
    }

    public Money add(Money other) {
        if (!currency.equals(other.currency))
            throw new CurrencyMismatchException(currency, other.currency);
        return new Money(amount.add(other.amount), currency);
    }

    public static Money of(String amount, String currency) {
        return new Money(new BigDecimal(amount), Currency.getInstance(currency));
    }
}

public record EmailAddress(String value) {
    public EmailAddress {
        if (!value.matches("^[\\w.-]+@[\\w.-]+\\.[a-z]{2,}$"))
            throw new InvalidEmailException(value);
    }
}

ドメインイベント

// Event — immutable record
public record OrderPlaced(OrderId orderId, CustomerId customerId, Money total, Instant occurredAt) {
    public static OrderPlaced of(Order order) {
        return new OrderPlaced(order.getId(), order.getCustomerId(), order.getTotal(), Instant.now());
    }
}

// Collect events in aggregate, publish after save
@Entity
public class Order {
    @Transient
    private final List<Object> domainEvents = new ArrayList<>();

    public void place() {
        this.status = OrderStatus.PLACED;
        domainEvents.add(OrderPlaced.of(this));
    }

    public List<Object> pullDomainEvents() {
        var events = List.copyOf(domainEvents);
        domainEvents.clear();
        return events;
    }
}

// Publish inside the transaction; save() is not commit.
@Service
@RequiredArgsConstructor
public class OrderApplicationService {
    private final OrderRepository orderRepository;
    private final ApplicationEventPublisher eventPublisher;

    @Transactional
    public Order placeOrder(PlaceOrderCommand command) {
        Order order = orderRepository.findById(command.orderId()).orElseThrow();
        order.place();
        Order saved = orderRepository.save(order);
        saved.pullDomainEvents().forEach(eventPublisher::publishEvent); // commit-bound listener below
        return saved;
    }
}

// Listen to events — bind to commit, not just publish.
// @EventListener fires synchronously inside the TX; if the TX later rolls back you've
// already sent the email. AFTER_COMMIT avoids rollback delivery but is best effort.
@Component
@RequiredArgsConstructor
public class OrderPlacedHandler {
    private final EmailService emailService;

    @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
    public void onOrderPlaced(OrderPlaced event) {
        emailService.sendOrderConfirmation(event.customerId(), event.orderId());
    }
}

Spring Data にイベントの発行を任せましょう。 pullDomainEvents() を手動で呼び出す代わりに、集約ルートに、収集したイベントを返す @DomainEvents メソッドと、イベントをクリアする @AfterDomainEventPublication メソッドを公開します。Spring Data のリポジトリは、すべての save() のたびにイベントを自動的に取り出して発行するため、サービスで手動設定する必要はありません。

どちらの発行方法も、永続的な配信を保証するものではありません。外部への副作用が必須の場合は、集約の変更とともにアウトボックスエントリを永続化するか、永続的な発行レジストリを使用し、冪等性のあるコンシューマーとリトライ/復旧の仕組みを備えてください。@Async が変えるのは実行方法であり、永続性ではありません。トランザクションの完了後にデータベースへ書き込む場合は、元のリソースが完了後もバインドされたままの可能性があるため、新しいトランザクションが必要です。トランザクション境界、メッセージング、Modulithを参照してください。

仕様(複雑なクエリ)

public class OrderSpecifications {
    public static Specification<Order> byStatus(OrderStatus status) {
        return (root, query, cb) -> cb.equal(root.get("status"), status);
    }

    public static Specification<Order> byCustomer(UUID customerId) {
        return (root, query, cb) -> cb.equal(root.get("customerId"), customerId);
    }

    public static Specification<Order> placedAfter(Instant date) {
        return (root, query, cb) -> cb.greaterThan(root.get("placedAt"), date);
    }
}

// Compose
Specification<Order> spec = OrderSpecifications.byStatus(PLACED)
    .and(OrderSpecifications.byCustomer(customerId))
    .and(OrderSpecifications.placedAfter(lastWeek));

orderRepository.findAll(spec, pageable);

腐敗防止層(ACL)

  • 外部システムやレガシーコードと連携する場合、それらのモデルをドメインに持ち込んではいけません
  • 外部データをドメインの言語に変換する翻訳層として、ACL を作成します
  • ACL はドメイン層ではなく、インフラストラクチャ層に置きます
// ✅ GOOD — ACL translates external payment API to domain concepts
@Component
@RequiredArgsConstructor
public class PaymentGatewayAdapter implements PaymentPort {

    private final ExternalPaymentClient client;  // third-party SDK

    @Override
    public PaymentConfirmation charge(OrderId orderId, Money amount) {
        // Translate domain → external
        PaymentApiRequest apiRequest = new PaymentApiRequest(
            orderId.value().toString(),
            amount.amount(), // illustrative provider accepts BigDecimal
            amount.currency().getCurrencyCode());

        // Call external system
        PaymentApiResponse apiResponse = client.charge(apiRequest);

        // Translate external → domain
        return new PaymentConfirmation(
            PaymentId.of(apiResponse.getTransactionId()),
            apiResponse.isSuccessful() ? PaymentStatus.CONFIRMED : PaymentStatus.DECLINED);
    }
}

プロバイダーが使用する正確な金額表現を維持してください。整数の補助通貨単位を使用する場合は、通貨の小数桁数と正確な算術演算を使って変換してください。暗黙的に丸めたり、金額を2進浮動小数点数に変換したりしてはいけません。

検証

集約の不変条件、ロールバックによって外部通知が防止されること、永続的な発行においてコミット後のクラッシュから復旧できることをテストします。既存の API ID とデータベースのリレーションシップが、要求された変更との互換性を維持していることを確認します。

注意点

  • エージェントがゲッター/セッターだけを持つ貧血モデルを作成する場合があります。振る舞いはドメインオブジェクトに持たせてください
  • エージェントが既存の Long/UUID 型 API ID を値オブジェクトに置き換える場合があります。公開契約を維持し、内部型は明示的にマッピングしてください。
  • エージェントがドメインロジックをサービスに置く場合があります。サービスは処理を調整するものであり、判断を下すものではありません
  • エージェントが外部から子エンティティに直接アクセスする場合があります。必ず集約ルートを経由してください
  • エージェントが保存とコミットを混同する場合があります。トランザクション内で発行し、リスナーや永続的な配信のセマンティクスを意図的に選択してください。
  • エージェントが任意の子エンティティ数を基準に集約を分割する場合があります。ビジネス上の不変条件に基づいて境界を決めてください。
  • エージェントが外部 API のモデルをドメインに持ち込む場合があります。腐敗防止層を使用して変換してください

公式情報源

コメント