👨‍⚕️

ドメイン駆動設計 - 2. 戦術的設計編

22 min read

はじめに

この記事では、ドメイン駆動設計の実装方法についてまとめています。

1. アーキテクチャ

ドメイン駆動設計は特定の技術に依存していないため、自由にアーキテクチャを選択することができます。

  • レイヤーアーキテクチャ
  • ヘキサゴナルアーキテクチャ
  • クリーンアーキテクチャ
  • イベント駆動アーキテクチャ

今回は、もっともシンプルなレイヤーアーキテクチャを用いて実装方法を説明していきます。

説明
プレゼンテーション層 リクエストを受け付けたり、レスポンスを返したりと外部をやりとりする層
アプリケーション層 プレゼンテーション層とドメイン層の仲介役
ドメイン層 ビジネス上の解決すべき問題を表現し、オブジェクトやロジックを含む。ドメイン層は、他の層に依存しない。
インフラ層 環境に依存するような実装を行う層。データベースとの接続や外部システムとの連携はこの層で実装します。

パッケージ構成

.
├── application  # アプリケーション層
│   ├── command
│   ├── dto
│   └── service
│
├── domain
│   └── model  # ドメイン層
│
├── infrastructure  # インフラ層
│   ├── repository
│   └── service
│
└── presentation  # プレゼンテーション層
    ├── controller
    ├── request
    └── response

2. エンティティ / 値オブジェクト

ドメイン駆動設計では、2種類のオブジェクトがあります。

名前 説明 利用シーン
エンティティ 一意な識別を持つオブジェクト そのシステムでオブジェクトの変更を管理する必要があるとき
値オブジェクト ただの値を表現するオブジェクト そのシステムでオブジェクトの変更を管理する必要がないとき

エンティティの例

  • 社員(マイナンバーや社員番号で識別): 氏名や住所、所属、給与といった属性を適切に変更する必要がある
  • 記事(記事のIDで識別): タイトルや本文を変更して管理する必要がある
  • 商品(JANコードで識別): 価格や在庫数などを変更したり、商品を削除したりする必要がある

値オブジェクトの例

  • 名前: 姓名 + 氏名で構成される
  • 住所: 郵便番号 + 都道府県 + 市町村 + 町 + 番地 + 号 (+ 建物名 + 部屋番号)で構成される
  • 価格: 数値 + 通貨(円/ドル/...)で構成される
  • 実装する時は、できるだけ「値オブジェクト」を使ってプログラムすることが推奨されています。究極ですが、エンティティはなくとも大丈夫です。どうしても必要なときに実装するくらいで構いません。
  • あるシステムではエンティティとして設計されても、別のシステムでは値オブジェクトで設計したりします
    • ex) あるECサイトで、商品を管理する「商品管理システム」と商品を検索する「商品検索システム」があったとします
      • 「商品管理システム」 → 商品を価格や商品名を変更したりする必要があるのでエンティティとして設計
      • 「商品検索システム」 → クエリに応じて商品を検索するだけなので、商品を変更したりすることがないので値オブジェクトとして設計

実装方法

エンティティの実装方法

エンティティを実装する時は、以下の項目を満たすように実装します。

  1. 同一性: 一意な識別子でエンティティが同一かどうか判定できるようにする
  2. 不変性: 一意な識別子を変更できないようにする
  3. 自己カプセル化: 値を一意な識別子やプロパティにセットする前に、セッターでバリデーションを行う
エンティティの実装方法(python)
class エンティティ:
    def __init__(self, id, a):
        assert id is not None, "idにNoneが指定されています"
	# 2. 不変性: 一意な識別子を変更できないようにする
        self.__一意な識別子 = id  # ※pythonではプライベートなプロパティを持てないので"__"で隠蔽しています
        self.プロパティ = a

    def __eq__(self, other):
        if not isinstance(other, エンティティ):
            return False
	# 1. 同一性: 一意な識別子でエンティティが同一かどうか判定できるようにする
        return other.一意な識別子 == self.一意な識別子

    @property
    def 一意な識別子(self):
        return self.__一意な識別子

    @property
    def プロパティ(self):
        return self.__プロパティ

    @プロパティ.setter
    def プロパティ(self, a):
        # 3. 自己カプセル化: 値を一意な識別子やプロパティにセットする前に、セッターでバリデーションを行う
	assert a is not None, "引数aにNoneが指定されました。〇〇を指定してください。"
        self.__プロパティ = a

