6
5

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) 第3回 リポジトリとドメインサービス、そしてドメインイベントの実装

Posted at

image.png

前回の記事では、ドメイン駆動設計(DDD)の中核となる構成要素である値オブジェクト、エンティティ、集約について詳しく解説しました。これらの概念により、ビジネスロジックを適切にモデリングし、ドメインの複雑性を管理する基礎を築くことができました。

今回は、これらのドメインモデルを永続化し、より複雑なビジネスロジックを実装するための重要な概念である、リポジトリパターン、ドメインサービス、そしてドメインイベントについて掘り下げていきます。

DDDにおける技術的関心事の分離

ドメイン駆動設計では、ビジネスロジックと技術的な実装詳細を明確に分離することが重要です。この分離により、ドメインモデルはビジネスの本質に集中でき、技術的な変更がビジネスロジックに影響を与えることを防げます。リポジトリ、ドメインサービス、ドメインイベントは、この分離を実現するための重要なパターンです。

リポジトリパターン

リポジトリパターンは、ドメインモデルの永続化を抽象化する設計パターンです。このパターンにより、ドメイン層は具体的なデータアクセス技術から独立し、純粋なビジネスロジックに集中できます。リポジトリは、集約をメモリ上のコレクション1のように扱えるインターフェースを提供します。

永続化の抽象化 リポジトリは、データベースやファイルシステムなどの永続化メカニズムの詳細を隠蔽します。ドメイン層からは、リポジトリがあたかもメモリ上のコレクションであるかのように見え、技術的な詳細を意識する必要がありません。

集約単位での操作 リポジトリは集約ルートに対してのみ定義され、集約全体を一つの単位として扱います。これにより、集約の境界が保護され、データの整合性が維持されます。

クエリの抽象化 リポジトリは、ドメインに必要な検索条件を表現するメソッドを提供します。これらのメソッドは、ビジネス要件に基づいた名前を持ち、SQLなどの技術的な詳細を露出しません。

// リポジトリインターフェース(ドメイン層)
public interface UserRepository {
    // 基本的なCRUD操作
    Optional<User> findById(UserId id);
    List<User> findByEmail(Email email);
    void save(User user);
    void remove(User user);
    
    // ビジネス要件に基づいた検索
    List<User> findActiveUsersCreatedAfter(LocalDateTime date);
    boolean existsByEmail(Email email);
    
    // ページング対応
    Page<User> findAll(Pageable pageable);
}

// リポジトリ実装(インフラストラクチャ層)
@Repository
public class JpaUserRepository implements UserRepository {
    private final JpaUserDataRepository jpaRepository;
    private final UserMapper mapper;
    
    @Override
    public Optional<User> findById(UserId id) {
        return jpaRepository.findById(id.getValue())
            .map(mapper::toDomainModel);
    }
    
    @Override
    public void save(User user) {
        UserDataModel dataModel = mapper.toDataModel(user);
        jpaRepository.save(dataModel);
    }
    
    @Override
    public List<User> findActiveUsersCreatedAfter(LocalDateTime date) {
        return jpaRepository.findByStatusAndCreatedAtAfter(
            UserStatus.ACTIVE.name(), 
            date
        ).stream()
            .map(mapper::toDomainModel)
            .collect(Collectors.toList());
    }
    
    @Override
    public boolean existsByEmail(Email email) {
        return jpaRepository.existsByEmail(email.getValue());
    }
}

// 仕様パターンの活用例
public interface Specification<T> {
    boolean isSatisfiedBy(T candidate);
    Specification<T> and(Specification<T> other);
    Specification<T> or(Specification<T> other);
    Specification<T> not();
}

// 仕様の実装例
public class ActiveUserSpecification implements Specification<User> {
    @Override
    public boolean isSatisfiedBy(User user) {
        return user.getStatus() == UserStatus.ACTIVE;
    }
}

// リポジトリでの仕様パターンの利用
public interface UserRepository {
    List<User> findBySpecification(Specification<User> spec);
}

リポジトリパターンを適切に実装することで、以下の利点が得られます:

  1. テスタビリティの向上 - インメモリ実装を使用した単体テストが容易
  2. 技術的な変更への耐性 - データベースの変更がドメイン層に影響しない
  3. ドメインロジックの純粋性 - SQLなどの技術的詳細がドメインに混入しない

ドメインサービス

ドメインサービスは、特定のエンティティや値オブジェクトに属さないドメインロジックを実装するためのパターンです。エンティティや値オブジェクトに無理に押し込めると不自然になるビジネスロジックは、ドメインサービスとして実装すべきです。

