読者です 読者をやめる 読者になる 読者になる

かとじゅんの技術日誌

技術の話をするところ

混乱しがちなサービスという概念について

DDD Scala

社内でサービスがよくわからないという話になったので、考察を少しまとめておきます。

過去のエントリでも以下のように触れましたが、もう少しかみ砕いてみよう。

サービスという言葉はあいまい まず、簡単に前提の整理から。単に"サービス"って言葉が何を指すのか結構曖昧です。 サービスは簡単にいうと手続きとか振る舞いのことですが、細かくいうと、PofEAAでいうサービスと、DDDいうサービスは、目的が異なります。前者はアプリケーションのためにドメインモデルを再利用可能にするためのものです。後者はドメインの知識を表している振る舞いです。これはのちほど詳しく説明します。 まぁこのあたりは具体例がないと理解しがたいですが、レイヤーの違いによって責務が異なるという感じです。DDDのサービスの章では、サービスには、アプリケーション層、ドメイン層、インフラストラクチャ層と、複数のレイヤーに存在すると言及されています。PofEAAのService Layerは、DDDでいうアプリケーション層のサービス(以下 アプリケーションサービス)に相当すると思います。

ServiceとDCIについて - かとじゅんの技術日誌

サービスは抽象的でわかりにくい。特にDDDのレイヤー化アーキテクチャのレイヤー分割という概念を踏まえないと混乱する原因になりますので、レイヤーの定義から入りましょう。

レイヤー化アーテクチャの目的

第2部 第4章 ドメインを隔離するからの引用。

まず、問題提起から

UI, DBおよびその他の補助的なコードがビジネスオブジェクトに直接書かれることがしばしばある。また、ビジネスロジックが新たに追加される時には、UIウィジットやデータベーススクリプトのふるまいに組み込まれてしまう。こういうことが起きるのは、短期的に見ると、動くようにするには最も簡単な方法だからだ。 ドメイン関連のコードがそうした膨大な他のコードの中に拡散してしまうと、コードを見て意味を理解するのがきわめて困難になる。

解決方法は以下。

複雑なプログラムはレイヤーに分割すること。各レイヤで設計を進め、凝集度を高めて下位層にだけで依存するようにすること。 ドメインモデルに関係するコード全部を1つの層に集中させ、UI、アプリケーション、インフラストラクチャのコードから分離すること。表示や格納、アプリケーションタスク管理などの責務から解放されることで、ドメインオブジェクトはドメインモデルを表現するという責務に専念できる。これによて、モデルは十分豊かで明確になるように進化し、本質的なビジネスの知識をとらえて、それを機能させることができるようになる。

ここでは簡単にいうとドメインを隔離することが第一義的と言っています。

レイヤーの内訳

レイヤーは大きく分けて以下に分かれます。

  • インターフェイス層(UIなど)
  • アプリケーション層
  • ドメイン層
  • インフラストラクチャ層

ここでは、同じく引用からアプリケーション層とドメイン層、インフラストラクチャ層の責務の違いを説明します。

アプリケーション層とは

ソフトウェアが行うことになっている仕事を定義し、表現力豊かなドメインオブジェクトが問題を解決するように導く。このレイヤが責務を負う作業は、ビジネスにとって意味があるものか、あるいは他システムのアプリケーション層と相互作用するのに必要なものである。このレイヤは薄く保たれる。ビジネスルールや知識を含まず、やるべき作業を調整するだけで、実際の処理は、ドメインオブジェクトによって直下のレイヤで実行される共同作業に移譲する。ビジネスの状況を反映する状態は持たないが、ユーザやプログラムが行う作業の進捗を反映する状態を持つことはできる

ドメイン層とは

ビジネスの概念と、ビジネスが置かれた状況に関する情報、およびビジネスルールを表す責務を負う。ビジネスの状況を反映する状態はここで制御され使用されるが、これを格納するという技術的な詳細は、インフラストラクチャに委譲される。この層がビジネスソフトウェアの核心である

インフラストラクチャ層とは

上位のレイヤーを支える一般的な技術的な機能を提供する。これには、アプリケーションのためのメッセージ送信、ドメインのための永続化、ユーザインターフェイスのためのウィジット描画などがある。インフラストラクチャ層は、ここで示す4層間における相互作用のパターンも、アーキテクチャフレームワークを通じてサポートすることがある。

ドメインサービスとは

それでは、各レイヤーのサービスについて説明する前に、ドメイン層のドメインサービスの概念について触れておきます。あくまでドメインサービスの話です。

問題提起:

ドメインから生まれる概念の中には、オブジェクトとしてモデル化すると不自然なものもある。こうしたドメインで必要な機能をエンティティや値オブジェクトの責務として押し付けると、モデルに基づくオブジェクトの定義を歪めるか、意味のない不自然なオブジェクトを追加することになる

解決方法:

ドメインにおける重要なプロセスや変換処理が、エンティティや値オブジェクトの自然な責務でない場合は、その操作は、サービスとして宣言される独立したインターフェイスとしてモデルに追加すること。モデルの言語を用いてインターフェイスを定義し、操作名が必ずユビキタス言語の一部になるようにすること。サービスには状態を持たせないこと。