値オブジェクトの実装方法

  1. 不変性 : 値オブジェクトの生成後、インスタンス変数などの値を変更できないようにする
  2. 等価性 : 各プロパティの値で値オブジェクトが同じかどうか判定できるようにする
値オブジェクトの実装方法
class 値オブジェクト:
    def __init__(self, a: str, b: int):
        assert a is not None, "引数aにNoneが指定されています。"
        assert b is not None, "引数bにNoneが指定されています。"
        self.__プロパティA = a
        self.__プロパティB = b

    def __eq__(self, other):
        if not isinstance(other, 値オブジェクト):
            return False
        return (other.プロパティA == self.プロパティA) and (other.プロパティB == self.プロパティB)

    def __hash__(self):
        return hash(self.プロパティA + self.プロパティB)

    @property
    def プロパティA(self):
        return self.__プロパティA

    @property
    def プロパティB(self):
        return self.__プロパティB

3. 集約 / コンポジション

実際の業務では、単体のエンティティや値オブジェクトで設計・開発することはほとんどなく、集約/コンポジションで設計・開発します。

集約 / コンポジションとは

  • 集約とは : エンティティと値オブジェクトの塊
  • コンポジション : 値オブジェクトの塊

  • 集約は、それを構成する一部に変更を加えることができる。
  • コンポジションは、塊として生まれるし、消される。コンポジションを構成する一部に変更を加えることができない。

集約とコンポジションは、ドメイン層に集約名 / コンポジション名でパッケージを作成します。

└── domain
     ├── __init__.py
     └── model
         ├── __init__.py
         ├── コンポジション名  # パッケージ
         ├── 集約名        # パッケージ
         └── ...

実装

└── domain
     ├── __init__.py
     └── model
         ├── __init__.py
         └── article  # 記事集約
             ├── __init__.py
             ├── article.py    # 記事
             ├── article_id.py # 記事ID
             ├── content.py    # 本文
             └── title.py      # タイトル
集約の例) 記事
class 記事ID:
    def __init__(self, value: str):
        assert isinstance(value, str), "引数valueには、文字列を指定してくだい。"
        assert value is not None and value != "", "引数valueは必須です。文字列を指定してください。"
        self.__value = value

    @property
    def value(self) -> str:
        return self.__value


class タイトル:
    def __init__(self, text: str):
        assert isinstance(text, str), "引数textには、文字列を指定してください。"
        assert text is not None, "引数textは必須です。文字列を指定してください。"
        assert len(text) <= 20, "引数textには20文字以内の文字列を指定してください。"
        self.__text = text

    @property
    def text(self) -> str:
        return self.__text


class 本文:
    def __init__(self, text: str):
        assert isinstance(text, str), "引数textには、文字列を指定してください。"
        assert text is not None, "引数textは必須です。文字列を指定してください。"
        self.__text = text

    @property
    def text(self) -> str:
        return self.__text


class 記事:
    def __init__(self, 
                 id: 記事ID, 
                 title: タイトル, 
                 content: 本文):
        assert isinstance(id, 記事ID) and id is not None, "引数idには、記事ID型を指定してください。"
        self.__id = id
        self.title = title
        self.content = content

    def __eq__(self, other):
        if not isinstance(other, 記事):
            return False
        return other.id == self.id

    @property
    def id(self):
        return self.__id

    @property
    def title(self):
        return self.__title

    @title.setter
    def title(self, new_title: タイトル):
        assert new_title is not None, "引数new_titleにNoneが指定されました。タイトル型を指定してください。"
        self.__title = new_title

    @property
    def content(self):
        return self.__content

    @content.setter
    def content(self, new_content: 本文):
        assert new_content is not None, "引数new_contentにNoneが指定されました。本文型を指定してください。"
        self.__content = new_content

└── domain
     ├── __init__.py
     └── model
         ├── __init__.py
         └── name  # 名前コンポジション
             ├── __init__.py
             ├── first_name.py  # 氏名
             └── last_name.py   # 姓名
コンポジションの例) 名前
class 姓名:
    def __init__(self, name: str):
        assert isinstance(name, str), "引数nameには、文字列を指定してください。"
        assert name is not None and name != "", "引数nameは必須です。文字列を指定してください。"
        self.__name = name

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


class 氏名:
    def __init__(self, name: str):
        assert isinstance(name, str), "引数nameには、文字列を指定してください。"
        assert name is not None and name != "", "引数nameは必須です。文字列を指定してください。"
        self.__name = name

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


