昔書いた記事ですが、今回は続編です。
DDDのリポジトリのインターフェイスをどのように設計すべきか
過去の実装は一旦捨てて書き直してみました。
前回と比べて改善した点は以下です。
- 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を使う方法は覚えておいて損はなさそう。