具体的な例で考えてみましょう。コード例は、以前のエントリから引用します。口座間転送のサービスです。転送メソッドは、MoneyにもBankAccountにも従属できないので、ドメインサービスとしています。あまり適切な例ではないかもしれませんが、万人ウケする事例はないので…。

口座間送金 サービス

ServiceとDCIについて - かとじゅんの技術日誌
object TransferDomainService {
  // 送金する
  // - 振る舞いの名前はユビキタス言語と対応する
  // - 原則的にステートレスであること
  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)
  }
}

呼び出し例

TransferDomainService.transfer(money, from, to)

このコードを見てもらうと、"なんだ ドメインモデルを入出力にとる関数じゃないか" と思うでしょう。そのとおりです。最初はその程度の認識でよいと思いますが、ここで一点だけいいたいのは、乱用は禁止ということです。ドメインサービスは、単に関数を導入をすればいいというものでありません。ドメインで必要な振る舞いであるが、無理矢理エンティティや値オブジェクトの振る舞いとすることで、ビジネス上の知見を歪める設計になるということです。また、逆に、従属するエンティティや値オブジェクトがないということで早期あきらめてしまい、なんでもかんでもドメインサービスにするというのもの違うのです。後者の場合は、振る舞いがあるべきドメインモデルから振る舞いを奪うことになるので、ドメインモデル貧血症の温床になる可能性があるのです。いずれにしても、ユビキタス言語のセマンティクスに従う必要があるということですね。十分な考慮なしに乱用は厳禁です。

アプリケーションサービスの事例

前置きはさておき、アプリケーション層のアプリケーションサービスの違いを考えてみましょう*1。 以下の例は、上記のドメインサービスを利用したアプリケーションサービスの例です。

case class TransferDto(money: Money, fromId: BankAccountId, toId: BankAccountId)

object TransferApplicationService {
 
  def transfer(transferDto: TransferDto): Try[Unit] = { 
    DB.localTx{ implicit tx =>
      for {
        Seq(from, to) <- BackAccountRepository.resolveBy(transferDto.fromId, transferDto.toId)
        (newFrom, newTo) = TransferDomainService.transfer(money, from, to)
        storeResult = BankAccountRepository.store(newFrom, newTo)
        Seq(fromUA, toUA) <- UserAccountRepository.resolveByBankAccountIds(transferDto.fromId, transferDto.toId)
        result <- NotifyInfrastructureService.notify(storeResult, fromUA.mailAddress, toUA.mailAddress)
      } yield result
    }
  }

}

ドメインサービスとアプリケーションサービスの違い

ドメインサービスはドメインモデルを入出力にとる関数ですが、それだけではアプリケーション要件は実現できません。そもそもアプリケーションサービスの入出力が違うわけです。アプリケーションサービスはI/F層から利用される前提であるため、TransferDtoはユースケースに強く依存することになります。また、I/Oの整合性を保証するためのトランザクション制御や、特定の処理完了を告げるためにプッシュ通知サービスを呼び出したりすることがあります*2。もちろん、単純なユースケースであれば、コントローラなどにこのようなロジックを直接書くかもしれないが、意図をより明確にするには別の名前をつけたメソッドに切り出したりします。この考え方をより推し進めたのがアプリケーションサービスです。

DDDでは、ドメインサービスはユビキタス言語に基づくビジネスロジックを表現しています。一方、アプリケーションサービスはビジネスロジックそのものではないという定義です*3。あくまで、ドメインモデルやインフラストラクチャサービスなどのやるべき作業を調整し進捗管理をするためだけの存在なのです。ユビキタス言語上の振る舞いと、アプリケーション上のユースケースという関係性とでも言えばよいでしょうか?この言葉だけでも、粒度が違うことが理解できると思います。この例ではリポジトリを使って現在の集約(=グローバルな識別子を持つエンティティ)を読み込んでドメインサービスの結果をまた保存しています。このI/Oはドメインモデル(エンティティ, 値オブジェクト, ドメインサービス)自体ので責務*4ではなく、リポジトリの責務なのでドメインサービスが担えないと考えています。

というわけで、ドメインサービスとアプリケーションサービスは、役割からして全く違うものです。

ドメインサービスのメソッドはユビキタス言語に結びついていることが前提です。ビジネス上の知識が単に振る舞いとなっているだけですが、重要なのは名前だけでなくその裏に秘められた不変条件です。口座間送金の場合はfromからtoに必ずお金が移動することです。

仮に TransferDomainService.transfer(money, from, to) メソッドの呼び出し部分が

newFrom = from.decrease(money)
newTo = to.increase(money)

であってもこのようなアプリケーションサービスは必要になるかもしれない。僕の経験ではほとんど必要になります。繰り返しになりますが、役割が違うから必要になるというわけです。もし、ドメインサービスにプッシュ通知などの通知機能が含まれているとしたら、それはドメイン知識と関係があるのか?それはユビキタス言語に含まれるのか?という問いをした方がよいでしょう。仮に、混同しているようなら、ドメインは隔離できていないということになります。

Akka で 番外編