class 名前:
    def __init__(self, first_name: 氏名, last_name: 姓名):
        assert isinstance(first_name, 氏名), "first_nameには氏名型を指定してください。"
        assert isinstance(last_name, 姓名), "last_nameには姓名型を指定してください。"
        assert first_name is not None, "first_nameは必須です。"
        assert last_name is not None, "last_nameは必須です。"
        self.__first_name = first_name
        self.__last_name = last_name

    @property
    def first_name(self):
        return self.__first_name

    @property
    def last_name(self):
        return self.__last_name

4. ドメインサービス

ドメインサービスとは

ドメインサービスとは、あるロジックを実現したいがエンティティ/値オブジェクト/集約/コンポジションに実装するのが不適切である場合に用いるものです。

  • 大抵のロジックは、エンティティ/値オブジェクト/集約/コンポジションのプロパティやメソッドとして実装できるので、ドメインサービスはなくても問題ありません
  • ドメインサービスを多用すると、次のような問題が発生します
    1. プロパティだけのエンティティ/値オブジェクトが発生(ドメイン貧血症)
    2. ドメインサービスにロジックが集中し、バグの温床になる/テストコードが肥大化する
  • たとえば、「複数のエンティティ/値オブジェクト/集約/コンポジションをもとに計算するロジック」など、どうしてもエンティティや値オブジェクトなどで実装できない時や不適切である時にだけドメインサービスを導入しましょう

実装方法

ドメインサービスは、次のようにドメイン層に実装します。

  • 関連する集約パッケージ、コンポジションのパッケージ以下に実装
  • 適当なパッケージ以下に実装
└── domain
     ├── __init__.py
     └── model
         ├── __init__.py
         ├── パッケージ名
         │   └── ドメインサービス名.py 
         ├── 集約名
         │   └── ドメインサービス名.py
         └── コンポジション名
             └── ドメインサービス名.py

クラスで実装

ドメインサービスをクラスとして実装します。

クラスで実装
class ItemRecommender:
    """ユーザーに対して商品をレコメンドするドメインサービス"""

    def recommend(self, user: User, items: Items) -> RankedItems:
        # ユーザー×商品のマッチスコアと商品IDを格納する変数
        scores = {}

        # ユーザー×商品のマッチスコアを計算します
        for item in items:
            # 売れ切れている商品は除外
            if item.is_sold_out():
                continue
            # マッチスコアを計算
            match_score = user.calculate_match_score(item)

            # 計算結果を格納
            scores[item.id] = match_score

	# RankedItemsクラスのsort_byメソッド(staticメソッド)を使って、インスタンス化
        return RankedItems.sort_by(scores)

セパレートインターフェースで実装

セパレートインターフェースとは、インターフェースとそれを継承した実装クラスの2つに分けて管理する設計パターンです。Pythonではインターフェースがないため、抽象クラスとそれを継承した実装クラスの2つを定義することで実現させます。

セパレートインターフェースを利用した方が良い場合の例

  • 将来、実装クラスを差し替える可能性がある
  • 複数のロジックがある

セパレートインターフェースの方法でドメインサービスを実装することで、実装が抽象に依存する(依存性逆転の原則)プログラムになるので途中でロジックを切り替えても依存元に影響がないため、改修の必要がありません。

セパレートインターフェースで実装

抽象クラスを定義

import abc


class ItemRecommender(abc.ABC):
    """ユーザーに対して商品をレコメンドするドメインサービスを抽象クラスで定義"""

    @abc.abstractmethod
    def recommend(self, user: User, items: Items) -> RankedItems:
        pass

抽象クラスを継承した実装クラスを定義

from ..item_recommender import ItemRecommender


class CalculateMatchPointRecommender(ItemRecommender):
    """
    ユーザーに対してマッチスコアが高い順に商品をレコメンドするドメインサービス
    """

    def recommend(self, user: User, items: Items) -> RankedItems:
        # ユーザー×商品のマッチスコアと商品IDを格納する変数
        scores = {}

        # ユーザー×商品のマッチスコアを計算します
        for item in items:
            # 売れ切れている商品は除外
            if item.is_sold_out():
                continue
            # マッチスコアを計算
            match_score = user.calculate_match_score(item)

            # 計算結果を格納
            scores[item.id] = match_score

        # RankedItemsクラスのsort_byメソッド(staticメソッド)を使って、インスタンス化
        return RankedItems.sort_by(scores)


