Open16

ドメイン駆動設計(DDD)についてとビジネスロジックの別け方 - Part2

shimakaze_softshimakaze_soft

オニオンアーキテクチャとクリーンアーキテクチャの違い

オニオンアーキテクチャやその他のアーキテクチャの共通点を集約して汎用化したモデルがクリーンアーキテクチャであり、2013年にC. Martin氏が提唱した。

https://jeffreypalermo.com/2008/07/the-onion-architecture-part-1/

クリーンアーキテクチャオニオンアーキテクチャを含むいくつかのアーキテクチャを自然な形で1つに統合するもの、という風に書かれている。
そのため、コアなコンセプトとしてはかなり似ている。違いがあるとすれば以下の2つになる。

  • オニオンアーキテクチャは、依存が内側に向かっていれば層を飛び越えても良い
  • オニオンアーキテクチャには明示的なアダプタ層(Interface Adapter)が無い

https://noiseless-blog.net/entry/2016/12/31/231632#背景

以下の記事はそれぞれの違いや比較などもして大変わかりやすい記事です。

https://speakerdeck.com/streamwest1629/kurinakiwakarankatutaren-falsetamefalseonionakitekutiya

この記事によればクリーンアーキテクチャには3つの特徴があると説明している。

  • 依存関係がわかりやすい = 依存方向のルール
  • 注目している問題以外はブラックボックスで良い = 関心の分離
  • 外部環境にプロダクトが依存しにくい

以下のような比較表も紹介されている

クリーンアーキテクチャの概念 オニオンアーキテクチャの概念
Entities層 Domain Model層
Entities層またはUse Cases層 Domain Services層
Use Cases層 Application Services層
Interface層またはinfrastructure層 Infrastructure層
infrastructure層 (外部のライブラリ・ドライバなど)
shimakaze_softshimakaze_soft

オニオンアーキテクチャではドメイン層とアプリケーション層の責務はどう違う?

https://little-hands.hatenablog.com/entry/2018/04/04/layer-obligation

  • ドメイン層
    • ドメインモデル層 : エンティティやそれに関わるクラス(ValueObjectなど)を持つ
    • ドメインサービス層 : ドメインが定義するプロセスを持つ
  • アプリケーション層 : アプリケーション固有のロジック(ユースケースなど)を持つ
  • 外部レイヤー : UIデータベーステストなど周辺の関心に関するロジックを持つ

アプリケーション固有のロジックと言ってしまうと、「アプリケーション」という単語が多義であることから、人によって受け取り方が異なってしまう。

それぞれが何が責務なのかの話に重点を置けば、以下のような説明をすることができる。

  • ドメイン層 : データの整合性を担保する
  • アプリケーション層(ユースケース層):ドメイン層が公開するメソッドを組み合わせることで、ユースケースを組み立てる
  • UI層:アプリケーションとその側の世界をつなぐ

ドメイン層

データの整合性を担保するとはどういうことか?
下記の記事を参照。

https://little-hands.hatenablog.com/entry/2017/10/04/201201

「必ず整合性を担保できるメソッドしか上位レイヤに公開しない(可視性をPublicにしない)」ということを目指すとのこと。

上記記事の例で言うと、「タスクは3回だけ、1日ずつ延期することができる。」というのがデータ整合性のルールになる。
これを担保するためには、下記の2つのような制御を行う必要がある。

  • タスクを延期する時には延期回数を記録する
  • 延期前にこれまでの延期回数を確認し、3回を超えていたらエラーとする

これらを担保しないメソッドは「タスク延期回数を全く考慮せずに自由に期日を変更できる」といったものになる。
ActiveRecordパターンのオブジェクトのように、全てのsetterをpublic設定で公開しているのは、まさにこういう状態になる。

正しく制御を行えるメソッドのみpublicにすることで、「上位レイヤがどう頑張ろうとデータの整合性を壊せない」という状態を目指す。

アプリケーション層(ユースケース層)

このレイヤの責務は「ドメイン層が公開するメソッドを組み合わせ、ユースケースを組み立てる」と上記では書いた。

それではドメイン層の責務と何が違うのか?

以下の例があるとする。

ドメイン層がユーザーの住所を変更するメソッド固定電話番号を変更するメソッドをそれぞれ独立したpublicメソッドとして公開していたとする。

これはドメイン層が データの整合性的に、住所、固定電話番号をそれぞれ独立して更新することを許可している ということを表している。

これに対してアプリケーション層では上記メソッドを組み合わせて以下のようなユースケースを実現することができる。

  • 住所だけ更新したいというユースケース
  • 固定電話番号だけ更新したいというユースケース
  • 住所、固定電話番号を同時に更新したいというユースケース