ドメインサービスが必要な場面

  • 複数の集約を跨ぐビジネスロジック
  • 外部サービスとの連携が必要な処理
  • エンティティに属さない計算や判定ロジック

ステートレスな設計 ドメインサービスは基本的にステートレス2であるべきです。状態を持たないことで、サービスの再利用性が高まり、並行処理においても安全に使用できます。ただし、リポジトリを通じてデータアクセスを行う場合は、呼び出し側でトランザクション管理を適切に行う必要があります。

アプリケーションサービスとの違い ドメインサービスは純粋なビジネスロジックを表現するのに対し、アプリケーションサービス3はユースケースの実行やトランザクション管理などの調整役を担います。

// ドメインサービスの例:価格計算サービス
@DomainService
public class PricingService {
    private final TaxRateRepository taxRateRepository;
    private final DiscountPolicyRepository discountPolicyRepository;
    
    public PricingService(
        TaxRateRepository taxRateRepository,
        DiscountPolicyRepository discountPolicyRepository
    ) {
        this.taxRateRepository = taxRateRepository;
        this.discountPolicyRepository = discountPolicyRepository;
    }
    
    // 複数の要素を考慮した価格計算
    public OrderTotal calculateOrderTotal(Order order, Customer customer) {
        Money subtotal = order.calculateSubtotal();
        
        // 顧客ランクに基づく割引の適用
        DiscountPolicy discountPolicy = discountPolicyRepository
            .findByCustomerRank(customer.getRank());
        Money discountAmount = discountPolicy.calculateDiscount(subtotal);
        
        // 税率の取得と適用
        TaxRate taxRate = taxRateRepository
            .findByLocation(order.getDeliveryAddress().getRegion());
        Money taxAmount = taxRate.calculateTax(
            subtotal.subtract(discountAmount)
        );
        
        return new OrderTotal(subtotal, discountAmount, taxAmount);
    }
}

// ドメインサービスの例:在庫割当サービス
@DomainService
public class InventoryAllocationService {
    
    // 複数の倉庫から最適な在庫割当を行う
    public AllocationResult allocateInventory(
        Order order,
        List<Warehouse> warehouses
    ) {
        AllocationResult result = new AllocationResult();
        
        for (OrderItem item : order.getItems()) {
            boolean allocated = false;
            
            // 配送先に近い倉庫から順に在庫を確認
            List<Warehouse> sortedWarehouses = sortByProximity(
                warehouses,
                order.getDeliveryAddress()
            );
            
            for (Warehouse warehouse : sortedWarehouses) {
                if (warehouse.hasAvailableStock(
                    item.getProductId(),
                    item.getQuantity()
                )) {
                    warehouse.reserve(
                        item.getProductId(),
                        item.getQuantity()
                    );
                    result.addAllocation(item, warehouse);
                    allocated = true;
                    break;
                }
            }
            
            if (!allocated) {
                result.addUnallocatedItem(item);
            }
        }
        
        return result;
    }
    
    private List<Warehouse> sortByProximity(
        List<Warehouse> warehouses,
        Address deliveryAddress
    ) {
        // 配送先への距離でソート
        return warehouses.stream()
            .sorted((w1, w2) -> {
                double distance1 = calculateDistance(
                    w1.getLocation(),
                    deliveryAddress
                );
                double distance2 = calculateDistance(
                    w2.getLocation(),
                    deliveryAddress
                );
                return Double.compare(distance1, distance2);
            })
            .collect(Collectors.toList());
    }
}

ドメインサービスを適切に活用することで、エンティティや値オブジェクトの責務を適切に保ちながら、複雑なビジネスロジックを表現できます。

ドメインイベント

ドメインイベントは、ドメイン内で発生した重要な出来事を表現するパターンです。イベント駆動アーキテクチャ4の中核となる概念であり、システムの疎結合性5と拡張性を大幅に向上させます。

イベントの特性

  • 不変性 - 過去に起きた事実を表すため、一度発生したイベントは変更されない
  • ビジネス言語での命名 - 「注文が確定された」「在庫が割り当てられた」など、ビジネスの言葉で表現
  • タイムスタンプ - いつ発生したかを記録

イベントの発行と購読 ドメインイベントは、集約やドメインサービスから発行され、イベントハンドラ6によって処理されます。この仕組みにより、ドメイン間の依存関係を最小限に抑えることができます。

// ドメインイベントの基底クラス
public abstract class DomainEvent {
    private final UUID eventId;
    private final Instant occurredOn;
    
    protected DomainEvent() {
        this.eventId = UUID.randomUUID();
        this.occurredOn = Instant.now();
    }
    
