😇

お寿司屋さんに学ぶ!ドメイン駆動設計(DDD)のエッセンス

2025/01/18に公開

はじめに

ソフトウェア開発の世界で近年注目を集めているドメイン駆動設計(DDD)。名前は聞いたことがあるけれど、なんだか難しそう…と感じている方も多いのではないでしょうか?

今回は、皆さんもよくご存知の「お寿司屋さん」を題材に、DDDの基本的な考え方とその魅力を、わかりやすく解説していきます。

DDDって何?従来の開発が抱えていた課題

DDDとは、一言で言えば、ビジネスの核心(ドメイン)を中心に据えてソフトウェアを設計する開発手法です。

しかし、なぜそのような手法が必要なのでしょうか? それは、ソフトウェア開発では、以下のような課題にしばしば直面しており、これらの課題を解決するアプローチのひとつとして注目されているのです。

開発者とビジネス側の間の「言葉の壁」:

開発者はシステムのプロですが、必ずしも業務のプロではありません。一方、ビジネス側(顧客やユーザー)は業務のプロですが、システム開発のプロではありません。
この知識のギャップが原因で、お互いの使う「言葉」が通じず、要件の誤解や手戻りが発生していました。

お寿司屋さんで言うと、お客様が「お任せで」と注文した時に、お客様の意図する「お任せ」と、板前が考える「お任せ」にズレがあると、お客様が満足するお寿司を提供できない状況と同じようなものです。

変化への対応の難しさ:

ビジネス環境は常に変化します。そして、ソフトウェアにも変更が求められます。
しかし、従来の開発手法では、ビジネスルールがコードのあちこちに散らばっていたり、データベースの構造と密接に結びついていたりするため、変更の影響範囲が大きく、修正に時間とコストがかかっていました。

お寿司屋さんで言うと、季節ごとに変わる旬のネタを柔軟にメニューに取り入れたり、新しい仕入れ先を開拓したりすることが難しい、硬直した運営体制のようなものです

技術偏重への陥りやすさ:

開発者が最新技術の習得・使用に意識が向きすぎて、ビジネスの本質的な課題解決がおろそかになることがありました。

お寿司屋さんで言うと、最新の自動シャリ玉製造機を導入したものの、肝心のネタの鮮度や、お客様への心配りがおろそかになってしまっては意味がありません。

DDDがもたらす解決策

「言葉の壁」の解消:

DDDでは、開発者とビジネス側が協力して、「ユビキタス言語」 と呼ばれる共通言語を作り上げます。この共通言語を使って、ドメインモデル(業務モデル)を構築することで、お互いの認識のズレをなくし、スムーズなコミュニケーションを実現します。

お寿司屋さんで言うと、「シャリ」「ガリ」「アガリ」など、お寿司屋さんで働く人たち(開発者とビジネス側)が共通の言葉を使うことで、意思疎通が円滑になります。また、お客様にも分かりやすいように、「マグロ(赤身)」「中トロ」「大トロ」のようなメニュー表記を工夫することも、ユビキタス言語の構築と言えるでしょう。

変化への強さ:

DDDでは、ドメインモデルをコードの中心に据え、ビジネスルールをそこに集約します。これにより、ビジネスルールの変更がコードの他の部分に与える影響を最小限に抑え、変化に強いソフトウェアを作ることができます。

お寿司屋さんで言うと、季節ごとの旬のネタ、仕入れ状況、お客様の好みなどの情報(ドメイン知識)を、メニューやレシピ、接客マニュアルなどに集約し、スタッフ間で共有することで、柔軟な対応が可能になります。例えば、「今日はサンマがおすすめ」という情報を、仕入れ担当から板前、ホールスタッフまで共有することで、お客様に旬の味覚を的確に提供できます。

ビジネス課題へのフォーカス:

DDDでは、技術的な詳細よりも、ビジネス上の課題解決 を重視します。

お寿司屋さんで言うと、最新の調理器具を導入する前に、「お客様満足度を向上させるには?」「リピート率を高めるには?」といった、ビジネス課題を明確にし、その解決策を考えることが重要です。例えば、お客様の好みを把握し、それに応じたサービスを提供することや、季節感のあるメニューを開発することで、顧客満足度向上に繋がるでしょう。