ここから番外編。AkkaでのCQRS+ESを前提にしているので理解しにくいと思った方は無理に読まなくてもよいと思います。

PersistentActorを使って 集約やドメインサービス、アプリケーションサービスを実装した場合の擬似コードを書いてみた。あくまで概念を理解してもらうためのコードなので、すべてのコードは記載していませんし、コンパイルできないとか、集約がなぜActorRefなのかなど、そういう細かい点は無視して下さい。

CQRS + ESになるとドメインモデル群は書き込み系にしか登場しませんが、読み込みは別の系になります。集約を読み込むことを考えると伝統的なスタイルではリポジトリを実装することになりますが、CQRSでは集約は基本的にキーバリューのデータ構造として保存できればいいと見なすことができます。

AkkaではPersistentActorにコマンドを投げてその時の状態変化の記録としてドメインイベントを追記保存していくことになります*5。蛇足ですが、集約はドメインモデルなのになぜ永続化機能も持つの?という疑問にだけ答えておくと、集約内部のルートとなるエンティティに代表されるドメインモデルには永続化機能がなくて、ライフサイクルを司る集約にだけ永続化機能があるという考え方です。以下の例では、開始と終了イベントしか永続化していません*6が、リポジトリでいうstoreの機能はAkkaが担っていることになります。

そうなるとドメインサービスとアプリケーションサービスはほぼ同じようなものになるのでは?という見方になると思いますが、微妙に要件(以下の例では通知サービスの呼び出しがある)が異なるのでそうもいかないというのがわかると思います。

// ドメインサービス
class TransferDomainService extends PersistentActor {

   var state: Option[TransferState] = None

   // snip

   override def receiveCommand = {
     case BankAccountIncreased(toRef) =>
        val (commandId, fromRef, toRef, money) = state.map( e => (e.fromRef, e.toRef, e.money) ).get
        persist(DomainTransferFinished(EventId(), commandId, fromRef, toRef, money)) { ev =>
          eventBus.unsubscribe(Topic(classOf[BankAccountIncreased], toRef))
          eventBus.publish(Topic(ev, Some(ev.commandId)) // 終わったら完了イベントを発火
        }
     case BankAccountDecreased(fromRef) =>
        eventBus.unsubscribe(Topic(classOf[BankAccountDecreased], fromRef))
        val toRef = state.map(_.toRef).get
        toRef ! Increase(state.map(_.money).get) // BankAccount に Increase メッセージをパッシング。状態の永続化も行われます。
     case Transfer(commandId, money, fromRef, toRef) =>
        persist(DomainTransferStarted(EventId(), commandId, fromRef, toRef, money)) { ev =>
          state = Some(TransferState(commandId, fromRef, toRef, money))
          eventBus.subscribe(Topic(classOf[BankAccountDecreased], fromRef))
          eventBus.subscribe(Topic(classOf[BankAccountIncreased], toRef))
          fromRef ! Decrease(money) // BankAccount に Decrease メッセージをパッシング。状態の永続化も行われます
          eventBus.publish(ev) // 開始イベントを発火
        }
   }

}
// アプリケーションサービス
class TransferApplicationService extends PersistentActor {

  // snip

  override def receiveCommand = {
     case NotifyCompleted(_, commandId) =>
       persist(ApplicationTransferFinished(EventId(), commandId, fromId, toId, money)) { ev =>
         eventBus.unsubscribe(Topic(classOf[NotifyCompleted], commandId))
         eventBus.publish(ev) // 終了イベントを発火
       } 
     case DomainTransferFinished(_, commandId, fromId, toId, money) => // ドメインロジックが完了したら
       eventBus.unsubscribe(Topic(classOf[TransferFinished], commandId))
       eventBus.subscribe(Topic(classOf[NotifyCompleted], commandId))
       notifyService ! Notify(commandId, fromId, toId, money) // 通知サービスを呼び出す
     case ApplicationTransfer(commandId, money, fromId, toId) =>
       persist(ApplicationTransferStarted(EventId(), commandId, fromId, toId, money)) { ev =>
         eventBus.subscribe(Topic(classOf[TransferFinished], commandId))
         val fromRef = context.actorSelection(s"/user/${fromId}").resolveOne() // リポジトリから参照を取得する処理に相当する
         val toRef = context.actorSelection(s"/user/${toId}").resolveOne() // リポジトリから参照を取得する処理に相当する
         transferRef ! Transfer(commandId, money, fromRef, toRef) // ドメインサービスを呼び出す
         eventBus.publish(ev) // 開始イベントを発火
       } 
  }

}

*1:混同の対象はアプリケーションサービスとドメインサービスとしているので、インフラストラクチャ層のインフラストラクチャサービスは特に触れません。

*2:この例では、DBやNotifyInfrastructureServiceが、アプリケーション固有ではなく一般的な技術基盤としてのインフラストラクチャサービスと位置づけています

*3:ビジネスにとっては必要不可欠であることは間違いないですが。

*4:ドメインモデルはユビキタス言語に即した表現を行うのが責務なので永続化は責務対象外としている

*5:エラー時のロールバックはこの例では考慮していません

*6:開始イベントは利用してないのでいらないかも。