    public UUID getEventId() {
        return eventId;
    }
    
    public Instant getOccurredOn() {
        return occurredOn;
    }
}

// 具体的なドメインイベント
public class OrderConfirmedEvent extends DomainEvent {
    private final OrderId orderId;
    private final CustomerId customerId;
    private final Money totalAmount;
    private final List<OrderItemData> items;
    
    public OrderConfirmedEvent(
        OrderId orderId,
        CustomerId customerId,
        Money totalAmount,
        List<OrderItemData> items
    ) {
        super();
        this.orderId = orderId;
        this.customerId = customerId;
        this.totalAmount = totalAmount;
        this.items = Collections.unmodifiableList(items);
    }
    
    // getterメソッド省略
}

// イベントを発行する集約
public class Order {
    private final List<DomainEvent> domainEvents = new ArrayList<>();
    
    // 価格情報を適用(割引、税額などを反映)
    public void applyPricing(OrderTotal total) {
        this.totalAmount = total.getFinalAmount();
        this.discountAmount = total.getDiscountAmount();
        this.taxAmount = total.getTaxAmount();
    }
    
    public void confirm() {
        if (status != OrderStatus.DRAFT) {
            throw new IllegalStateException("下書き状態の注文のみ確定できます");
        }
        if (items.isEmpty()) {
            throw new IllegalStateException("商品が含まれていない注文は確定できません");
        }
        
        this.status = OrderStatus.CONFIRMED;
        
        // イベントの発行
        domainEvents.add(new OrderConfirmedEvent(
            this.id,
            this.customerId,
            this.totalAmount,
            this.items.stream()
                .map(item -> new OrderItemData(
                    item.getProductId(),
                    item.getQuantity(),
                    item.getUnitPrice()
                ))
                .collect(Collectors.toList())
        ));
    }
    
    public List<DomainEvent> pullDomainEvents() {
        List<DomainEvent> events = new ArrayList<>(domainEvents);
        domainEvents.clear();
        return events;
    }
}

// イベントハンドラの例
@Component
public class OrderConfirmedEventHandler {
    private final InventoryService inventoryService;
    private final EmailService emailService;
    private final PointService pointService;
    
    @EventHandler
    @Transactional
    public void handle(OrderConfirmedEvent event) {
        // 在庫の引当
        inventoryService.allocateInventory(
            event.getOrderId(),
            event.getItems()
        );
        
        // 確認メールの送信
        emailService.sendOrderConfirmation(
            event.getCustomerId(),
            event.getOrderId()
        );
        
        // ポイントの付与
        pointService.addPoints(
            event.getCustomerId(),
            calculatePoints(event.getTotalAmount())
        );
    }
    
    private int calculatePoints(Money amount) {
        // 100円につき1ポイント
        return amount.getAmount()
            .divide(BigDecimal.valueOf(100), RoundingMode.DOWN)
            .intValue();
    }
}

// イベントディスパッチャー
@Component
public class DomainEventDispatcher {
    private final ApplicationEventPublisher publisher;
    
    @Transactional
    public void dispatch(List<DomainEvent> events) {
        events.forEach(publisher::publishEvent);
    }
}

// Outboxパターンによる確実なイベント発行
@Component
public class OutboxEventDispatcher {
    private final EventOutboxRepository outboxRepository;
    private final MessagePublisher messagePublisher;
    
    @Transactional
    public void dispatch(List<DomainEvent> events) {
        // イベントをOutboxテーブルに保存(トランザクション内)
        events.forEach(event -> {
            EventOutbox outboxEntry = new EventOutbox(
                event.getEventId(),
                event.getClass().getName(),
                serialize(event),
                EventStatus.PENDING
            );
            outboxRepository.save(outboxEntry);
        });
    }
    
    // 非同期ワーカーが定期的に実行
    @Scheduled(fixedDelay = 5000)
    public void publishPendingEvents() {
        List<EventOutbox> pendingEvents = outboxRepository
            .findByStatus(EventStatus.PENDING);
            
        pendingEvents.forEach(outboxEntry -> {
            try {
                // メッセージブローカーへ発行
                messagePublisher.publish(
                    outboxEntry.getEventType(),
                    outboxEntry.getPayload()
                );
                
                // 発行済みとしてマーク
                outboxEntry.markAsPublished();
                outboxRepository.save(outboxEntry);
            } catch (Exception e) {
                // リトライ処理
                outboxEntry.incrementRetryCount();
                outboxRepository.save(outboxEntry);
            }
        });
    }
}