お寿司屋さんの業務から学ぶDDDの基本概念

それでは、お寿司屋さんを舞台に、DDDの基本的な概念を見ていきましょう。

ドメイン

お寿司屋さんのビジネスそのものを指します。
新鮮なネタを仕入れ、美味しいお寿司を握り、お客様に提供し、満足していただき、利益を上げる。この一連の流れが「お寿司屋さんドメイン」です。

ドメインモデル

お寿司屋さんの業務(ドメイン)を、概念的に表現したものです。
例えば、「お客様が来店し、注文をして、板前が寿司を握り、提供され、会計をする」という一連の流れを、図で表したり、文章で説明したりしたものがドメインモデルです。

ドメインオブジェクト

ドメインモデルを構成する、重要な「モノ」「コト」「概念」などです。

モノ:

物理的に存在する、あるいは具体的に識別可能なオブジェクトです。
お寿司屋さんでは、顧客テーブル板前メニュー寿司 などが「モノ」です。

コト:

ビジネスプロセス上で発生するイベントやアクティビティです。
お寿司屋さんでは、注文予約調理会計配達 などが「コト」です。

概念:

ビジネスルールやポリシーなどを抽象的に表現したものです。数値や状態なども含まれます。
お寿司屋さんでは、金額割引在庫数注文ステータス などが「概念」です。

ドメインロジック

お寿司屋さんの業務上の「ルール」です。
例えば、「注文の合計金額が1万円以上なら、10%割引する」「常連のお客様には、特別なサービスを提供する」といったものがドメインロジックです。

ドメインイベント

お寿司屋さんで起こる「重要な出来事」です。
例えば、以下のようなものがドメインイベントです。

  • 注文が確定した (OrderPlaced)
  • 支払いが完了した (PaymentCompleted)
  • 特定のネタが在庫切れになった (OutOfStock)

ドメインイベントはイベントソーシングと一緒に語られることが多い

ドメインオブジェクト:エンティティと値オブジェクト

ドメインオブジェクトは、ドメインモデルを構成する重要な要素であり、主に エンティティ値オブジェクトに分類されます。

なぜ、エンティティと値オブジェクトが重要なのか?

ドメインオブジェクトには、他にもサービス、ファクトリ、リポジトリなど、様々な種類があります。しかし、DDDで最初に理解すべき最も重要なドメインオブジェクトは、エンティティ値オブジェクト です。 なぜなら、これらはドメインモデルの基本的な構成要素であり、ビジネスの本質を表現する上で中心的な役割を果たすからです。

エンティティ:

  • 特徴: 顧客注文のように、識別子(ID) を持ち、時間とともに状態が変わるオブジェクトです。
  • お寿司屋さんで言うと?: 顧客(来店回数や好みが変わる)、注文(「調理中/提供済み」と状態が変わる)、板前(経験やスキルが向上する)などがエンティティです。
  • 例: 顧客は顧客IDで識別され、来店するたびにポイントが貯まったり、好みのネタが変わったりするかもしれません。注文は注文IDで識別され、「受付/調理中/お渡し可能/会計済み」と状態が変化します。

値オブジェクト:

  • 特徴: 金額 や 住所 のように、値そのものが重要で、変わることがないオブジェクトです。
  • お寿司屋さんで言うと?: 金額、住所、寿司(ネタとシャリの組み合わせ)、数量 などが値オブジェクトです。
  • 例: 1000円という金額は、どこで使われても 1000円 です。マグロシャリで構成される寿司は、その組み合わせ自体が価値であり、不変です。

エンティティと値オブジェクトで得られるメリット

1. ドメインの重要な概念の識別

ドメインの重要な概念を識別できる: ビジネスにおいて何が核となる要素(エンティティ)で、何がそれを説明する値(値オブジェクト)なのかを明確に区別できるようになります。

2. オブジェクトのライフサイクル

エンティティは識別子によって追跡され、状態の変化を管理する必要があります。一方、値オブジェクトは不変であるため、生成後は変更されず、自由に共有できます。

3. 不変性の恩恵

値オブジェクトを不変にすることで、意図しない変更を防ぎ、コードの安全性を高めることができます。

4. ドメインモデルの表現力

エンティティと値オブジェクトを適切に使い分けることで、ドメインモデルをより正確に、より豊かに表現することができます。