更新操作を単独で行わせたいか同時に行わせたいかは、実現したいユースケースに合わせてアプリケーション層で自由に組み合わせればよい。

逆に、ドメイン層が 「データの整合性的に、住所と固定電話番号は同時の更新しか許可しない」としていたら、アプリケーション層もそれに従わざるを得ないことになる。

ユースケースというのは施策に応じて短いサイクルで変化する。WEB系や特にスタートアップ企業のような仮説検証などを繰り返さなければいけない企業であれば、いろんなユースケースが登場する。
しかし、それに合わせてデータの整合性も同じように変化するのかと言えば違う。
ライフサイクルが異なるものを、それぞれのレイヤに閉じ込めることで、変化に強い設計とすることができる。

shimakaze_softshimakaze_soft

オニオンアーキテクチャのレイヤ構造

https://buildersbox.corp-sansan.com/entry/2019/07/10/110000

https://i0.wp.com/jeffreypalermo.com/wp-content/uploads/2018/06/image257b0257d255b59255d.png?resize=366%2C259&ssl=1

オニオンアーキテクチャには、以下の4つのレイヤが存在している。

  • User Interface / Infrastructure / Tests(外側)
  • Application Services
  • Domain Services
  • Domain Model(中心)

大事なのは依存の方向と処理の流れになる。
依存方向は外側から中心の内側へと流れるようになる。外側のレイヤから内側のレイヤへの依存関係は許されているものの、逆の依存関係は基本的には許されない。

処理の流れは外側から中心の内側を通った後は、最終的には外に流れる。

https://github.com/matthewrenze/clean-architecture-demo

Application Services / Domain Services

User Interface(プレゼンテーション層)から利用される共通ロジックを提供する。
特定の業務の流れもしくはユースケースともとらえることができる。

Application

サンプルのデモアプリではそもそもApplication ServiceとDomain Serviceという言葉を使っていないため、それらに該当するクラスは/Applicationにまとめて含まれている。

このディレクトリには、先ほどの顧客リスト取得の実装の詳細が含まれている。

# from 


class GetCustomersListQuery(InterfaceGetCustomersListQuery):
    _database: InterfaceDatabaseService

    def __init__(self, database: InterfaceDatabaseService):
        self._database = database

    def execute(self) -> list[CustomerModel]:
        """ """
        # self._database = database
shimakaze_softshimakaze_soft

Infrastructure

外側のレイヤであるInfrastructure外部との通信を責務として持っている。

Tests

アプリケーションコードに対して、テストを実行するレイヤです。

これまでレイヤを分割する際に、インターフェースを挟んでいた。
このおかげでモックを挟むことができるため、レイヤごとにテストが可能になりアプリケーションのテスタビリティが向上する。

これは/Applicationの顧客リスト取得処理のテストコードで、データベースをモック化することで単体のレイヤでのテストを可能にしている。

shimakaze_softshimakaze_soft

pythonのオニオンアーキテクチャのサンプル

https://iktakahiro.dev/python-ddd-onion-architecture

https://speakerdeck.com/iktakahiro/ddd-and-onion-architecture-in-python

上記のオニオンアーキテクチャを参考に自分流のアレンジも加える。

ソースコードとディレクトリ構造は以下のようになっている。

https://github.com/iktakahiro/dddpy

$ tree
├── main.py
├── dddpy
│   ├── domain
│   │   └── book
│   │       ├── book.py  # Entity
│   │       ├── book_exception.py  # Exception definitions
│   │       ├── book_repository.py  # Repository interface
│   │       └── isbn.py  # Value Object
│   ├── infrastructure
│   │   └── sqlite
│   │       ├── book
│   │       │   ├── book_dto.py  # DTO using SQLAlchemy
│   │       │   ├── book_query_service.py  # Query service implementation
│   │       │   └── book_repository.py  # Repository implementation
│   │       └── database.py
│   ├── presentation
│   │   └── schema
│   │       └── book
│   │           └── book_error_message.py
│   └── usecase
│       └── book
│           ├── book_command_model.py  # Write models including schemas of the RESTFul API
│           ├── book_command_usecase.py
│           ├── book_query_model.py  # Read models including schemas
│           ├── book_query_service.py  # Query service interface
│           └── book_query_usecase.py
└── tests

テクニック

上記サンプルで登場する実装テクニック

  • リポジトリ パターン
  • DTO
  • CQRS パターン
  • Unit of Work パターン
  • Dependency Injection - 依存性の注入 (FastAPIの機能を使用)

Entity

