Scala
DDD

続: DDDのリポジトリのインターフェイスをどのように設計すべきか

昔書いた記事ですが、今回は続編です。

DDDのリポジトリのインターフェイスをどのように設計すべきか

過去の実装は一旦捨てて書き直してみました。

scala-ddd-base/reboot

前回と比べて改善した点は以下です。

  • Try, Futureなどの個別の型向けのtraitを作らない
  • IO用コンテキストをリポジトリメソッドから追い出した
  • 型パラメータの数を減らす

型クラスとTagless Final

同期版、非同期版というようにトレイトや実装を分けていくと複雑になりがちなので、以下のようしました(実装は???ですが、詳細はgithubを参照してください)

型パラメータはM[_]だけになります。高階型というやつです。ID型は抽象型メンバーにすることで型パラメータの数を減らしました。

trait UserAccountRepository[M[_]]
    extends AggregateSingleReader[M]
    with AggregateMultiReader[M]
    with AggregateSingleWriter[M]
    with AggregateMultiWriter[M]
    with AggregateSingleSoftDeletable[M] {
  override type IdType        = UserAccountId
  override type AggregateType = UserAccount
}

これまでTry,Futureを個別にトレイトに分けて実装してましたが、型クラスのインスタンスとして定義します(repoTry, repoFuture)。このインスタンスは、applyメソッドで型名をしてすることで引き当てることができます。(あー、あと書き込み系メソッドの戻り値に集約を返さないようにあえてしています。書き込みと読み込みを分離できた方がスケールしやすいからですね)

object UserAccountRepository {

  implicit val repoTry = new UserAccountRepository[Try] {

    override def resolveById(id: UserAccountId): Try[UserAccount] = ???

    override def resolveMulti(ids: Seq[UserAccountId]): Try[Seq[UserAccount]] = ???

    override def store(aggregate: UserAccount): Try[Long] = ???

    override def storeMulti(aggregates: Seq[UserAccount]): Try[Long] = ???

    override def softDelete(id: UserAccountId): Try[Long] = ???

  }

  implicit val repoFuture = new UserAccountRepository[Future] {
    override def resolveById(id: UserAccountId): Future[UserAccount] = ???

    override def resolveMulti(ids: Seq[UserAccountId]): Future[Seq[UserAccount]] = ???

    override def store(aggregate: UserAccount): Future[Long] = ???

    override def storeMulti(aggregates: Seq[UserAccount]): Future[Long] = ???

    override def softDelete(id: UserAccountId): Future[Long] = ???
  }

  def apply[M[_]](implicit F: UserAccountRepository[M]): UserAccountRepository[M] = F

}

さて実際の利用例ですが、アプリケーションサービスでUserAccountRepositoryを利用しますが、Mは型パラメータのままとして、利用時まで決定を遅延させる方式で実装します。Tagless Finalといいます。

import cats.imlicits._

class UserAccountService[M[_]: MonadError](userAccountRepository: UserAccountRepository[M]) {

  def updateName(id: UserAccountId, firstName: String, lastName: String): M[Long] = {
    for{
      aggregate <- userAccountRepository.resolveById(id)
      result <- userAccountRepository.store(aggregate.withName(firstName, lastName))
    } yield result
  }

}

val service = new UserAccountService(UserAccountRepository[Try])
service.updateName(id, firstName, lastName)

IOContextはどうなるか?

SkinnyORM(ScalikeJDBC)などのDBSessionやExecutionContextをどう渡すのか悩ましいですが、以下のようにして抽象化しました。
コンテキスト類を渡す必要がある場合は、cats.data.ReaderTを利用します。実装例は以下。これでもよいですが、implicitの定義しなおしはだるい。

object UserAccountRepository {

  case class IOContext(dbSession: DBSession, ec: ExecutionContext)

  type ReaderTFuture[A] = ReaderT[Future, IOContext, A]

  implicit val repoFuture = new UserAccountRepository[ReaderTFuture] {
    override def resolveById(id: UserAccountId): ReaderTFuture[UserAccount] = ReaderT{ ioContext =>
      implicit val dbSession = ioContext.dbSession
      implicit val ec = ioContext.ec
      Future{ dao.findById(id) }
    }
// ...
  }

}

Futureをもう少し抽象化したいと考え、monix.eval.Taskで考えてみました。ずいぶんすっきりしました。これでTaskの生成にはExecutionContextが不要(実行時には必要だが、スケジューラ経由で与えられるようです)になったので、DBSessionだけが読めればよいことになります。

object UserAccountRepository {

  type ReaderTTask[A] = ReaderT[Task, DBSession, A]

  implicit val repoFuture = new UserAccountRepository[ReaderTTask] {
    override def resolveById(id: UserAccountId): ReaderTTask[UserAccount] = ReaderT{ implicit dbSession =>
      Task{ dao.findById(id) }
    }
// ...
  }

}

Slickの場合はDBSessionに対応するものがなく、クエリをDBIO#runすることになるので、M型はTaskを指定するとよいと思います。

object UserAccountRepository {

  def repoFuture(_profile: JdbcProfile, _db: JdbcProfile#Backend#Database) = new UserAccountRepository[Task] {
    val profile = _profile
    val db = _db
    override def resolveById(id: UserAccountId): Task[UserAccount] = Task.deferFutureAction{ implicit ec =>
      db.run(dao.filter(_.id === id.value).take(1).result)
    }.flatMap(converToAggregate)
// ...
  }

}

scala-ddd-base/reboot

scala-ddd-base/rebootは上記で述べた考え方で実装しています。詳しくはREADME.mdみてください。Free版も作ってますが、おまけで実装しましたが普通に使えます。実装例はここからみてください

使い方

コアとなるトレイトは抽象メソッドだけを持ち、サポート的なトレイト(こちらは実装が伴うのでMは具象型になります)はSlickもしくはSkinnyORM用の実装を提供します。
使い方はこのあたりみるとわかりやすいです。

  • Skinnyの場合

IOContextの引き回しは不要で、ReaderTを実行するときにDBSessionを与えるだけです。そうするとTaskが返されるのでrunAsyncなどで実行をトリガーします。

import monix.execution.Scheduler.Implicits.global

val userAccountRepository = UserAccountRepository[BySkinnyWithTask]
val resultReader: ReaderT[Task, DBSession, UserAccount] = for {
  _ <- userAccountRepository.store(userAccount)
  result <- userAccountRepository.resolveBy(userAccount.id)
} yield result
val resultFuture: Future[UserAccount] = resultReader.run(AutoSession).runAsync
  • Slickの場合

UserAccountRepository#applyで型クラスのインスタンスを取得したかったが、ちょっと難しそうなので普通にファクトリ経由でリポジトリのインスタンスを作ります。

import monix.execution.Scheduler.Implicits.global

val userAccountRepository: UserAccountRepository[BySlickWithTask] = UserAccountRepository.bySlickWithTask(dbConfig.profile, dbConfig.db)
val resultTask: Task[UserAccount] = for {
  _ <- userAccountRepository.store(userAccount)
  result <- userAccountRepository.resolveBy(userAccount.id)
} yield result
val resultFuture: Future[UserAccount] = resultTask.runAsync

まとめ

  • ここまで書いておいてなんですが、UserAccountRepositoryのM[_]のような抽象的な型がほんとうに必要かどうか、考えた方がいいでしょうね。ほとんどのケースでFutureやTaskという具体型でよかったりするので、必要性がなければ過度な抽象化はさけたほうが無難。
  • ReaderTやTaskを使う方法は覚えておいて損はなさそう。

参考リンク