実装に役立つドメインモデルパターン

ここでは、ドメインモデルをコードで表現する際に役立つドメインモデルパターンを、Python のコード例を交えていくつか紹介します。

1. エンティティ

顧客 エンディディ:

class Customer:
    def __init__(self, id: str, name: str, visit_count: int = 0):
        self._id: str = id  # 識別子
        self._name: str = name
        self._visit_count: int = visit_count

    @property
    def id(self) -> str:
        return self._id

    @property
    def name(self) -> str:
        return self._name

    @property
    def visit_count(self) -> int:
        return self._visit_count

    # 情報を更新するメソッド
    def change_name(self, new_name: str) -> None:
        self._name = new_name

    # 来店回数を増やすメソッド
    def increment_visit_count(self) -> None:
        self._visit_count += 1

ポイント

  • idで顧客を識別します。
  • change_name()increment_visit_count()メソッドで状態を変更できます。

2. 値オブジェクト

金額 値オブジェクト:

@dataclass(frozen=True)
class Money:
    amount: int

    def __post_init__(self):
        if self.amount < 0:
            raise ValueError('金額は0以上でなければなりません')

    # 金額を加算するメソッド (新しい Money オブジェクトを返す)
    def add(self, other: 'Money') -> 'Money':
        return Money(self.amount + other.amount)

    # 比較メソッド
    def __eq__(self, other: object) -> bool:
        if not isinstance(other, Money):
            return NotImplemented
        return self.amount == other.amount

    def __ne__(self, other: object) -> bool:
        return not (self == other)

    def __hash__(self) -> int:
        return hash(self.amount)

ポイント

  • __eq__メソッドを実装することで、==演算子で金額を比較できるようになります。
  • add()メソッドは、金額加算後の新しいMoneyオブジェクトを返します(変更元オブジェクトは不変)

3. 集約

注文集約 :

import uuid
from datetime import datetime
from enum import Enum
from typing import Optional


# 寿司(値オブジェクト)
@dataclass(frozen=True)
class Sushi:
    name: str
    price: Money


# 注文明細 (エンティティ)
class OrderItem:
    def __init__(self, id: str, sushi: Sushi, quantity: int):
        self._id: str = id
        self._sushi: Sushi = sushi
        self._quantity: int = quantity

    @property
    def id(self) -> str:
        return self._id

    @property
    def sushi(self) -> Sushi:
        return self._sushi

    @property
    def quantity(self) -> int:
        return self._quantity


# 注文ステータス
class OrderStatus(Enum):
    PENDING = '受付'
    CONFIRMED = '確定'
    PREPARING = '準備中'
    READY = 'お渡し可能'
    COMPLETED = '完了'


# 注文種別
class OrderType(Enum):
    DELIVERY = '配達'
    TAKE_OUT = 'お持ち帰り'
    EAT_IN = '店内飲食'


# 注文 (エンティティであり、集約ルート)
class Order:
    def __init__(
        self,
        id: str,
        customer_id: str,
        order_items: Optional[list[OrderItem]] = None,
        status: OrderStatus = OrderStatus.PENDING,
        order_type: OrderType = OrderType.EAT_IN,
    ):
        self._id: str = id
        self._customer_id: str = customer_id
        self._order_items: list[OrderItem] = (
            order_items if order_items is not None else []
        )
        self._status: OrderStatus = status
        self._order_type: OrderType = order_type

    @property
    def id(self) -> str:
        return self._id

    @property
    def customer_id(self) -> str:
        return self._customer_id

    @property
    def order_items(self) -> list[OrderItem]:
        return self._order_items

    @property
    def status(self) -> OrderStatus:
        return self._status

    @property
    def order_type(self) -> OrderType:
        return self._order_type

    # 注文明細を追加するメソッド
    def add_order_item(self, sushi: Sushi, quantity: int) -> None:
        self._order_items.append(
            OrderItem(id=str(uuid.uuid4()), sushi=sushi, quantity=quantity)
        )

    # 合計金額を計算するメソッド
    def get_total_amount(self) -> Money:
        total = Money(0)
        for item in self._order_items:
            total = total.add(Money(item.sushi.price.amount * item.quantity))
        return total

    # 注文を確定するメソッド
    def confirm(self) -> None:
        if self._status != OrderStatus.PENDING:
            raise Exception('注文は既に確定しています')
        self._status = OrderStatus.CONFIRMED
        # ドメインイベントを発行する
        DomainEvents.publish(
            OrderConfirmed(
                order_id=self._id,
                customer_id=self._customer_id,
                ordered_at=datetime.now(),
            )
        )