Python でEntityを表すには、__eq__()メソッドを使用してオブジェクトの ID を確認する。

from dataclasses import dataclass


@dataclass
class Book:
    identifer: str
    title: str

    def __eq__(self, o: object) -> bool:
        if isinstance(o, Book):
            return self.identifer == o.identifer
        return False

Value Object

Value Objectの定義

  • エンティティでは無い
  • すべてのプロパティが同じ場合、オブジェクトは同一になる
  • その結果、それらは交換可能になる

Value Objectを表すには、@dataclassでデコレータを使用する。(eq=Truefrozen=Trueをつける)

from dataclasses import dataclass


@dataclass(init=False, eq=True, frozen=True)
class Isbn:
    """Isbn represents an ISBN code as a value object"""

    value: str

    def __init__(self, value: str) -> None:
        if pattern.match(value) is None:
            raise ValueError("isbn should be a valid format.")
        object.__setattr__(self, "value", value)

ValueObjectは必ずしも不変である必要は無いが、堅牢性を保護した方が良いとのこと。

Interface (抽象クラス)

抽象クラスであるabcモジュールを使用して、インターフェースを表現する。

from abc import ABC, abstractmethod
from dddpy.domain.book import Book


class BookRepository(ABC):
    """BookRepository defines a repository interface for Book entity."""

    @abstractmethod
    def create(self, book: Book) -> Book | None:
        raise NotImplementedError

    @abstractmethod
    def find_by_id(self, id: str) -> Book | None:
        raise NotImplementedError

    @abstractmethod
    def find_by_isbn(self, isbn: str) ->Book | None
        raise NotImplementedError

    @abstractmethod
    def update(self, book: Book) -> Book | None:
        raise NotImplementedError

    @abstractmethod
    def delete_by_id(self, id: str) -> None:
        raise NotImplementedError
shimakaze_softshimakaze_soft

DTOとFactory メソッド

DTO (Data Transfer Object) は、ドメイン オブジェクトをインフラストラクチャ層から分離するための優れた方法です。

from datetime import datetime

from sqlalchemy import Column, Integer, String

from dddpy.domain.book import Book
from dddpy.infrastructure.sqlite.database import Base


class BookDTO(Base):
    __tablename__ = "book"

    id: str | Column = Column(String, primary_key=True, autoincrement=False)
    title: str | Column = Column(String, nullable=False)

最小のMVCアーキテクチャでは、モデルは多くの場合、O/R マッパーによって提供される基本クラスを継承する。
ただし、その場合にドメイン層は外側の層に依存する。

この問題を解決するために、次の2つのルールを設定できる。

  • ドメイン層のクラス (エンティティや値オブジェクトなど) は、SQLAlchemyベースのクラスを拡張しない
  • DTOは、O/R マッパー クラスを拡張する

DTOリポジトリパターンを主張する人は、熱心すぎるアプローチです。」とのこと。

shimakaze_softshimakaze_soft

読み取り/書き込みモデルとリクエストの検証

読み取り専用モデル書き込み専用モデルから分離して、CQRSパターンを実装すると便利。
このアプローチは入出力検証の管理にもうまく機能する。

https://github.com/iktakahiro/dddpy/blob/main/dddpy/usecase/book/book_query_model.py

https://github.com/iktakahiro/dddpy/blob/main/dddpy/usecase/book/book_command_model.py

from pydantic import BaseModel, Field


class BookReadModel(BaseModel):

    id: str = Field(example="vytxeTZskVKR7C7WgdSP3d")
    isbn: str = Field(example="978-0321125217")

FastAPI はresponse_modelとrequest_modelを設定できる。
驚くべきことに、Read モデルResponse モデルに、Write モデルRequest モデルに関連付けることで、APIドキュメントを生成して検証することができる。

https://github.com/iktakahiro/dddpy/blob/main/main.py#L66

@app.post(
    "/books",
    response_model=BookReadModel,
    status_code=status.HTTP_201_CREATED,
    responses={
        status.HTTP_409_CONFLICT: {
            "model": ErrorMessageBookIsbnAlreadyExists,
        },
    },
)
async def create_book(
    data: BookCreateModel,
    book_command_usecase: BookCommandUseCase = Depends(book_command_usecase),
):
    try:
        book = book_command_usecase.create_book(data)
    except BookIsbnAlreadyExistsError as e:
        raise HTTPException(
            status_code=status.HTTP_409_CONFLICT,
            detail=e.message,
        )
    except Exception as e:
        logger.error(e)
        raise HTTPException(
            status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
        )

    return book

驚くべきことに、読み取りモデルを response_model に関連付け、書き込みモデルを要求モデルに関連付けることで、フレームワークは API ドキュメントを生成し、要求本文を検証できる。

