0
1

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】なぜドメインイベント使うのか?(副作用の分離)

Last updated at Posted at 2025-03-25

はじめに

DDDでは、集約を作成・変更した後に、Repositoryを使って保存するのが一般的です。
しかし、これだけでは対応しづらい場面もあります。たとえば、DBへの保存だけでなく、通知や外部システムとの連携といった「副作用」が発生するケースです。

そんな時に役立つのが、ドメインイベントというアプローチです。

本記事では、リポジトリパターンでは実装が複雑になりがちなケースを取り上げ、かんたんなドメインイベントを使った実装例をご紹介します。題材として、飲食店の順番待ちアプリケーションを取り上げます。

ドメインイベントの使いどころ

  • 集約の変更 + それ以外の副作用が発生するとき
  • 外部への操作が分岐するとき

例えば、DBに集約を保存するだけでなく、ユーザーや別システムに通知をしたい時などに、ドメインイベントを利用するときれいに書くことができます。

ドメインイベントを使った実装方針

業務ルール と 副作用 を分離することを意識します。

  • ドメイン層では、業務ルールによって、「何が起きたか?」を過去形で定義する
  • ユースケース層では、起きたイベントによって、副作用(外部への操作)を実行する

image.png

サンプル

飲食店での順番待ちアプリを考えます。飲食店の入り口にあり、ボタンを押すとレシートが出てくるアレです。

22585969.png

登場人物は、ユーザーと飲食店、番号札の3つです。ただし、番号札を使う場合のユーザーは不特定多数の人なので、今回の例ではユーザーエンティティは登場しません。

エンティティ(ドメイン層)

from pydantic import BaseModel
import datetime as dt

class Restaurant(BaseModel):
    """飲食店"""
    id: int
    name: str

class WaitingTicket(BaseModel):
    """番号札"""
    restaurant: Restaurant
    number: int # 番号札の番号
    phone_number: str # 連絡先電話番号

ユースケースと業務ルール

  • 予約登録
    • ユーザーは店の発券機で、順番待ち番号札を発行します
  • 予約管理
    • システムは現在呼び出し中の番号を知っています
    • システムは1分おきに、登録中の番号札を確認します
      • システムは、順番が来た番号札のユーザーにSMSを送信します ※1
      • システムは、番号札が呼び出し中の番号より古くなったら、順番待ち番号札のレコードを削除します。またユーザーにキャンセルのSMSを送信します
        スクリーンショット 2025-03-23 134157.png

予約管理での副作用一覧

予約管理は業務ルールが複雑なため、下表のようにイベントに対する副作用一覧を作りました。ここでの「副作用」とは、DBに対する操作および通知システムに対する操作の2つを指します。

イベント DBに対する操作 通知システムに対する操作
順番が来た 何もしない 来店依頼を通知
順番が5人過ぎた 番号札をDELETE キャンセルを通知
それ以外 何もしない 何もしない

実装する

それでは、「番号札の発券」と「待ち状況の確認」を実装します。比較のために、番号札の発券はリポジトリパターンで、待ち状況の確認はドメインイベントパターンで実装します。

番号札の発券(リポジトリパターン)

class TicketUsecase:

    def create_reservation(self):
        """予約作成"""

        repository = WaitingTicketRepository()
        
        # 番号札を発券: 50番
        ticket = repository.build(number=50)

        # DBに保存
        repository.save(ticket)

上記の例では、50番の番号札を発券します。番号札発券後はDBに保存するだけなので、リポジトリパターンでもうまく書くことができます。

待ち状況の確認(ドメインイベントパターン)


class TicketUsecase:

    def check_reservation(self):
        """予約確認"""

        # 副作用一覧
        repository = WaitingTicketRepository() # DB操作
        notifier = SNS() # 通知システム
        
        # 番号札を取得: 50番
        ticket = repository.get(number=50)

        # 番号札の状態を確認。呼び出し中の番号は55番
        domain_event = ticket.check_status(current_number=55)

        # イベントを適用
        EventHandler(repository, notifier).apply(domain_event, ticket)