ポイント

  • Order(注文)が集約ルートとなり、OrderItem(注文明細)を保持します。
  • add_order_item()メソッドを通じてのみ、OrderItemを追加できます。
  • get_total_amount()メソッドで、注文の合計金額を計算できます。

4. ドメインサービス

割引サービス:

class DiscountService:
    def calculate_discount(self, order: Order, customer: Customer) -> Money:
        discount = Money(0)
        if order.get_total_amount().amount >= 10000:
            discount = discount.add(
                Money(int(order.get_total_amount().amount * 0.1))
            )  # 10%割引
        if customer.visit_count >= 10:
            discount = discount.add(Money(500))  # 常連割引
        return discount

ポイント

  • OrderCustomerの両方の情報を使って、割引額を計算しています。
  • 特定のエンティティに紐づかない、独立したサービスとして定義されています。

5. リポジトリ

注文リポジトリ(インターフェース):

class OrderRepository(ABC):
    @abstractmethod
    def find_by_id(self, order_id: str) -> Optional[Order]:
        pass

    @abstractmethod
    def save(self, order: Order) -> None:
        pass

ポイント

  • ドメインは、このインターフェースに依存します。
  • 具体的なデータベースアクセス処理は、このインターフェースを実装したクラスに記述します。

6. ドメインイベント

OrderPlaced(注文確定)イベント:

from datetime import datetime


# ドメインイベント
@dataclass(frozen=True)
class OrderConfirmed:
    order_id: str
    customer_id: str
    ordered_at: datetime

@dataclass(frozen=True)
class OrderPlaced:
    order_id: str
    customer_id: str
    ordered_at: datetime

# ドメインイベントのモック(適当)
class DomainEvents:
    @staticmethod
    def publish(event: object) -> None:
        print(f'Event: {event.__class__.__name__}')

ポイント

  • イベントをイミュータブルにします。
  • イベント名には過去形を用います。
  • イベントハンドラーを用いて、イベントに応じた処理を実行できます。

7. ファクトリ

注文ファクトリ:

import uuid
from typing import Optional


# ログ出力用モッククラス
class Logger:
    def log(self, message: str):
        print(f'LOG: {message}')

@dataclass
class CreateOrderItem:
    sushi: Sushi
    quantity: int

class OrderFactory:
    def __init__(self, logger: Logger):
        self._logger = logger

    def create(
        self,
        customer_id: str,
        items: list[CreateOrderItem],
        order_type: OrderType = OrderType.EAT_IN,
    ) -> Order:
        order_id = str(uuid.uuid4())
        order_items = [
            OrderItem(
                id=str(uuid.uuid4()),
                sushi=item.sushi,
                quantity=item.quantity,
            )
            for item in items
        ]

        # 依存オブジェクトを使った処理の例(ここではログ出力)
        self._logger.log(
            f'Creating order {order_id} for customer {customer_id} with {len(order_items)} items.'
        )

        return Order(
            id=order_id,
            customer_id=customer_id,
            order_items=order_items,
            status=OrderStatus.PENDING,
            order_type=order_type,
        )

ポイント

  • Order集約の生成を担当します。
  • 関連するOrderItemオブジェクトの生成も同時に行い、生成時の整合性を保証します。
  • 外部サービスへの依存がある場合の、依存性注入(DI)もしています。

8. 仕様(Specification)

割引適用可能仕様:

from abc import ABC, abstractmethod


# 仕様(Specification)のための抽象基底クラス
class Specification(ABC):
    @abstractmethod
    def is_satisfied_by(self, order: Order) -> bool:
        pass


# 最小注文金額の仕様
class MinimumOrderAmountSpecification(Specification):
    def __init__(self, minimum_amount: Money):
        self.minimum_amount: Money = minimum_amount

    def is_satisfied_by(self, order: Order) -> bool:
        return order.get_total_amount().amount >= self.minimum_amount.amount