CQRS パターン

https://github.com/iktakahiro/dddpy/blob/main/dddpy/infrastructure/sqlite/book/book_query_service.py

https://github.com/iktakahiro/dddpy/blob/main/dddpy/infrastructure/sqlite/book/book_repository.py

ReadモデルWriteモデルを分割することで、リクエストの入出力に柔軟に対応できる。
DRYについて長い間教わってきましたが、DRYは必ずしも絶対的なものではないことに気づくとのこと。

shimakaze_softshimakaze_soft

Unit of Work パターン

このプロジェクトで欠けているのは、トランザクション管理。

ギャップについては、Unit of Workパターンがほぼ完全に適合する。
しかし、最初に言っておきたいのは、これはあなたを含む他の人には強くお勧めしないということです。

なぜなら、リポジトリパターンをあれこれ提案した私にとっても冗長だからです。
しかし、フレームワークとそのミドルウェアの助けなしにトランザクション管理を行う方法は他に考えられないとのこと。

https://github.com/iktakahiro/dddpy/blob/main/dddpy/usecase/book/book_command_usecase.py#L18

from abc import ABC, abstractmethod


class BookCommandUseCaseUnitOfWork(ABC):
    book_repository: BookRepository

    @abstractmethod
    def begin(self):
        raise NotImplementedError

    @abstractmethod
    def commit(self):
        raise NotImplementedError

    @abstractmethod
    def rollback(self):
        raise NotImplementedError

https://github.com/iktakahiro/dddpy/blob/main/dddpy/infrastructure/sqlite/book/book_repository.py#L63

from dddpy.usecase.book import BookCommandUseCaseUnitOfWork


class BookCommandUseCaseUnitOfWorkImpl(BookCommandUseCaseUnitOfWork):
    def __init__(
        self,
        session: Session,
        book_repository: BookRepository,
    ):
        self.session: Session = session
        self.book_repository: BookRepository = book_repository

    def begin(self):
        self.session.begin()

    def commit(self):
        self.session.commit()

    def rollback(self):
        self.session.rollback()
shimakaze_softshimakaze_soft

Dependency Injection

最後のトピックです。

依存性注入(DIP)は誇張のように聞こえますが、本質的には、クラス インスタンスを他のクラスのプロパティに割り当てることを意味する。
私が FastAPI を気に入っている理由の1つは、デフォルトで DI メカニズムが提供されていること。

https://github.com/iktakahiro/dddpy/blob/main/main.py#L53

from fastapi import Depends


def book_query_usecase(session: Session = Depends(get_session)) -> BookQueryUseCase:
    book_query_service: BookQueryService = BookQueryServiceImpl(session)
    return BookQueryUseCaseImpl(book_query_service)


def book_command_usecase(session: Session = Depends(get_session)) -> BookCommandUseCase:
    book_repository: BookRepository = BookRepositoryImpl(session)
    uow: BookCommandUseCaseUnitOfWork = BookCommandUseCaseUnitOfWorkImpl(
        session, book_repository=book_repository
    )
    return BookCommandUseCaseImpl(uow)
shimakaze_softshimakaze_soft

先行開発!クリーンアーキテクチャ -- ゼロから始める新規開発

https://logmi.jp/tech/articles/323260

Enterprise Business Rules

クリーンアーキテクチャ図の中ではEntitiesって書かれている。

クリーンアーキテクチャで言うEntityは何かというと、ビジネスルールをカプセル化したもの。
いわゆるドメインオブジェクトがEntityと認識する。

ドメインオブジェクト=ドメインの概念を表現する。

ドメインとは何か、ソフトウェアで解決しようする対象の領域のこと。

運送業務をこなすアプリケーションだったら、その運送の世界がドメインという。
ビジネスと言っても差し支えない。

ビジネスオブジェクト。ビジネスの概念を表現する、そういうオブジェクトの話。

Application Business Rules

これUse Casesとも書く。

何かといえばアプリケーション層。

アプリケーションの目的であるドメイン(ビジネス)の問題を解決するために、ドメインオブジェクトを束ねあげてユースケースを実現すると言える。

買い物するときにオーダー、注文とか注文を受け付ける受注という概念がありますが、それはビジネスのオブジェクトになる。

しかし、それを使って買い物カートを作るのは、現在のビジネスになかったもの。
それをアプリケーションとして表現する。だから買い物かごは買い物かご機能を実現するためのオブジェクト。現在のリアルのビジネスにはなかったものを新たに作り出すというか、それらを組み合わせて解決に導くのがアプリケーション層になる。