トランザクション境界とイベント発行の原子性
ドメインイベントは永続化の抽象化されたトランザクションのコミット後に確実に発行される必要があります。上記のOutboxパターンを採用することで、データベースコミットとイベント発行の原子性を保証できます。この方式では:

  1. ドメインイベントをOutboxテーブルに保存(トランザクション内)
  2. 非同期ワーカーが定期的にOutboxをポーリング
  3. 未発行のイベントをメッセージブローカーへ発行
  4. 発行済みのイベントをマークして二重発行を防止

これにより、システム障害時でもイベントの損失を防ぎ、結果整合性を確実に実現できます。

ドメインイベントを活用することで得られる利点:

  1. 疎結合な設計 - イベントの発行側と購読側が直接依存しない
  2. 監査証跡の自然な実装 - イベントストア7に保存することで履歴が残る
  3. 非同期処理の実現 - 重い処理を非同期で実行可能
  4. システム間連携 - 他のシステムへの通知が容易

実装パターンの組み合わせ

これまでに紹介したリポジトリ、ドメインサービス、ドメインイベントは、実際のアプリケーションでは組み合わせて使用されます。以下に、これらのパターンを統合した実装例を示します。

// アプリケーションサービスでの統合例
@ApplicationService
@Transactional
public class OrderApplicationService {
    private final OrderRepository orderRepository;
    private final CustomerRepository customerRepository;
    private final WarehouseRepository warehouseRepository;
    private final PricingService pricingService;
    private final InventoryAllocationService inventoryAllocationService;
    private final DomainEventDispatcher eventDispatcher;
    
    public OrderConfirmationResult confirmOrder(
        OrderId orderId,
        CustomerId customerId
    ) {
        // リポジトリから集約を取得
        Order order = orderRepository.findById(orderId)
            .orElseThrow(() -> new OrderNotFoundException(orderId));
        
        Customer customer = customerRepository.findById(customerId)
            .orElseThrow(() -> new CustomerNotFoundException(customerId));
        
        // ドメインサービスを使用した価格計算
        OrderTotal total = pricingService.calculateOrderTotal(
            order,
            customer
        );
        
        // 価格情報を注文に適用(割引、税額などを反映)
        order.applyPricing(total);
        
        // 注文の確定(ドメインイベントが発生)
        order.confirm();
        
        // 在庫割当(ドメインサービス)
        List<Warehouse> warehouses = warehouseRepository.findAll();
        AllocationResult allocation = inventoryAllocationService
            .allocateInventory(order, warehouses);
            
        if (allocation.hasUnallocatedItems()) {
            // トランザクションのロールバックにより、
            // これまでの在庫予約も自動的に取り消される
            throw new InsufficientInventoryException(
                allocation.getUnallocatedItems()
            );
        }
        
        // 集約の永続化
        orderRepository.save(order);
        
        // ドメインイベントの発行
        eventDispatcher.dispatch(order.pullDomainEvents());
        
        return new OrderConfirmationResult(
            order.getId(),
            total,
            allocation
        );
    }
}

おわりに

今回は、ドメイン駆動設計における重要な実装パターンであるリポジトリ、ドメインサービス、ドメインイベントについて解説しました。これらのパターンを適切に活用することで、技術的関心事とビジネスロジックを分離し、保守性と拡張性の高いシステムを構築できます。

第1回から第3回にかけて、DDDの基本概念から実装パターンまでを段階的に解説してきました。これらの知識を組み合わせることで、複雑なビジネス要件を適切にモデリングし、変更に強いソフトウェアを構築することができます。実際のプロジェクトでは、これらのパターンを柔軟に適用し、チームやビジネスの状況に応じて最適な設計を選択することが重要です。

  1. コレクション - オブジェクトの集合を扱うデータ構造。List、Set、Mapなどの総称。

  2. ステートレス - 状態を持たないこと。前回の処理結果に依存せず、同じ入力に対して常に同じ出力を返す性質。

  3. アプリケーションサービス - ユースケースを実装し、ドメイン層とプレゼンテーション層の間を調整する役割を持つサービス。

  4. イベント駆動アーキテクチャ - イベントの発生と処理を中心に設計されたアーキテクチャ。疎結合な非同期処理を実現。

  5. 疎結合性 - コンポーネント間の依存関係が少ない状態。変更の影響範囲を限定し、保守性を向上させる。

  6. イベントハンドラ - 特定のイベントが発生した際に実行される処理を実装したコンポーネント。

  7. イベントストア - 発生したイベントを時系列で保存するストレージ。イベントソーシングの実装で使用される。

6
5
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
5

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?