こんにちは。Holmesでサーバーサイドエンジニアをしている友野です。例年ひどい花粉症ですが、医者に処方してもらった薬で今年は快適に過ごせています。薬すごい。
ドメイン駆動設計(以降、DDD)を実践する上で悩みどころは色々ありますが、中でもドメインサービスをいつ、どのように使うかは特に難しい問題です。
今回は、開発時にドメインサービスを使うかどうかを判断するポイントを整理しておこうと思います。 現時点での整理であり、これがいかなる場面においても正解であるとは思いませんが、少なからず社内理解は進んでいます。
TL;DR
弊社ではドメインサービスの使い所として、そのふるまいが以下のいずれかに該当するかを判断基準としています。
そしてなにより大事なのは、ビジネスルールとして名前がつけられるかどうかです。
背景−ドメインサービスを使わない選択
こちらの記事の通り、ホームズクラウドはSpring MVCに則ったパッケージ構成で構築されており、データクラスとサービスクラスの組み合わせでビジネスルールを実現していました。
この考えのままドメインサービスを適用しようとすると、手続き的なロジックがドメイン層に移るだけでドメイン貧血症に陥りやすい、というのはよく知られています。DDD原典である「エリック・エヴァンスのドメイン駆動設計(牧野 祐子 牧野 祐子 今関 剛 今関 剛 今関 剛 和智 右桂 和智 右桂 Eric Evans)|翔泳社の本(以降、Evans本)」でも、ドメインサービスに頼りすぎるのは良くないとする記述もあります。
サービスは節度を持って使用すべきで、エンティティと値オブジェクトからすべてのふるまいを奪ってはならない。
ユースケース層でドメインオブジェクトを組み合わせれば、ある程度のビジネスルールが表現できることもあり、積極的にドメインサービスは使用していませんでした*1。なにより、何かしらの基準を設けないとドメイン貧血症になる懸念がありました。
一方で、アーキテクチャを含めた書き換えをしていく中で、いよいよ表現が難しくなってくるシーンが出てきました。試験的にドメインサービスで書いてみようと一部メンバーと試行錯誤を始めて、手続き的なロジックが徐々に生まれてきたのもこの時です。本記事のモチベーションは、過去を振り返り、同じ徹を踏まないようにすることです*2。
ドメインサービスの定義
まずは定義を確認します。Evans本では以下のように定義*3されています。
サービス
…概念的にどのオブジェクトにも属さないような操作が含まれることがある。強引に決着をつけるのではなく、問題領域に引かれる自然な輪郭にしたがって、モデルの中に明確にサービスを含めればよい。
<中略>
サービスとは、モデルにおいて独立したインタフェースとして提供される操作で、エンティティと値オブジェクトのようには状態をカプセル化しない。
また、「実践ドメイン駆動設計(ヴォーン・ヴァーノン 髙木 正弘)|翔泳社の本(以降、Vernon本)」では以下のように解説されています。
第7章 サービス
ドメインにおけるサービスとは、そのドメインに特化したタスクをこなす、ステートレスな操作のことだ。実行すべき何かの操作があって、それを集約や値オブジェクトのメソッドにするのは場違いだと感じたときは、ドメインモデルの中でサービスを作るべきだと考えられる。
対象のソフトウェアが実現すべきビジネスルールのうち、ドメインオブジェクトに持たせるには不自然なふるまいはドメインサービスに持たせる、と理解できます。
具体例に挙げられているものから考えてみる
不自然なふるまいとは一体どんなものがあるのでしょうか。プロダクションコードで利用するためにもっと解像度を高めていく必要があります。いくつかの書籍の例をピックアップしてみます。
- 口座間の資金振替(Evans本)
資金振替
のために引き落とし
と預け入れ
を行う
- ユーザー認証における仕組みの隠蔽(Vernon本)
テナントの有効状態確認、認証、パスワード暗号化
の知識をクライアント側から追い出す
- メールアドレス重複チェック(「ドメイン駆動設計 モデリング/実装ガイド - little-hands - BOOTH」)
ユーザーオブジェクト自身
は他のユーザー
の情報を持っていない
- 拠点間の輸送(「ドメイン駆動設計入門 ボトムアップでわかる!ドメイン駆動設計の基本(成瀬 允宣)|翔泳社の本」)
輸送
は必ず出庫
と入庫
のセットで行われる
これらを俯瞰してみると、以下のようなケースに使われることがありそうです。
- 単一/複数集約の複数インスタンス間にまたがって実現するケース(資金振替、メールアドレス重複チェック、拠点間の輸送)
- その処理過程・順序そのものがビジネスルールであるケース(認証の仕組み隠蔽)
- インタフェースを通じてインフラ層のふるまいと組み合わせるケース(メールアドレス重複チェック)
これらのケースを踏まえて、どうやってドメインサービスを実装していくかを考えてみます。
考えるポイント
プロダクトに適用した際に考えたポイントは以下の通りです。
以下、例を挙げながら、1つずつ見ていきます。サンプルコードは読者の方にイメージしていただくためのものであり、弊社プロダクトにおける実際のビジネスルールを正確に表したものではありません。また、弊社プロダクトの開発環境に合わせ、Javaで記述しています。
複数集約間で整合性を担保する必要があるか
最もドメインサービスの価値を発揮すると考えています。どちらかの集約にビジネスルールを書いたとしても、もう片方の集約のことを知らないとそのルールが実現できないのはやはり不自然です。かといって、そこで集約を1つにまとめるのはもっと不自然ですし、とても大きな集約になる懸念もあります。
複数集約間の整合性担保については、松岡さんのブログ記事にも記載がある通り、非常に分かりやすく、まさにドメインサービスの提供価値だと思います。
弊社プロダクトのコアドメインである契約ドメインでいえば、例えば「業務委託契約の更新を行うために、今年度分の契約書に押印する」はこのケースに当てはまります。集約の分割は別テーマになるため詳細は省きますが、契約と契約書を同一集約としてモデリングすることも可能とは思います。ただ、異なる概念・異なるライフサイクルのため、別集約とする方が弊社プロダクトにとって、よりドメインに沿った考え方です。契約ライフサイクルの考え方は次の記事を参照ください。
契約は当事者間の合意によって成り立つもので、基本的にはそれを文書化したものが契約書です。しかし、必ずしも契約書がなくとも、口頭や覚書で契約を更新することも可能ですし、複数契約書によって1つの契約を結ぶこともあります。いずれにせよ、契約は何かしらの合意を示すエビデンスによって更新されるものです。今回はその中でも分かりやすい契約書の例で進めます。
契約書の押印により契約を更新するドメインサービスの、必要最小限の要素のみのサンプルは以下の通りです。
@RequiredArgsConstructor public class ContractDomainService { // 契約書のリポジトリインタフェース // フレームワークによってDIされる private final DocumentRepository documentRepository; // 契約のリポジトリインタフェース private final ContractRepository contractRepository; /** * 契約書による契約更新 */ public void renewContract( DocumentId documentId, DocumentPartyId documentPartyId, RenewDateRange renewDateRange) { // 集約の復元 Document document = documentRepository.findById(documentId); Contract contract = contractRepository.findById(document.getContractId()); // 契約書の当事者(DocumentParty)が押印する document.signBy(documentPartyId); // 契約期間を引数の期間で更新する contract.renew(renewDateRange); // 状態を変えた集約の永続化 documentRepository.save(document); contractRepository.save(contract); } }
処理詳細は置いておくとして、2つの集約の状態が変わることで実現できるビジネスルールであることが表現できていると思います。状態変更は、それぞれの集約のふるまいに移譲するので、ドメインサービス自身はステートレスです。
ドメインサービス内で、リポジトリ等のインタフェースを利用することについては賛否あると思います。Evans本、Vernon本ではそれぞれ以下のような記述があります。
サービスと隔離されたドメイン層(Evans本)
ドメイン層とアプリケーション層のサービスは、これらインフラストラクチャ層のサービスと協力して動作する。例えば、ある銀行のアプリケーションでは、口座残高が一定限度額を下回ると、顧客に電子メールを送信するかもしれない。この電子メールシステムはインタフェースによってカプセル化され、場合によっては、代わりの通知手段もそこに含まれるかもしれない。このインタフェースが、インフラストラクチャ層のサービスである。
7.3 ドメインにおけるサービスのモデリング(Vernon本)
ドメイン内のサービスは、必要に応じてリポジトリを使える。しかし、集約のインスタンスからリポジトリにアクセスすることは、お勧めできない。
これらを踏まえて、ホームズクラウドではドメインサービス内でのインタフェース利用を良しとしています。また別のサンプルとして、集約のインスタンスを引数に受け取り、直接状態を変えることも考えられます。こちらの方がシンプルに書けますね。
public class ContractDomainService2 { /** * 契約書による契約更新 */ public void renewContract( Document document, Contract contract, DocumentPartyId documentPartyId, RenewDateRange renewDateRange) { // 契約書の当事者(DocumentParty)が押印する document.signBy(documentPartyId); // 契約期間を引数の期間で更新する contract.renew(renewDateRange); // 集約の永続化はユースケース層で行う } }
単一集約でも複数インスタンスを扱うか
ユーザーの存在チェック、重複チェックはドメインサービスの例としてよく聞きます。同一集約でも任意のインスタンスが他のインスタンスの状態を知らないのでイメージはしやすいですが、この例だけだとプロダクトに適用するイメージはあまり持てませんでした。
こちらも契約ドメインの例を考えると「任意の契約書のレビュー依頼がすでにされているかどうか」がこのパターンに当たりそうです。ドメインモデルとサンプルコードは以下の通りです。
@RequiredArgsConstructor public class RenewRequestDomainService { // レビュー依頼のリポジトリインタフェース private final ReviewRequestRepository reviewRequestRepository; /** * レビュー依頼済みの契約書か判定 */ public boolean isAlreadyReviewRequested(DocumentId documentId) { // リポジトリインタフェース経由で集約を取得する Optional<ReviewRequest> reviewRequest = reviewRequestRepository.findByDocumentId(documentId); // すでにレビュー依頼がされている場合はtrue return reviewRequest.isPresent(); } }
リポジトリから集約を復元して真偽を判定しているだけなので、ユースケース層のふるまいとしても表現できそうです。ドメインサービスにするかどうかは、ビジネスルールとして名前がつけられるかどうかだと考えています。以下、Evans本の引用です。
モデルの言語を用いてインタフェースを定義し、操作名が必ずユビキタス言語の一部になるようにすること。
この場合は、契約書に対してレビューは常に1つしかない、というのが表現したいビジネスルールです。ユースケース層でリポジトリから復元してチェックするだけだと、アプリケーション上は同じふるまいですが、このルールは表現できなくなります。
実装したいドメインルールが外部サービスと連携する表現をした方が自然か
前述のEvans本からの引用部分、
にて述べられているインフラストラクチャ層のサービスは、リポジトリだけではありません。
先日、ホームズクラウドは、契約書の締結にクラウドサイン連携を選択可能になりました(プレスリリース)。当事者に契約書の締結を依頼する締結依頼とクラウドサイン側のAPIをコールするリクエストを作る必要があります。この時、ドメインサービスからインタフェースを使います。
@RequiredArgsConstructor public class ExecutionRequestDomainService { // クラウドサインAPIをコールするインタフェース private final CloudSignConnector cloudSignConnector; // 締結依頼のリポジトリインタフェース private final ExecutionRequestRepository executionRequestRepository; /** * クラウドサインを利用した締結依頼を発行する */ public void issueExecutionRequestWithCloudSign( DocumentId documentId, CloudSignRequest cloudSignRequest) { ExecutionRequest executionRequest = ExecutionRequest.initialize(documentId); // 必要な情報を渡して締結を依頼する cloudSignConnector.issue(cloudSignRequest); // 締結依頼を進行中にする executionRequest.inProgress(); // 進行中にした締結依頼を永続化 executionRequestRepository.save(executionRequest); } }
ちなみに cloudSignConnector.issue()
インタフェースの実装では、RestTemplate等でクラウドサインAPIをコールする、レスポンスモデルを変換するといった実装を書きます。
リポジトリインタフェースの例と同様で、こちらもビジネスルールとして名前がつけられるかどうかを重要視しています。
逆に、ユースケースの最後で集約の情報をログに出力する場合など、アプリケーションの関心事はビジネスルールとして名前がつけにくいので、ユースケースクラスからインタフェース経由で実装しています。
さいごに
ドメインサービスは使わない方が良い、というのはある意味で正しいと思います。MVCパターンからリアーキテクティングを試みると、多かれ少なかれドメイン貧血症に陥る可能性は高いですし、部分的には実際に陥りつつあります。
しかし、ドメインサービスを使わない選択をすることで、ビジネスルールが見つけられないとしたら、それは本末転倒な気がします。 ドメインルールとして名前をつけられるかどうかを注意深く考えながら、3つの判断基準に照らしてドメインサービスを書いていくと、最初の迷いは軽減できるはずです。
We're hiring
少しずつですが、HolmesでもDDDの考え方が浸透してきました。さらにスピードを上げて良いプロダクトを作るために、エンジニアを積極採用中です。DDDに関して知見や興味があり、一緒にHolmesの目指す世界観を作りたい方、是非力を貸してください!
Holmesでは現在エンジニアを募集しています。 興味がある方は是非こちらからご連絡ください!