フレームワークをビジネスロジックに依存させたい

別なフレームワークに変えるといったこともスムーズにできることになる。

SOLID原則の話を突き詰めるとクリーンアーキテクチャになる

Clean Architecture 達人に学ぶソフトウェアの構造と設計の本を読めば、22章ぐらいまでは「もう知っているよ、こんなこと」っていう話をしている。

SOLID原則にたどり着くまでの話をしていて、23章ぐらいからクリーンアーキテクチャの話、この図になってくる。
SOLID原則の話を突き詰めるとこのクリーンアーキテクチャの図になるよって話している。
だからクリーンアーキテクチャを理解するのにSOLID原則があれば、彼がこういうふうにこんなにクラス分けしている理由がわかるはず。

https://logmi.jp/tech/articles/323334#s2

shimakaze_softshimakaze_soft

https://nrslib.com/clean-architecture-with-java/

入力を変換して送るのがController

アプリケーションが要求するデータに入力を変換する。
入力を変換するController。

class UserController:
    _add_use_case: UserAddUseCase

    @Inject
    def __init__(self, _add_use_case: UserAddUseCase) -> None:
        self._add_use_case = _add_use_case

    def create_user(self, name: str, role_id: str) -> None:
        """ """
        role: UserRole = convert_role(role_id)
        input_data: UserAddInputData = UserAddInputData(name, role)
        self._add_use_case.handle(input_data)

    def convert_role(self, name: str, role_id: str) -> UserAddUseCase:
        """ """
        match role_id:
            case "admin":
                return UserRole.ADMIN
            case "member":
                return UserRole.MEMBER;
            case _:
                raise RuntimeException

Controllerの役目は司令官ではない。

ゲームのコントローラは例えにすれば、ボタンを押したら、そのボタンを押した事実をそのまま送るんじゃなくて、ゲーム機にわかる信号に変換して送る。
Bボタンを押した事実は送れない。Bボタンを押したことを電気信号に変えてコンピュータに送っているん。
コントローラがやっていることは、入力の変換になる。

そのため、このControllerUserControllerがやっていることは、createUserメソッドが入力のnamerole_idそれぞれ文字列をアプリケーションであるaddUseCaseに、ゲーム機が欲しがっているデータ、inputDataという形式に変換している。

Input Data

Input Data、入力データになる。

<DS>ともよく表記される。DSって何かといえば、Data Structure
つまりデータ構造体である。

from dataclasses import dataclass


@dataclass
class UserAddInputData(UserAddInputData):
    _user_name: str
    _role: UserRole

    def get_user_name(self) -> None:
        return self._user_name

    def get_role(self) -> None:
        return self._role

UserAddInputDataはユーザーの入力データである。
入力データをひとまとめにして、Input Dataとして定義する。
DTOとほぼ一緒になる。

ポートとアプリケーションロジック

Input Boundary
Input Portと呼ばれるもの。
ポート・アダプターで言うところのポートになる。

<I>がついているので、ただのインターフェース。
どういった引数、どういった入力データが必要なのかを定義して、それの使用を強制させる。

from abc import ABC, abstractmethod


class UserAddUseCase(ABC):

    def handle(self, input_data: UserAddInputData) -> None:
        raise NotImplementedError

Use Case Interactor

Use Case Interactor。
実際のアプリケーションロジックビジネスロジック
アプリケーションロジックになる。

見てみると、ビジネス上のUserオブジェクトを作って、それを保存して……userRepository、リポジトリパターンが出てくるが、後々で後述する。
saveすると、結果的にOutputDataPresenterに対してouput、出力している。


@Transactional
class UserAddInteractor(UserAddUseCase):
    _user_repository: UserRepository
    _user_add_presenter: UserAddPresenter

    @Inject
    def __init__(self, user_repository: UserRepository, user_add_presenter: UserAddPresenter) -> None:
        self._user_repository = UserRepository
        self._user_add_presenter = UserAddPresenter

    def handle(self, input_data: UserAddInputData) -> None:
        uuid4: str = str(uuid.uuid4())

        uuid4: str = str(uuid.uuid4())
        user: User = User(UserId(uuid4), UserName(input_data.get_user_name(), input_data.get_role())
        self._user_repository.save(user)

        output_data: UserAddOutputData = UserAddOutputData(uuid4)
        self._user_add_presenter.output(output_data)

入力したデータを使って、さきほどのアプリケーション、すでにある世界のドメインのビジネスの世界でオブジェクトを作って、それを操作して保存。

Userを作って保存できなかったため、保存するアプリケーションになる。
それを出力データとして作ってPresenterに引き渡す。