DDD実践メモ
ドメイン駆動設計(DDD)・レイヤー化アーキテクチャを実践するうえで行き当たったリアルな課題をメモ
「削除」というビジネスロジック
更新や削除などの処理には,権限管理(認可)というビジネスロジックが考えられるが,ことRepositoryパターンにおける「削除」は対象エンティティのIDを指定するだけでできてしまい,モデル内に閉じ込めるのが難しい。そこでDeletableIDという型を使う
通知が必要な場合
アプリケーションサーバーで何らかの処理を行ったあと,メールやpush通知を送る場合がある(例: アカウント登録処理後のメール)。理想的にはイベントドリブンアーキテクチャを導入して,特定処理の後のドメインイベントを購読(subscribe)させるべきであろうが,モノリスに比べるとインフラ管理がどうしても難しくなってしまう。
通知サービスをUse Case内部で呼び出す
Use Case層に通知サービスクラスのインターフェースを置いて、インフラ層等で実装する。
Use Caseの実装としては、ドメインモデルを用いたデータ更新系の処理を行った後で、通知サービスを呼び出すことになる。
通知詳細をPresenterとして取り扱う
より特殊 (※一般の反対としての特殊) な場合として、通知サービスをPresenter内部に閉じ込めるという方針を考えてみる。
この際,異なる複数のプレゼンテーションロジックを取り扱う場合にどのように対応するのかについて、検討の余地があるだろう。
例としてチャットアプリを考えてみると、
「メッセージ送信」のリクエスト元に成功したかどうか返したい = レスポンスとしてのプレゼンテーションロジックがある。
一方で,メッセージ受信先にも通知を送りたい = サブドメイン機能としてのプレゼンテーションロジックもある。(こちらはそもそもプレゼンテーションロジックなのか微妙なところだが…)
このような異なる種類のプレゼンテーションロジックを単一のPresenter実装で取り扱うか,目的に応じたPresenterを用意するのかは検討の余地がある。
通知をUse Caseの1つとして扱う
通知をユースケースと考える場合,ベストプラクティスとして先述の通りイベントドリブンアーキテクチャ上での当該ドメインイベントPublish→通知ユースケースSubscribeという形式になるだろう。
一方,クリーンアーキテクチャでいうところのユースケース (アプリケーションサービス)とコントローラーが適切に切り分けられていれば,コントローラー内で擬似的にイベント購読を再現することもできるはずである。すなわちController
内でHogeUseCase
を呼び出してから,処理結果をDTO(≒ドメインイベント)に詰めてNotifyUseCase
を呼び出す,という方法である。
非同期処理を行うユースケース
ExecutableIdパターン
DeletableIDに関する記事を再読し、削除のビジネスロジックにとどまらず、より一般にビジネスロジックをカプセル化するExecutableIDパターンを思いついた。
※ここではclass EligibleWorkerForRequestId implements Executable<WorkerId>
のようにしているが、Executable<WorkerId>
は脳内のメンタルモデルにとどめておき、class EligibleWorkerForRequestIdImpl implements EligibleWorkerForRequestId
のようにして、インターフェースをpublicに、実装クラスをprivateにしてWorker.getEligibleWorkerId()
を通してしかインスタンスが得られないようにするのがより適切であろう。
GPT-4への質問
ドメイン駆動設計の戦術的設計で、型システムを利用したExecutableIdというパターンを思いつきました。
たとえば、アウトソーシングサービスにおいて、業務依頼に対してワーカーーが応募できるためには、最低限ワーカーが初期研修を受けていないといけない(一般的なビジネスマナーや、サービスの使い方など。)とします。
このビジネスロジックは「ワーカーが初期研修を受けているかどうか」という点でWorker集約内で完結したロジックでありますが、一方で、業務依頼への応募処理(ユースケース)を取り扱うためには、業務依頼のRequest集約上にも「応募を受ける」メソッドが必要になります。
つまり、別の集約で判断し発行したExecutableIdをもとに、リクエストへの応募処理を行うのです。イメージはこんな感じです。
class WorkerId {
value: string;
// TODO: implement
}
interface ExecutableId<T> {
id: T;
}
class NotExecutableId<T> {
constructor(this.id: T);
}
class EligibleWorkerForRequestId implements Executable<WorkerId> {
constructor(this.id: WorkerId);
}
class Worker {
id: WorkerId;
getEligibleWorkerId(): EligibleWorkerForRequestId|NotExecutableId<WorkerId> {
if (this.hasCompletedTraining) {
return EligibleWorkerId(this.id);
} else {
// 応募不可とわかれば良いだけなので、NotEligibleWorkerIdのようなクラスは定義しない
return NotExecutableId(this.id);
}
}
}
class Request {
applicants: Array<WorkerId>;
addNewApply(eligibleWorkerId: EligibleWorkerForRequestId) {
const workerId = eligibleWorkerId.id;
this.applicants.push(workerId);
}
}
class ApplyToRequestInteractor {
workerRepo: WorkerRepository;
requestRepo: RequestRepository;
applyToRequest(requestId: string, workerId: string): Result {
const worker = this.workerRepo.find(new WorkerId(workerId));
const request = this.requestRepo.find(new RequestId(requestId));
const eligibleOrNotId = worker.getEligibleWorkerId();
// 以下のような実装を型システムにより強制
if (eligibleOrNotId instanceof eligibleWorkerId) {
request.addNewApply(eligibleWorkerId);
this.requestRepo.save(request);
return Result.success;
} else {
return Result.failure;
}
}
}
このような発想は現実的だと思いますか?また、メリット・デメリットや、今回のポリシーにおける抜け道とかは想定されますか?
GPT-4の回答
このようなパターンを「ExecutableID」として定義する考え方は、実際に現実的で、多くのソフトウェアアーキテクチャの問題を解決するのに有効です。ここで示されている概念は、ドメイン駆動設計(DDD)の原則に沿ったものであり、特定のビジネスルールやポリシーをモデル内で厳格に適用する方法を示しています。このアプローチにはいくつかのメリットとデメリットがあります。
メリット
- 明確なビジネスルールの適用:ビジネスロジックが集約内で明確に定義されており、ビジネスルールの遵守を保証します。これにより、アプリケーションの整合性を維持しやすくなります。
- 再利用性と拡張性の向上:ExecutableIDパターンを使うことで、特定の条件を満たすオブジェクトのみが特定のアクションを実行できるようになり、再利用性とシステムの拡張性が向上します。
- 型安全性:このパターンを利用することで、コンパイル時により多くのエラーを検出できるようになり、実行時のエラーの可能性を減少させます。
- ドメインの意図の明確化:ExecutableIDを使用することで、ドメインモデルがより表現豊かになり、ドメインの意図がコードを通じてより明確に伝わります。
デメリット
- 複雑性の増加:追加の抽象化層を導入することで、システムの全体的な複雑性が増します。これは、新しい開発者がシステムを理解する際の障壁となる可能性があります。
- パフォーマンスの影響:一部のシナリオでは、ExecutableIDパターンの使用により、実行速度にわずかな影響が生じる場合があります。特に、IDの検証処理が重い場合に顕著です。
- 設計と実装のオーバーヘッド:ExecutableIDパターンを設計し実装するには、初期の投資が必要です。特に、複雑なビジネスルールを持つアプリケーションでは、このパターンを適切に適用するために、より多くの時間とリソースが必要になる可能性があります。
抜け道と潜在的な問題
- 過剰設計:すべてのシナリオでExecutableIDパターンを使用することは、過剰設計につながる可能性があります。特定のビジネスロジックが単純な場合や、アプリケーションのその部分が頻繁に変更されない場合、このパターンを適用する必要はないかもしれません。
- テストの複雑化:ExecutableIDを使用すると、テストケースの作成が複雑になる場合があります。特に、ExecutableIDを生成するための条件が複雑な場合、これらの条件を満た
すためのテストセットアップが困難になる可能性があります。
このアプローチは、ビジネスロジックが複雑で、アプリケーションの整合性を保つことが重要な場合に特に有用です。適切なケースで適切に実装された場合、ExecutableIDパターンはアプリケーションの品質とメンテナンス性を大幅に向上させることができます。
Vertical Slice Architecture
一定のレイヤー構造は保ちつつも、関連機能ごとに「垂直に」スライスする。Feature-Slicedなレイヤードアーキテクチャと考えるのが一番シンプルであろうか?
なおかつ、各スライスの実装方針は自由。あるスライスではDDDの戦術的設計パターンである「エンティティ」や「値オブジェクト」、「リポジトリパターン」等をふんだんに使うのもよし、あるスライスでは複雑なビジネスロジックはあまりないので、シンプルな実装のためトランザクションスクリプトで実装するのもよし。
しかし、複数機能(集約)にまたがったユースケースをどのように整理するか(「機能ごとの連携の仕組み」)という問題や、機能ごとに実装スタイルが違うことによる認知負荷の問題は残る。
リポジトリパターン
ユースケース層で、複数集約間のトランザクションをどう張るのかという実装パターンの話。トランザクションの関心事だけはユースケース層でOKという点についても同意。
リポジトリパターンのコンセプトは、ある一面を切り取ればORMの再発明ともいえる。たしかに。その考えがあれば、リポジトリ層でトランザクションスクリプトを書くようなアンチパターン実装は減るかも。
随想:
マイクロサービスなら、ここもイベントドリブンアーキテクチャと楽観ロック等の方法を用いて結びつけるのであるが、その場合にも(結果整合を用いるとはいえ)擬似的なトランザクションが存在している。ここのトランザクションの存在を、今回の記事の例のように明示的にコードベース上に反映することはできないだろうか?イベントドリブンアーキテクチャと楽観ロックを同時に用いた場合には、あるユースケースにおけるトランザクションの関心が外側のインフラ層に漏れ出してしまうように見えるのだが、どうすれば良いのだろう?
DDDの戦術的設計
他集約の情報がほしいときに、コールバックを引数として受け取る設計
Enterprise Business RulesとApplication Business Rulesの違い
システム化する際の都合がApplication Business Rulesで、システムとしての形式によらず存在するロジックがEnterprise Business Rules。個数や文字数の制限がシステム都合なのかドメインロジックとしての都合なのか、がわかりやすい2つのケースが例示されている。