class EstimatePurchaseProbabilityRecommender(ItemRecommender):
    """
    ユーザーに対して購入確率が高い順に商品をレコメンドするドメインサービス
    """

    def __init__(self, estimator: Estimator):
        self.__estimator = estimator

    def recommend(self, user: User, items: Items) -> RankedItems:
        # ユーザー×商品の購入確率と商品IDを格納する変数
        scores = {}

        # ユーザー×商品の購入確率を計算します
        for item in items:
            # 売れ切れている商品は除外
            if item.is_sold_out():
                continue
            # 購入確率を計算
            prob = self.__estimator.predict(user, item)

            # 計算結果を格納
            scores[item.id] = prob

        # RankedItemsクラスのsort_byメソッド(staticメソッド)を使って、インスタンス化
        return RankedItems.sort_by(scores)

5. リポジトリ

リポジトリとは

リポジトリとは、集約のCRUDを担当するオブジェクトです。

  • 実際にデータベースとやりとりします
  • 集約とリポジトリは1対1の関係になる: 例えば、「企業」という集約を格納・取得するときは、「企業リポジトリ」を使います

実装方法

リポジトリの実装は、セパレートインターフェースに沿って実装されます。

...
├── domain
│   ├── __init__.py
│   └── model
│       ├── __init__.py
│       └── company   # 企業集約
│           ├── __init__.py
│           ├── company.py             # 企業(エンティティ)
│           ├── company_id.py          # 企業ID(値オブジェクト)
│           ├── name.py                # 企業名(値オブジェクト)
│           └── company_repository.py  # 企業リポジトリ(インターフェース)
├── infrastructure
│   ├── __init__.py
│   └── repository
│       ├── __init__.py
│       └── company
│           ├── __init__.py
│           └── mysql_company_repository.py  # 企業リポジトリ(実装クラス)
...
ドメイン層
import abc

from typing import NoReturn


class CompanyRepository(abc.ABC):

    def company_with(self, company_id: CompanyId) -> Company:
        """企業ID指定で企業(集約)を取得する"""
        pass
    
    def save(self, company: Company) -> NoReturn:
        """企業(集約)を保存する"""
        pass

    def delete(self, company_id: CompanyId) -> NoReturn:
        """企業ID指定で企業(集約)を削除する"""
        pass
インフラ層
from domain.model import CompanyRepository


class MySQLCompanyRepository(CompanyRepository):

    def __init__(self, driver):
        self.__driver = driver

    def company_with(self, company_id: CompanyId) -> Company:
        """企業ID指定で企業(集約)を取得する"""
        return self.__driver.select_company_by_(company_id)
    
    def save(self, company: Company) -> NoReturn:
        """企業(集約)を保存する"""
        if self.__driver.has_record_of(company.id):
            self.__driver.update(company)
        else:
            self.__driver.insert(company)

    def delete(self, company_id: CompanyId) -> NoReturn:
        """企業ID指定で企業(集約)を削除する"""
        self.__driver.delete(company)

6. ファクトリ

ファクトリとは

ファクトリとは、集約やコンポジションをシンプルに生成するオブジェクトです。

オブジェクトの生成パターン

集約やコンポジションのコンストラクタで生成する

  • 単純な生成を行う場合によい
  • しかし、複雑な生成には適さない
  • 引数が長くなりがち
集約やコンポジションのコンストラクタで生成する
class User:
    def __init__(self, 
                 user_id: UserId,
                 first_name: FirstName, 
                 last_name: LastName, 
                 profile_image_path: ProfileImagePath,
                 age: Age, 
                 gender: Gender):
        # バリデーションを行う
        # ...
        # ...
        self.__user_id          = user_id
        self.first_name         = first_name
        self.last_name          = last_name
        self.profile_image_path = profile_image_path
        self.age                = age
        self.gender             = gender

    # methods
    # ...

a_user = User(user_id, FirstName("taiyo"), LastName("tamura"),
              ProfileImagePath("https://hogeho.com/path/to/image"),
              Age(26), Gender.MEN)

ファクトリメソッドで生成する

  • 複雑なロジックで生成できる
  • 引数が短くなったりする
ファクトリメソッドで生成する
class User:
    def __init__(self, 
                 user_id: UserId,
                 first_name: FirstName, 
                 last_name: LastName, 
                 profile_image_path: ProfileImagePath,
                 age: Age, 
                 gender: Gender):
        # バリデーションを行う
        # ...
        # ...
        self.__user_id          = user_id
        self.first_name         = first_name
        self.last_name          = last_name
        self.profile_image_path = profile_image_path
        self.age                = age
        self.gender             = gender

    # methods
    # ...

    @staticmethod
    def new_men(user_id: UserId, first_name: str, last_name: str, age: int):
        return User(
            user_id, 
            FirstName(first_name), 
            LastName(last_name), 
            ProfileImagePath("https://hogehoge.com/default/image.jpg"), 
            Age(age), 
            Gender.MEN
        )