# 顧客来店回数の仕様
class CustomerVisitCountSpecification(Specification):
    def __init__(self, customer: Customer, required_visit_count: int):
        self.customer: Customer = customer
        self.required_visit_count: int = required_visit_count

    def is_satisfied_by(self, order: Order) -> bool:
        # 実際は、注文情報だけでなく、顧客情報に基づいて判定すべき
        return self.customer.visit_count >= self.required_visit_count


# AND仕様
class AndSpecification(Specification):
    def __init__(self, spec1: Specification, spec2: Specification):
        self.spec1: Specification = spec1
        self.spec2: Specification = spec2

    def is_satisfied_by(self, order: Order) -> bool:
        return self.spec1.is_satisfied_by(order) and self.spec2.is_satisfied_by(
            order
        )


# 割引サービス(仕様パターン利用)
class DiscountServiceWithSpecification:
    def calculate_discount(self, order: Order, customer: Customer) -> Money:
        # 割引適用条件の仕様を定義
        min_amount_spec = MinimumOrderAmountSpecification(Money(10000))
        visit_count_spec = CustomerVisitCountSpecification(customer, 10)

        # 仕様を組み合わせて利用
        eligible_for_discount = AndSpecification(
            min_amount_spec, visit_count_spec
        ).is_satisfied_by(order)

        discount = Money(0)
        if eligible_for_discount:
            discount = discount.add(
                Money(int(order.get_total_amount().amount * 0.1))
            )  # 10%割引

        return discount

ポイント

  • Specificationインターフェースを定義し、is_satisfied_by()メソッドで判定ロジックを記述します。
  • MinimumOrderAmountSpecification(注文金額が指定金額以上)やCustomerVisitCountSpecification(来店回数が指定回数以上)など、具体的な仕様を個別のクラスとして実装します。
  • AndSpecificationのように仕様を組み合わせることも可能です。
  • DiscountServiceWithSpecificationでは、生成した仕様を使って、割引条件を満たしているかを判定します。

トランザクションスクリプトパターン

ドメインモデルパターンは、ビジネスロジックをドメインオブジェクトにカプセル化する、オブジェクト指向的なアプローチです。一方、それと対照的なアプローチとして、トランザクションスクリプト パターンがあります。

どのようなパターン?

ビジネスロジックを、手続き(スクリプト)として記述する、手続き型のアプローチです。
ドメインオブジェクトは、データ構造を保持するだけの、シンプルなオブジェクト(データキャリア、または貧血ドメインモデルとも呼ばれる)になる傾向があります。
各スクリプトは、特定のユースケースや操作(例えば、「注文を確定する」「顧客情報を更新する」など)に対応し、必要なデータアクセスや処理をすべて含みます。

お寿司屋さんで言うと: 「注文受付担当」「調理担当」「会計担当」のように、担当者ごとに処理が分かれていて、それぞれが手順書(スクリプト)に従って作業するイメージです。

メリット

  • シンプルで理解しやすい。
  • 小規模なアプリケーションでは、迅速な開発が可能。

デメリット

  • ビジネスロジックが複数のスクリプトに散在しやすく、重複が発生しやすい。
  • アプリケーションの規模が大きくなると、保守性や拡張性が低下する。

# 注文を確定するスクリプト

def confirm_order(order_id: str, order_repository: OrderRepository, customer_repository: CustomerRepository):
  """注文を確定する

  Args:
      order_id (str): 注文ID
      order_repository (OrderRepository): 注文リポジトリ
      customer_repository (CustomerRepository): 顧客リポジトリ
  """

  # 1. 注文を取得
  order = order_repository.find_by_id(order_id)
  if order is None:
    raise Exception("注文が見つかりません")

  # 2. 顧客情報を取得
  customer = customer_repository.find_by_id(order.customer_id)
  if customer is None:
      raise Exception("顧客が見つかりません")

  # 3. 注文ステータスを「確定」に変更
  order.status = OrderStatus.CONFIRMED

  # 4. 顧客の来店回数を増やす
  customer.increment_visit_count()

  # 5. 注文と顧客情報を保存
  order_repository.save(order)
  customer_repository.save(customer)

  # 6. 注文確定イベントを発行(ここでは簡略化してログ出力)
  print(f"Event: OrderConfirmed, Order ID: {order_id}")

ドメインモデルパターンとの比較

