面白そうなネタがあったので、自分なりの考えをまとめてみる。
Ruby/Rails 用 DI コンテナ Dee をつくった、あるいは Ruby のカルチャーについて
この記事はRuby用のDIコンテナの話題なんですが、DCIについても言及されているようです。比較軸はDIそのものというより、サービスとDCIだと思うので、それについてダラダラといくつか考えをまとめてみます。多分返事になるようでならないかも。それと宗教上の都合でDDDの視点から書きます...。
サービスという言葉はあいまい
まず、簡単に前提の整理から。単に"サービス"って言葉が何を指すのか結構曖昧です。
サービスは簡単にいうと手続きとか振る舞いのことですが、細かくいうと、PofEAAでいうサービスと、DDDいうサービスは、目的が異なります。前者はアプリケーションのためにドメインモデルを再利用可能にするためのものです。後者はドメインの知識を表している振る舞いです。これはのちほど詳しく説明します。
まぁこのあたりは具体例がないと理解しがたいですが、レイヤーの違いによって責務が異なるという感じです。DDDのサービスの章では、サービスには、アプリケーション層、ドメイン層、インフラストラクチャ層と、複数のレイヤーに存在すると言及されています。PofEAAのService Layerは、DDDでいうアプリケーション層のサービス(以下 アプリケーションサービス)に相当すると思います。
あわせて読んで欲しいのはこのあたり。
- PofEAA 9.4 サービスレイヤ P142
- DDD サービス P103
- ドメイン駆動設計・アプリケーション構築編・サービス - Strategic Choice
ここではDDDでいうところの、ドメイン層のサービス(以下 ドメインサービス)に焦点を絞って考えてみます。
あと、ここでいうモデルというのはドメインモデルとします。DCIのDataもドメインモデルの前提。
オブジェクトはメンタルモデルを写し取るもの
いきなり話は変わってしまいますが、オブジェクト指向の成り立ちの話を少し
DCIアーキテクチャ - Trygve Reenskaug and James O. Coplien - Digital Romanticism から印象的な下りを紹介。
事実、オブジェクト指向プログラミングにおける先駆者たちの目的は、エンドユーザのメンタルモデルをコードにおいてとらえることだった。
そもそも、オブジェクト指向は、人間のメンタルモデルをシミュレーションするための問題解決手法として登場したので、これは素直に理解できます。源流をたどればAlan KayのDynabook構想とか出てきますね。この記事を読んでもらうとわかりますが、DCIもメンタルモデルに近づくための手法の一つです。
一方、DDDですが、Kent BeckはDDDに対する謝辞でこんなことを綴っています。
「ソフトウェアの設計を、今取り組んでいる問題ドメインのメンタルモデルに適合させるにはどうすればよいか、ということについて、Eric Evansは素晴しい本を警いた。」
あと、DHHは、おすすめ書籍のリストの中でこのように述べています。
Evans’ book, Domain-Driven Design, is great. It offers a mental framework for thinking deeper about the abstraction of object oriented programming.
DDDでも、"ユビキタス言語(DDD P24)"や"モデル駆動設計(DDD P45)"という手法を利用しますが、メンタルモデルを実装に反映するためにあります。
どちらも、オブジェクトはメンタルモデルを写し取るものだというスタンスは一致しているといえます。メンタルモデルを反映するのはドメインモデルなので、やはりメンタルモデルの主戦場はドメイン層だと思います。アプリケーションサービスじゃなくて、ドメインサービスの話を重視するのはこのためです。
ドメインサービスは乱用禁物
さて、ドメインサービスについて少し掘り下げていきます。
ドメインサービスの事例としては、次のような口座間送金サービスの例がよくでてきます。まぁ特に説明はいらないと思います。
通貨 値オブジェクト
case class Money(amount: Int, currency: Currency) extends Ordered[Money] { // 加算する def +(other: Money): Money = { require(currency == other.currency) Money(amount + other.amount) } // 減算する def -(other: Money): Money = { require(currency == other.currency) Money(amount - other.amount) } // 比較する def compare(other: Money): Int = { require(currency == other.currency) amount compare other.amount } }
口座 エンティティ
class BankAccount(val balance: Money) extends Entity[BankAccountId] { // 入金する def increase(money: Money): BankAccount = BankAccount(balance + money) // 出金する def decrease(money: Money): BankAccount = { require(balance - money >= Money.Zero) BankAccount(balance - money) } }
口座間送金 サービス
object TransferService { // 送金する // - 振る舞いの名前はユビキタス言語と対応する // - 原則的にステートレスであること def transfer(money: Money, from: BankAccount, to: BankAccount): (BankAccount, BankAccount) = { require(from.balance > money) val newFrom = from.decrease(money) val newTo = to.increase(money) (newFrom, newTo) } }
このケースでは、"口座間送金"という概念を二つの口座の相互作用として捉え、口座エンティティ単独の振る舞いとして相応しくないと判断しています(そういう設定)。では、送金自体を行う振る舞いは、どこにいけばよいのでしょうか。完全に宙に浮いています。そういう時にドメインサービスを利用するわけですね(DDDでは)。振る舞いとは関係なさそうなモデルに無理矢理振る舞いを押し付けるのも問題なので、これはこれで現実解です。
しかし、安易にドメインサービスを使ってしまうと、オブジェクトから振る舞いを奪ってしまい、オブジェクトはただのデータの入れ物になってしまう。いわゆる、ドメインモデル貧血症です。
というわけで、なんでもかんでもドメインサービスはよくない。乱用禁物だと思います。早急にドメインサービスと決めずに、宙に浮いた振る舞いが所属すべきモデルを探すのが原則です。
まぁ、この例ではサービスにしてもさほどデメリットを感じませんね。メソッドもユビキタス言語と対応しているし、その内部の処理もドメインオブジェクトで表現される振る舞いであれば、そんなに違和感ないです。ただ、サービスの振る舞いが増えてきたり、振る舞い自体がFATになった場合は要注意だと思います。
蛇足ですが、ドメインモデルをデータクラスとサービス(手続き)に完全に分離する"トランザクションスクリプト"という実装パターンもあります。これはオブジェクト指向らしい表現は損なわれますが、よく利用される手法です。オブジェクト指向を重視するDDDとは相性がよくないというか、相容れないので注意が必要です。メンタルモデルを重視する派、重視しない派はこのあたりで分かれます。分水嶺ですね。
あわせて読みたい
エンティティと値オブジェクトが主役
というわけで、ドメイン駆動設計では、主役はエンティティと値オブジェクト。
口座間送金の振る舞いが、ドメインサービスではなく、口座エンティティにあった場合を考えてみます。
次のようにしてみました。送金元と送金先で振る舞いを分けました。コードみればわかるので説明は割愛。
口座 エンティティ
class BankAccount(val balance: Money) extends Entity[BankAccountId] { // 入金する def increase(money: Money): BankAccount = ... // 出金する def decrease(money: Money): BankAccount = ... // 送金する def send(money: Money, to: BankAccount): (BankAccount, BankAccount) = { val newFrom = from.decrease(money) val newTo = to.onReceived(money, newFrom) // do logging (newFrom, newTo) } // 送金を受け取る。ドメインイベントを受け取るイベントハンドラに近いイメージ def onReceived(money: Money, from: BankAccount): BankAccount = { val newTo = increase(money) // do logging newTo } }
モデル(エンティティ)がFATになりますね。サービスとは裏返しの関係になるので仕方ないです。
つまるところ、これの善し悪しを決めるのは、メンタルモデルをとらえている、ユビキタス言語の定義がどうなっているかに寄りますね。
メンタルモデルに近づくためのDCI
前置き長くなりましたが、DCIの話。 DCIでは、コンテキスト(モデルを使う場面)に紐づく振る舞いと、そうでない振る舞いがある、という考え方がある(ボクの理解では)。
前述した口座エンティティの例では、send
メソッドは送金元口座の役割であり、onReceived
メソッドは送金先口座の役割です。そして、これらの役割は口座間送金という場面でしか使いません。なので役割にその振る舞いを定義し、口座間送金というコンテキストに入った際に振る舞いを持つ役割が口座エンティティに合成され、コンテキストから離れたら分離されるようにするイメージです。
ここで問題なのは、実装方法ですね。Rubyだとどういう方法が現実的なのかちょっとわかってないですが、extend/unextend がよさそうですね。
Scalaではいくつか実装方法があるのですが、ScalaでのDCIの実装を考えるで示した、暗黙的型変換と型クラスの方式がおすすめです。例は以下。 この例は、先ほどのドメインサービスをDCIで置き換えたものです。
暗黙的型変換と型クラスを使ったDCIの例
// ロール: 送信側 型クラス trait Sender[A] { def send[B: Receiver](self: A, money: Money, to: B): (A, B) } object Sender { implicit def toSenderOps[A: Sender](self: A) = new { def send[B: Receiver](moeney: Money, to: BankAccount): (BankAccount, BankAccount) = implicitly[Sender[A]].send(self, money, to) } } // ロール:受信側 型クラス trait Receiver[A] { def onReceived[B: Sender](self: A, money: Money, from: B): A } object Receiver { implicit def toReceiverOps[A: Receiver](self: A) = new { def onReceived[B: Sender](moeney: Money, from: BankAccount): BankAccount = implicitly[Receiver[A]].onReceived(self, money, from) } } // コンテキスト: 口座間送金 // - ドメイン層に配置したい case class TransferContext(from: BankAccount, to: BankAccount) { // 送金先ロール implicit object BankAccountReceiver extends Receiver[BankAccount] { def onReceived[B: Sender](self: A, money: Money, from: B): A val newTo = self.increase(money) // do logging newTo } } // 送金元ロール implicit object BankAccountSender extends Sender[BankAccount] { def send[B: Receiver](self: A, money: Money, to: B): (A, B) = { import Receiver._ val newFrom = self.decrease(money) val newTo = to.onReceived(money, newFrom) // do logging (newFrom, newTo) } } // 送金する // - メソッド名はユビキタス言語とリンクしたい def transfer(money: Money): (BankAccount, BankAccount) = { import Sender._ from.send(money, to) } }
コンテキストのインターフェイスをみるとわかりますが、ドメインサービスに近いですよね。でも決定的に違うところがあります。振る舞いがエンティティや値オブジェクトから流出しないという点です。それでいて、FATモデルを解消できる。これは大きな違いですね(このコード例は、DCIでよくみるサンプルとは少し違います。口座間送金という概念は、ドメイン上のコンテキストとしました。トランザクション管理とかどうすんの?って話はアプリケーション層の責務なので、アプリケーションサービスでやるか、それに相当するアプリケーションのコンテキストで行えばよいからです)。
コードはドメインサービスの例よりは長くなりましたが、メンタルモデルのよりよい表現に近づけるのではないでしょうか。これをコードが長い、手間とみるか、メンタルモデルに近づいたとみるか、マインドセットの違いで変わってきます。多態を最初に学んだ時もこんな感じだったのではないかな。何やってるか直接的に理解できないけど柔軟性を手に入れるトレードオフをした。DCIもそうですよ。メンタルモデルに近づくためです。このトレードオフはDDDに魅力を感じている人なら分かってくれると思いますけどね。
あと、ロールをほかの用途でも使えるなら費用対効果がよいですね。まぁ、エンティティや値オブジェクトがFATになってきたら検討の余地はあると思います。
今回の例では、ロールを型クラスで実現したのでそんなに違和感ないですね。言語の機能なんで当然ですが。そもそもロールはアドホックな多相性(継承関係がなくても同じインターフェイスで操作できる)を要求するので、型クラスでそれを実現しただけです。
もちろん、言語によって実装難易度の差はありますが、コンテキスト内で実行時にロールをモデルにmix-inできればそれっぽくなると思います。動的型付けが強い言語は特にやりやすいですね。Lean Architectureでは、Scala, C#, Python, Ruby, Javaの例があります。PHPも5.4.0以降で使えるtrait
を使えばできそう。
Scalaは静的型の整合性を崩さずに動的に振る舞いを合成することができましたが、Javaはフレームワークやライブラリのサポートがないときつそうです(Qi4jが使えそうです)...。いわゆる硬直しない今どきの言語?wならできるのかなと思っています。
あわせて読みたい
- DCIアーキテクチャ - Trygve Reenskaug and James O. Coplien - Digital Romanticism
- Modegramming Style: DCI (Data Context Interaction)
Scalaの型クラスについて言及されている - 『DCI なんて面倒なだけで Service 使えばいい』への返答 - 鳩舎
まとめ
ということで、 問題の大きさにあった解決手法を選べというのは当然ですが、
- 振る舞いは原則的にエンティティや値オブジェクトに。ドメインサービスは乱用禁物。
- エンティティや値オブジェクトがFATになってきたらDCIを検討する(その言語において実装が現実的であれば)
という方針がよいかなと思っています。
そして、ScalaはDCIと相性がよいです(キリッ