待ち状況の確認では、UseCaseでリポジトリにいきなり保存せず、EventHandlerなるクラスを呼び出していますね。また、ticket(番号札)の check_status メソッドは戻り値に domain_eventという値を返しています。なぜこのように書くのでしょうか?

それは業務ルールと副作用を分離したいからです。

  • 業務ルールの判断(Domain層)
  • 外部への副作用の実行(Usecase層)

このような方針のもと、Domain層とUsecase層の実装を見てみましょう。

番号札の実装(Domain層)

from enum import Enum
from pydantic import BaseModel
import datetime as dt

class ReservationEvent(Enum):
    """番号札のイベント"""
    ACTIVATED = "ACTIVATED" # 順番が来た
    EXPIRED = "EXPIRED" # 順番が過ぎて、無効になった

class WaitingTicket(BaseModel):
    """番号札"""
    restaurant: Restaurant
    number: int # 番号札の番号
    phone_number: str # 連絡先電話番号

    def check_status(self, current_number: int) -> ReservationEvent | None:
        """順番待ち状態を確認し、ドメインイベントを返す"""

        # 順番待ち中
        if current_number < self.number:
            return None # イベントは発生しない

        # 呼び出し中になった (※ 番号が5番過ぎるまで有効)
        if current_number <= self.number + 5:
            return ReservationEvent.ACTIVATED

        # 自分の番号が過ぎた     
        return ReservationEvent.EXPIRED

ここでは「番号札のイベント」を定義します。番号札は、呼び出し中の番号 current_numberと自分の番号を比較し、イベントを発生させます。

イベントハンドラの実装(UseCase層)

class EventHandler:
    def __init__(self, repository, notifier):
        """外部システムへのアダプタを登録"""
        self.repository = repository # DB
        self.notification = notifier # 通知システム

    def apply(self, domain_event: ReservationEvent | None, ticket: WaitingTicket):
        """ドメインイベントに応じて、外部システムを操作する"""
        match domain_event:

            case ReservationEvent.ACTIVATED:
                self.notification.send("予約時間になりました。ご来店ください")

            case ReservationEvent.EXPIRED:
                self.repository.delete(ticket)
                self.notification.send("順番が過ぎました。改めて番号札をお取りください")

            case _:
                # 何もしない
                pass

このようにEventHandlerでは、イベントに対する副作用を実装します。下記の副作用一覧をきれいに実装に落とし込むことができました。

副作用一覧(再掲)

イベント DBに対する操作 通知システムに対する操作
順番が来た 何もしない 来店依頼を通知
順番が5人過ぎた 番号札をDELETE キャンセルを通知
それ以外 何もしない 何もしない

ユースケースからの呼び出し(再掲)

        # (前略)

        # 番号札の状態を確認。呼び出し中の番号は55番
        domain_event = ticket.check_status(current_number=55)

        # イベントを適用
        EventHandler(repository, notifier).apply(domain_event, ticket)

ユースケースでは、エンティティの呼び出しとイベントの適用という進行内容だけを記述しており、可読性も高くなるかと思われます。

image.png

まとめ

ドメインイベントを使用した実装を行うと、下記のように責務を分離することができます。

  • 業務ルールの判断(Domain層)
  • 外部への副作用の実行(Usecase層)

特に、イベントの種類によって副作用(外部への操作)が変化する場合は、ドメインイベントパターンがうまくはまることが多いです。責務を分離し、保守性の高いコードを書くことを心がけましょう。

リポジトリパターン ドメインイベントパターン
集約の変更のみで良い 〇 シンプルに書ける △ 記述がやや冗長になる
集約の変更以外の操作がある △ 記述が複雑になりがち 〇 シンプルに書ける

※ 集約が大きい場合に、集約を構成する一部のテーブルのみを変更したい場合も、ドメインイベントパターンで実装するときれいに書けます

補足(DDD中級者以降向け)

本記事のドメインイベントパターンは簡易なものです。
非同期イベントの実装など、より高度な実装をされたい方は下記の参考文献を参照ください

参考文献

0
1
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
0
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?