再考 - ドメインサービス
自分が大規模システムで組むアーキテクチャは基本的にはCleanArchitectureを踏襲しているが、その中の構成要素であるドメインサービスだけは少し独自(?)の解釈をしていて、書籍などでよく見る
- ビジネスロジックを持つが、状態をもたない
- 複数の集約にまたがる処理を書く場所
という責務の他に、外部システムへの委譲処理だったり、共通UseCaseのような責務も持たせている。
これは、自分が「xxService」という命名にトラウマがあり(何でも置き場になりがち)、単なるServiceだとコントローラやらプレゼンターやら、どこから呼ばれても違和感がない様に見えてしまうから、とりあえずDomeinServiceへ寄せている経緯がある。
※ここで語るのは、あくまで大規模想定で、小さいシステムならこんな事を意識する必要はないはず。
※あくまで自分の考えで、一般的ではない可能性があることをご了承ください。
なぜDomainServiceに寄せているのか
domainServiceと呼ぶのも👇のような考えがあったからで、DIPして技術関心事へある程度処理を委譲しつつドメインロジックを実現する場所として色々定義したいからだった(インターフェースはドメイン層だし、ドメインロジックと呼べるよな!?というノリで寄せている。
- 銀行の順番待ちみたいな実装をすると、待ち番号が書いてある紙を渡すUseCaseがあったとして、待ち番号を発行するロジックをドメインで持つのは、現実世界では待ち番号を案内担当者がメモって採番する行為に等しいように思う。
- 担当者が2人いると採番するのにコミュニケーションが必要だし面倒。採番はなんだかよくわからんシャシャシャーと紙を吐き出す機械に任せたい=技術的関心事に渡そうぜ みたいな話だと思っている。
- 技術的関心事によって実現するドメインに必要な要素を都合よく隠蔽できる場所(Repositoryに近しいドメインとインフラの境界の処理で、Repositoryは入出力、DomainServiceにもたせているのは出力のみ)だと思っている。
- それが正しいかはしらんけど、そうすると絶対的に便利だなとおもって書いている。(めっちゃ適当に書いてるのでツッコミ大歓迎w)
- iDDDのサンプルソースなどでは、repositoryのDIだけしてdomainServiceはドメイン層にベタ書き実装があったり、書き方が揺れている。悪いとは思わないが、repoがある時点でpureなdomainServiceではないし、委譲処理を書くところがない(または曖昧)のは分散モノリスになりやすそうという個人的な印象。
ドメイン層で表現しようと思えばできるという余談
コストに見合うとは全く思えないけど、表現しようと思えばできる。
- Effを使えば受付番号採番機みたいなドメインモデルを定義して、採番的なドメインロジックに技術津的関心事を注入することも可能だが、そこまで細かいモデリングが必要なケースとそうでないケースがありそうだなと。(隠蔽したほうがシンプルになるはず)
- ActorSystemのアプローチであれば、担当者同士の状態が常に問い合わせられる状態で存在しており、コミュニケーションがとれる(オンメモリに状態を持って常駐している)ので、合意形成のアルゴリズムなどをガチガチに実装すれば採番も可能にできるかも。
- とはいえ基本的には、ActorSystem以外のアプローチだとDB等に副作用を保存しているので都度都度スナップショットを取り出してこないといけない。
- 取り出すオブジェクトが一意じゃなければ並列性等考慮すると採番のロジックも普通にバグってしまう。
- こういうのを、redisなどのシングルプロセスのkvsに一旦保存してincrみたいなコマンドで採番していけば、「一旦保存」の部分さえ気をつければincrは全く同時に叩いてもかぶった番号を返すことはないのでサクッと採番が可能になる。
- これは「採番機」みたいなオブジェクトを実装側に隠蔽して、ドメインでは意識しない良いテクニックだと思う
- 「採番はなんだかよくわからんシャシャシャーと紙を吐き出す機械に任せる」ということをコードで定義するとこうなる
- 採番機をドメインにおくのは、めっちゃ採番機に詳しい担当者を前提にしているようなことな気がしている(そんなやつおらん)
細分化してみよう
とはいえ、domainServiceの実装がsecondary-adapterにあるのは違和感はあるっちゃーあるし、共通UseCaseをdomainServiceと呼ぶのも違和感がある。
細分化してみると現状のドメインサービスは以下の3つ存在していそう
- pureなdomainService
- 汎用業務プロセス(UseCaseからUseCase呼びたい時に逃がすやつ
- 技術関心事への委譲手続きプロセス(現状はここが多そう)
これを、
- pureなdomainService => DomainService
- 汎用業務プロセス => JointProcess
- 技術関心事への委譲手続きプロセス => DelegateProcess
として分けるとかなり腹落ちがした。 JointProcessの中でDelegateProcessやDomainServiceを呼んでもOKだし、UseCaseから呼んでもOK。
JointProcessはUseCaseに実装ベタ書き、DomainServiceはDomain層に実装ベタ書き、DelegateProcessはUseCase層にインターフェースを置いてSecondaryAdapterで実装注入(DIP)する。
自分の中で一番表現したかったのはDelegateProcessの部分。これがなしでRepositoryのみだと SaaSのエレガントな連携とは - まっちゅーのチラ裏 でも書いたとおり、双方向の依存を作り、分散モノリス化しやすいと思う。(多分みんなServiceとかに切り出して書いてるのかな。)
DelegateProcess相当のものを用意することで、たとえ双方向の依存が必要な連携方法だったとしても隠蔽でき、UseCase層からは単方向の依存に見せかけることができる。
クソ雑な図で示すと、Repositoryと委譲処理の処理の流れのイメージは👇のような感じ。(
また、サードパーティapiのコールバック等、コントローラーからusecase通って〜という処理を書かないといけないからサードパーティapiのデータ構造を丸コピしたようなドメインを定義しないといけないみたいなケースも、コントローラーからusecase -> deligate processと呼んで、ドメイン層を通さずにデータ構造をそのままDBに保存してしまうしまうという手が使えそう(UseCaseにDTOは要るが、ドメインは汚染されない。ドメインに侵食しないのが大事。外部のデータ構造なんてドメインは興味ない。) サードパーティapiのデータ構造は基本全部保存はしといた方がいいが、ドメインに顔出した方がいいかは全く別問題なのでこの手は有効だと思っている。(保存さえしておけば、後で情報落ちで困ることもないし、こういう情報をドメイン層介さず保存することで、自分たちのドメインモデルとして柔軟にReposiotryで組み立てることができる)
委譲プロセスがないと、別コンテキストの関心毎が各所に散らばって分散モノリスっぽくなってしまうと思っていて、みんなどこに書くんだ!と思っていたが名前をつけて思考が整理され、スッキリした。(最近、巷で分散モノリスの話が出てきてるが、これも委譲プロセスをアーキテクチャの中に見いだせていなからおきてると思ってる。UseCase層に登場するからドメインや!的なノリ。委譲プロセスとして切り出せば関心事は最小にできる
JointProcess,DelegateProcessの命名は適当なのでチームの人とかと話していい名前を見つけてみる(個人的には割としっくり来ている)
なぜApplicationServiceじゃダメなのか
目的自体は委譲なので、DIPで注入さえできればApplicationServiceという名前でもいいんだけど、UseCaseと混同する気もするし、「どこかにお願いする処理」を表す言葉として表現に乏しい気がしている。
なぜDomainServiceのままじゃダメなのか
別にダメじゃないと思う。
個人的には なぜDomainServiceに寄せているのか
に書いたとおり、DelegateProcessはDomainServiceと呼べると思っているし、アーキテクチャの表現力の観点では全く問題がないので。
ただ、JointProcessはDomainServiceとは呼べないなという違和感と、DelegateProcessは名前をつけて意識しやすくしてあげることで分散モノリスのような悲劇(もちろん途中から分離した場合はある程度仕方ない)を回避できるんじゃなかろうか。と考えている。
あと、DomainServiceだとドメイン層に置かないといけないが、DelegateProcessならUseCase層における。ドメインロジックだよな!から、あくまで業務手順の一つで詳細はしらん!にできる。ドメインモデルじゃなくてDTOで事足りる場面も多いと思うのでこれは良い戦略何じゃないかなと思っている(過度にやりすぎるとドメイン知識が漏れ出すので注意は必要)
※あくまで僕の実装しているプロジェクトの命名規則どうしようかな〜の話です🙏 認知負荷下げていきたい!
まとめ
これを徹底して、UseCaseに委譲の関心事を押し出すことで、誰もが一度は憧れた(かもしれない) :point_down:のようなドメインモデリングが可能になる。(流石にやりすぎではあるけど、あくまでイメージですw)
case class Payment(
id: PaymentId,
type: PaymentType
)
コレを不可能にしていた要素は大部分、UseCase層に押し出せる。(変に抽象化すると歪さをうむのでUseCase層ではある程度愚直に書かないといけない。ドメイン層のものと混ざらないことが大事。また、業務知識として出てきたものをUseCaseへ押し出してはいけないという前提のもとです)
課金履歴みたいなデータ構造は課金手段によって構造が変わるが、PaymentTypeだけ意識してドメイン層で取り扱うようにし、UseCaseで履歴の構造を組み立てるとかもできるかもしれない。(ここはある程度ドメイン層で表現した良さそうな直感はある。Repositoryによる入力が発生しそうなので。なるべく上に押し出す意識が持てればOK。外部連携用のデータをそのまま表示したいなどの要件があった際は、CQRS+ESで表示用のコンテキストに持っていけば、リードモデルが連携先のデータ構造に似てるとかは考えうる。課金レベルの複雑なドメインで表示の要件が出たときの歪さは自分の中でもきっちりとは整理できていないがCQRS+ESにのっておけば適切に分離できるだろうと考えている。近々決済機能を実装する機会があるのでそこで試す。他のドメインでは上手くいっている)
こうすることで、複数の課金要素(たとえばLinePayUseCaseやそれに必要なDTO)はそのままの形ではドメイン層に現れない。 コレでビジネスはシンプルになるはずだ。
※共通プロセス(JointProcess)、委譲プロセス(DelegateProcess)という名前は、結構解釈が割れそうな気もするのでいい名前思いついたらコメントいただけるとうれしい
※JointProcessは使用する側される側どっちなのかわかりにくい気がする・・・
※DelegateProcessは、Delegateという言葉に色々意味が含まれすぎてる気がする・・・
※追記: DelegateProcessは、xxxSolutionとかのほうが、注入するのも違和感ないし、UseCaseからもどっかに依頼してる感が出るし、業務的にもSolutionのことは手順内でやりとりだけ知っとけばいい感あるかもしれない。とはいえ、Serviceと同じくらいフワっとしてる気もする /(^o^)\