かとじゅんの技術日誌

技術の話をするところ

ScalaでのDCIの実装を考える

みなさん、こんばんわ。

会社のアドベントカレンダーで、Scalaコードでわかった気になるDDDというブログを書いたのですが、最近、老害を防ぐためにDCIについても勉強中です。

DCIアーキテクチャ - Trygve Reenskaug and James O. Coplien

とりあえず、これを読めということらしいですが、今ひとつ理解できなかったので、

Lean Architecture: for Agile Software Development

を買って読んでます(巻末にScalaのコード例もあってなかなかよさげです)。

この本ではtraitのmix-in方式を紹介しているのですが、この方法はイマイチだと思っているので、別の方法を考えてみたのでさくっと紹介します。

暗黙的型変換と型クラスでの実装

今回は、ECサイトなどの、商品(Product)の購入(purchase)について考えてみる。商品を購入する振る舞いを持つロールを購入者(Purchaser)として定義し、あるコンテキスト(ProductPurchaseContext)内で、継承関係のないユーザ(User)とグループ(Group)に対して、そのロールを適用する方法について考えてみた(ちなみに、実装コードの詳細はあまり書いてないので、イメージとしてとらえてください)。

まず、購入者のロールを表すトレイトがこれ(振る舞いの仕様を記述している部分をMethodless Roleと呼んでいるっぽい)。いわゆる、型クラスです。Aには何でも指定することができる。今回はUserもしくはGroupを指定する。また、購入メソッドであるpurchaseの第一引数にA型が渡される仕様となっている。

// Methodless Role
trait Purchaser[A] {

  def purchase(self: A, product: Product): A

}

// Data
trait User extends Entity[UserId] {
  // ...
}

// Data
trait Group extends Entity[GroupId] {
  // ...
}

型クラスの実装である、User, Group用のPurchaserの実装を定義する(DCI的にはMethodful Roleと呼んでいるっぽい)。

// Methodful Role
object UserPurchaser extends Purchaser[User] {
  def purchase(self: User, product: Product): User = {
    // ...
  }
}

// Methodful Role
object GroupPurchaser extends Purchaser[Group] {
  def purchase(self: Group, product: Product): Group = {
    // ...
  }
}

コンテキスト本体はこれ。 implicit def toPurchaserOpsによって、A型(User, Group)に対してpurcheseが追加されます。 実際の振る舞いは、implicit parameterにより、A型に対応したPurchaser[A]の実装が利用されます。

// Context
object ProductPurchaseContext {
  implicit val up = UserPurchaser
  implicit val gp = GroupPurchaser

  implicit def toPurchaserOps[A](self: A)
    (implicit purchaser: Purchaser[A]) =
    new {
      def purchase(product: Product): A = 
        purchaser.purchase(self, product)
    }

  def purchase(user: User, product: Product) = 
    user.purchase(product)

  def purchase(group: Group, product: Product) =
    group.purchase(product)

}

object Main extends App {
  // ...
  import ProductPurchaseContext._
  purchase(User("Junichi","Kato"), Product(ProductType.MacPro))
  purchase(Group("Capsule Corp"), Product(ProductType.MacPro))
  // ...
}

コンテキストの内部では、UserとGroupにロールが適用されているので、User#purchaseおよびGroup#purchaseメソッドは呼べます。コンテキスト外部で呼ぶとちゃんとコンパイルエラーになります。 あと、全く継承関係がないモデルに対して、Roleを適用してRoleに定義されたメソッドで操作が可能です(型クラスの実装自体を追加すれば未知のデータに対しても振る舞いを適用することができる)。

Lean Architectureで説明されている方法でも可能だが、既存のインスタンスへのロールの適用が面倒なので、こっちのがよいと思っている。

追記

ろーじーからコメントもらったので、objectではなくcase classにしてみた。
objectだとコンテキストがシングルトンになるので微妙じゃね?という感じ。確かに、それはある。コンテキストは、都度作られて実行して破棄されるものだからclassの方がよいのでは、ってことか。なるほど。

// Context
case class ProductPurchaseContext() {
  implicit val up = UserPurchaser
  implicit val gp = GroupPurchaser

  implicit def toPurchaserOps[A](self: A)
    (implicit purchaser: Purchaser[A]) =
    new {
      def purchase(product: Product): A =
        purchaser.purchase(self, product)
    }

  def execute(user: User, product: Product) =
    user.purchase(product)

  def execute(group: Group, product: Product) =
    group.purchase(product)

}

object Main extends App {
  // ...
  ProductPurchaseContext().execute(User("Junichi","Kato"), Product(ProductType.MacPro))
  ProductPurchaseContext().execute(Group("Capsule Corp"), Product(ProductType.MacPro))
  // ...
}