ドメイン駆動設計(DDD)についてとビジネスロジックの別け方 - Part2
こちらがPart1
オニオンアーキテクチャとクリーンアーキテクチャの違い
オニオンアーキテクチャやその他のアーキテクチャの共通点を集約して汎用化したモデルがクリーンアーキテクチャであり、2013年にC. Martin氏が提唱した。
クリーンアーキテクチャ
はオニオンアーキテクチャ
を含むいくつかのアーキテクチャを自然な形で1つに統合するもの、という風に書かれている。
そのため、コアなコンセプトとしてはかなり似ている。違いがあるとすれば以下の2つになる。
- オニオンアーキテクチャは、依存が内側に向かっていれば層を飛び越えても良い
- オニオンアーキテクチャには明示的な
アダプタ層(Interface Adapter)
が無い
以下の記事はそれぞれの違いや比較などもして大変わかりやすい記事です。
この記事によればクリーンアーキテクチャには3つの特徴があると説明している。
- 依存関係がわかりやすい = 依存方向のルール
- 注目している問題以外はブラックボックスで良い = 関心の分離
- 外部環境にプロダクトが依存しにくい
以下のような比較表も紹介されている
クリーンアーキテクチャの概念 | オニオンアーキテクチャの概念 |
---|---|
Entities層 | Domain Model層 |
Entities層またはUse Cases層 | Domain Services層 |
Use Cases層 | Application Services層 |
Interface層またはinfrastructure層 | Infrastructure層 |
infrastructure層 | (外部のライブラリ・ドライバなど) |
オニオンアーキテクチャではドメイン層とアプリケーション層の責務はどう違う?
- ドメイン層
- ドメインモデル層 :
エンティティ
やそれに関わるクラス(ValueObject
など)を持つ - ドメインサービス層 : ドメインが定義するプロセスを持つ
- ドメインモデル層 :
- アプリケーション層 :
アプリケーション固有のロジック(ユースケースなど)
を持つ - 外部レイヤー :
UI
、データベース
、テスト
など周辺の関心
に関するロジックを持つ
アプリケーション固有のロジック
と言ってしまうと、「アプリケーション」という単語が多義
であることから、人によって受け取り方が異なってしまう。
それぞれが何が責務
なのかの話に重点を置けば、以下のような説明をすることができる。
- ドメイン層 :
データの整合性を担保
する - アプリケーション層(ユースケース層):
ドメイン層が公開するメソッドを組み合わせる
ことで、ユースケースを組み立てる
- UI層:
アプリケーションとその側の世界をつなぐ
ドメイン層
データの整合性を担保する
とはどういうことか?
下記の記事を参照。
「必ず整合性を担保できるメソッドしか上位レイヤに公開しない(可視性をPublicにしない)」
ということを目指すとのこと。
上記記事の例で言うと、「タスクは3回だけ、1日ずつ延期することができる。」
というのがデータ整合性のルール
になる。
これを担保するためには、下記の2つのような制御を行う必要がある。
- タスクを延期する時には延期回数を記録する
- 延期前にこれまでの延期回数を確認し、3回を超えていたらエラーとする
これらを担保しないメソッドは「タスク延期回数を全く考慮せずに自由に期日を変更できる」
といったものになる。
ActiveRecordパターンのオブジェクトのように、全てのsetterをpublic設定で公開しているのは、まさにこういう状態
になる。
正しく制御を行えるメソッドのみpublicにする
ことで、「上位レイヤがどう頑張ろうとデータの整合性を壊せない」
という状態を目指す。
アプリケーション層(ユースケース層)
このレイヤの責務は「ドメイン層が公開するメソッドを組み合わせ、ユースケースを組み立てる」
と上記では書いた。
それではドメイン層の責務
と何が違うのか?
以下の例があるとする。
ドメイン層がユーザーの住所を変更するメソッド
と固定電話番号を変更するメソッド
をそれぞれ独立したpublicメソッドとして公開していたとする。
これはドメイン層が データの整合性的に、住所、固定電話番号をそれぞれ独立して更新することを許可している ということを表している。
これに対してアプリケーション層では上記メソッドを組み合わせて以下のようなユースケースを実現する
ことができる。
- 住所だけ更新したいというユースケース
- 固定電話番号だけ更新したいというユースケース
- 住所、固定電話番号を同時に更新したいというユースケース
更新操作を単独で行わせたいか同時に行わせたいか
は、実現したいユースケース
に合わせてアプリケーション層で自由に組み合わせればよい。
逆に、ドメイン層が 「データの整合性的に、住所と固定電話番号は同時の更新しか許可しない」としていたら、アプリケーション層もそれに従わざるを得ないことになる。
ユースケースというのは施策に応じて短いサイクルで変化
する。WEB系や特にスタートアップ企業のような仮説検証などを繰り返さなければいけない企業であれば、いろんなユースケースが登場する。
しかし、それに合わせてデータの整合性も同じように変化するのか
と言えば違う。
ライフサイクルが異なるものを、それぞれのレイヤに閉じ込めることで、変化に強い設計
とすることができる。
オニオンアーキテクチャのレイヤ構造
オニオンアーキテクチャには、以下の4つのレイヤが存在している。
- User Interface / Infrastructure / Tests(外側)
- Application Services
- Domain Services
- Domain Model(中心)
大事なのは依存の方向と処理の流れになる。
依存方向は外側から中心の内側へと流れるようになる。外側のレイヤから内側のレイヤへの依存関係は許されているものの、逆の依存関係は基本的には許されない。
処理の流れは外側から中心の内側を通った後は、最終的には外に流れる。
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
Infrastructure
外側のレイヤであるInfrastructure
は外部との通信を責務
として持っている。
Tests
アプリケーションコードに対して、テストを実行するレイヤです。
これまでレイヤを分割する際に、インターフェースを挟んでいた。
このおかげでモックを挟むことができるため、レイヤごとにテストが可能になりアプリケーションのテスタビリティが向上する。
これは/Application
の顧客リスト取得処理のテストコードで、データベースをモック化することで単体のレイヤでのテストを可能にしている。
pythonのオニオンアーキテクチャのサンプル
上記のオニオンアーキテクチャを参考に自分流のアレンジも加える。
ソースコードとディレクトリ構造は以下のようになっている。
$ 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=True
とfrozen=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
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
とリポジトリパターン
を主張する人は、熱心すぎるアプローチです。」とのこと。
読み取り/書き込みモデルとリクエストの検証
読み取り専用モデル
を書き込み専用モデル
から分離して、CQRSパターンを実装すると便利。
このアプローチは入出力検証の管理にもうまく機能する。
from pydantic import BaseModel, Field
class BookReadModel(BaseModel):
id: str = Field(example="vytxeTZskVKR7C7WgdSP3d")
isbn: str = Field(example="978-0321125217")
FastAPI はresponse_mode
lとrequest_model
を設定できる。
驚くべきことに、Read モデル
をResponse モデル
に、Write モデル
を Request モデル
に関連付けることで、APIドキュメントを生成して検証することができる。
@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 パターン
Readモデル
とWriteモデル
を分割することで、リクエストの入出力に柔軟に対応できる。
DRYについて長い間教わってきましたが、DRYは必ずしも絶対的なものではないことに気づくとのこと。
Unit of Work パターン
このプロジェクトで欠けているのは、トランザクション管理。
ギャップについては、Unit of Workパターンがほぼ完全に適合する。
しかし、最初に言っておきたいのは、これはあなたを含む他の人には強くお勧めしないということです。
なぜなら、リポジトリパターンをあれこれ提案した私にとっても冗長だからです。
しかし、フレームワークとそのミドルウェアの助けなしにトランザクション管理を行う方法は他に考えられないとのこと。
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
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()
Dependency Injection
最後のトピックです。
依存性注入(DIP)
は誇張のように聞こえますが、本質的には、クラス インスタンスを他のクラスのプロパティに割り当てることを意味する。
私が FastAPI を気に入っている理由の1つは、デフォルトで DI メカニズム
が提供されていること。
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)
先行開発!クリーンアーキテクチャ -- ゼロから始める新規開発
Enterprise Business Rules
クリーンアーキテクチャ図の中ではEntities
って書かれている。
クリーンアーキテクチャで言うEntityは何かというと、ビジネスルールをカプセル化したもの。
いわゆるドメインオブジェクトがEntityと認識する。
ドメインオブジェクト=ドメインの概念を表現する。
ドメインとは何か、ソフトウェアで解決しようする対象の領域
のこと。
運送業務をこなすアプリケーションだったら、その運送の世界がドメイン
という。
ビジネスと言っても差し支えない。
ビジネスオブジェクト。ビジネスの概念を表現する、そういうオブジェクトの話。
Application Business Rules
これUse Cases
とも書く。
何かといえばアプリケーション層。
アプリケーションの目的であるドメイン(ビジネス)の問題を解決するために、ドメインオブジェクトを束ねあげてユースケースを実現する
と言える。
買い物するときにオーダー、注文とか注文を受け付ける受注という概念
がありますが、それはビジネスのオブジェクトになる。
しかし、それを使って買い物カートを作るのは、現在のビジネスになかったもの。
それをアプリケーションとして表現する。だから買い物かごは買い物かご機能を実現するためのオブジェクト。現在のリアルのビジネスにはなかったものを新たに作り出すというか、それらを組み合わせて解決に導くのがアプリケーション層になる。
フレームワークをビジネスロジックに依存させたい
別なフレームワークに変えるといったこともスムーズにできることになる。
SOLID原則の話を突き詰めるとクリーンアーキテクチャになる
Clean Architecture 達人に学ぶソフトウェアの構造と設計
の本を読めば、22章ぐらいまでは「もう知っているよ、こんなこと」っていう話をしている。
SOLID原則にたどり着くまでの話
をしていて、23章ぐらいからクリーンアーキテクチャの話
、この図になってくる。
SOLID原則の話を突き詰めるとこのクリーンアーキテクチャの図になるよって話している。
だからクリーンアーキテクチャを理解するのにSOLID原則があれば、彼がこういうふうにこんなにクラス分けしている理由がわかるはず。
入力を変換して送るのが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ボタンを押したことを電気信号に変えて
コンピュータに送っているん。
コントローラがやっていることは、入力の変換
になる。
そのため、このController
、UserController
がやっていることは、createUser
メソッドが入力のname
とrole_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すると、結果的にOutputData
をPresenterに対してoupu
t、出力している。
@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に引き渡す。
トランザクション処理をどこに記述するか
以下にまとめてある
アプリケーションサービスとドメインサービスの違い
サービスには2種類がある