a_user = User.new_men(user_id, "taiyo", "tamura", 26)

「ファクトリとしてのドメインサービス」で生成する

他のシステムにリクエストしてデータを取得する場合、ドメインサービスをファクトリクラスとして利用します。

OrderServiceクラス
# domain.model.order.order_serviceモジュール
import abc


class OrderService(abc.ABC):
    """注文を行うドメインサービス"""

    @abc.abstractmethod
    def open(self, order: Order) -> OrderStatement:
        """注文オブジェクト指定で注文し、注文明細を返すメソッド"""
        pass
OrderServiceImplクラス
# infrastructure.service.order.order_service_implモジュール
from domain.model.order.order_service import OrderService
from infrastructure.service.order.adaptor import OrderAdaptor


class OrderServiceImpl(OrderService):

    def __init__(self, order_adaptor: OrderAdaptor):
        self.__order_adaptor = order_adaptor

    def open(self, order: Order) -> OrderStatement
        """注文オブジェクト指定で注文し、注文明細を返すメソッド"""
        try:
            return self.__order_adaptor.open(order)
        except Exception as e:
            raise Exception("OrderServiceImplクラスのopenメソッドで例外発生しました。{}".format(e))
OrderAdaptorクラス
# infrastructure.service.order.adaptor.order_adaptorモジュール
import abc


class OrderAdaptor(abc.ABC):

    @abc.abstractmethod
    def open(self, order: Order) -> OrderStatement:
        pass
OrderAdaptorImplクラス
import requests

from domain.model.order.order_id import OrderId
from domain.model.order.order_amount import OrderAmount
from domain.model.order.order_statement import OrderStatement


class OrderAdaptorImpl(OrderAdaptor):
    ORDER_API_URL = "https://other.system.api.com/order"

    def open(self, order: Order) -> OrderStatement:
        response = requests.post(
            self.ORDER_API_URL, 
            data={'user_id': order.user.id, 'item_ids': order.item_ids}
        )

        order_id = response.content['order_id']
        order_amount = response.content['total_amount']

        return OrderStatement(
            OrderId(order_id), 
            bool(is_opened), 
            OrderAmount(order_amount)
        )

7. アプリケーションサービス

アプリケーションサービスとは、プレゼンテーション層とドメイン層の仲介を行うオブジェクトです。

  • アプリケーションサービスは、ユースケースのイベントフローごとにメソッドを提供します
  • アプリケーションサービスはあくまで調整役のため、薄い処理を行うだけのレイヤーとなります
  • データベースのトランザクションといったコントロールを行う場合もあります
.
├── application
│   ├── __init__.py
│   ├── command  # クライアントからの受け取った「エンティティの作成/変更」「値オブジェクトの作成」に必要なデータを詰め込んだクラス
│   │   ├── __init__.py
│   │   └── create_article_command.py
│   ├── dto  # プレゼンテーション層がレスポンスを返すのに必要なデータを詰め込んだクラス
│   │   ├── __init__.py
│   │   ├── article_dto.py
│   │   └── articles_dto.py
│   └── service  # アプリケーションサービスを定義する
│       ├── __init__.py
│       └── article_application_service.py  # 記事のCRUDなどを管理するアプリケーションサービス
...

実装方法

記事のアプリケーションサービス
class ArticleApplicationService:
    """記事の操作等を行うアプリケーションサービス"""
    def __init__(self, article_repository: ArticleRepository):
        self.__article_repository = article_repository

    def get_articles(self, page: int, n: int) -> ArticlesDto:
        articles = self.__article_repository.articles_with(page, n)
        return ArticlesDto([ArticlesDto.Article(an_article.id, an_article.title) for an_article in articles])

    def get_article(self, an_article_id: int) -> ArticleDto:
        article_id = ArticleId.of(an_article_id)
        article = self.__article_repository.article_with(article_id)
        return ArticleDto(article.id, article.title, article.content)

    @transactional
    def create(self, create_article_command: CreateArticleCommand):
        article = Article.new(
            create_article_command.id, 
            create_article_command.title,
            create_article_command.content
        )
        self.__article_repository.save(article)

    @transactional
    def delete(self, an_article_id: int):
        article_id = ArticleId.of(an_article_id)
        self.__article_repository.delete(article_id)

参考文献

書籍

Webサイト

Discussion

ログインするとコメントできます