特徴 ドメインモデルパターン トランザクションスクリプト
ビジネスロジック ドメインオブジェクト(例:Order)にカプセル化 手続き(例:confirm_order)としてオブジェクトの外に記述
ドメインオブジェクト データと振る舞いを持つ(リッチドメインモデル) データのみを持つ(貧血ドメインモデル)
結合度 ドメインオブジェクト間の結合は疎 スクリプトとデータオブジェクト間の結合は密

パターンを適用する際の注意点

ドメインモデルパターンは強力なツールですが、やみくもに適用すれば良いというものではありません。以下の点に注意しましょう。

銀の弾丸ではない:

すべての状況に適用できる完璧なパターンは存在しません。
プロジェクトの特性や状況に応じて、適切なパターンを選択、または複数のパターンを組み合わせて使う必要があります。

複雑さのトレードオフ:

パターンを適用することで、コードの複雑さが増す場合があります。
パターンの適用が本当に必要かどうか、慎重に検討しましょう。

チームの理解:

チームメンバー全員が、適用するパターンについて理解している必要があります。
パターンに関する知識を共有し、共通認識を持つことが重要です。

まとめ:お寿司屋さんとDDD

お寿司屋さんを例に、DDDの概念とドメインモデルパターンを解説しました。

  • ドメイン(お寿司屋さん): ビジネスの領域
  • ドメインモデル(お寿司屋さんの業務フロー): ドメインを概念的に表現したもの
  • ドメインオブジェクト(顧客、注文、寿司など): ドメインモデルを構成する要素(「モノ」「コト」「概念」)
  • ドメインロジック(割引ルールなど): ビジネスルール
  • ドメインイベント(注文確定、在庫切れなど): ドメイン内で発生する重要な出来事
  • ドメインモデルパターン(エンティティ、値オブジェクト、集約など): 実装上の指針

DDDのメリット:

  • ビジネス要求への的確な対応: ドメインを中心に据えることで、ビジネス要求を正確に捉え、ソフトウェアに反映することができます。
  • 変化への強さ: ビジネスルールがドメインモデルに集約されるため、仕様変更への対応が容易になります。
  • 保守性の向上: コードがビジネスの構造を表現しているため、理解しやすく、メンテナンスが楽になります。
  • コミュニケーションの円滑化: ユビキタス言語とドメインモデルを通じて、開発者とビジネス関係者間のコミュニケーションがスムーズになります。

最後に:DDDは「銀の弾丸」ではなく、その「目的」を見据えて活用する

ここまで、お寿司屋さんを例にDDDについて色々と講釈を垂れてきましたが、最後にこの記事の真意をお伝えさせてください。

私は、この記事を通じて、DDDの布教をしたいわけではありません。

もちろん、DDDが有用な手法のひとつであるかもしれません。しかし、どんなプロジェクトにも適用できる万能な「銀の弾丸」ではないことも事実です。導入には学習コストがかかりますし、シンプルなアプリケーションには過剰な設計となる可能性もあります。

では、なぜ今回DDDを解説したのか?

それは、私自身がDDDを通じて、「ソフトウェア開発の目的」 を、改めて考えてみたかったからです。

DDDでは、オブジェクト指向、ドメインモデルパターン、アーキテクチャなど、様々な 「やり方(How)」 が語られます。しかし、それらはあくまでも手段です。

最も大切なのは、ソフトウェア開発を通じて、ビジネスの課題を解決し、価値を生み出すこと

つまり、「何を作るか(What)」 そして 「なぜ作るのか(Why)」 を、開発者とビジネス側が一緒になって考え、ソフトウェアを通じてビジネスを成長させていく

そのために、DDDは強力な武器となり得ます。ドメインを中心に据え、ビジネスと開発が一体となることで、より良いソフトウェアを生み出すことができるでしょう。

しかし、肝心なのは 「目的」 です。目的に合ってさえいれば、DDDでなくても良いとさえ思います。 極端な話、昔ながらの手続き型プログラミングでも、ビジネスの目的を達成できるのであれば、何ら問題はありません。

この記事が、皆さんにとって、DDDという手法を知るだけでなく、ソフトウェア開発における「目的」を改めて考えるきっかけとなれば、この上ない喜びです。

株式会社ソニックムーブ

Discussion