前置き
ScalaでメジャーなDBアクセスライブラリのslickを使用する環境で、ドメイン層とアプリケーション層でいかにしてトランザクションをあつかうかについてのメモです。
それぞれのトランザクション
DDDでのトランザクション
複数の集約にまたがってアトミックな処理が求められる場合にはレポジトリ内でセッションが完結しません。DDDの定石ではアプリケーション層でトランザクションを管理することになります。
このとき広く使われているのは、スレッドローカルなトランザクションやセッションを引数で引き回して実現する方法です。
// アプリケーション層
DB.transaction { session =>
domainServiceA.do(a, session)
domainServiceB.do(b, session)
}
slickのトランザクション
次にslickのトランザクションの解説です。
細かいことは slick-doc-ja 3.0 — データベースI/Oアクション を見ていただきたいですが、クエリーはその場で実行されるのではなく組み立てられたアクションを返すようになっており、それらを合成した一つのアクションとしてまとめて実行することでトランザクションを利用できます。
val action = for {
_ <- domainServiceA.do(a)
- <- domainServiceB.do(b)
} yield ()
DB.run(action)
slickを使ってアプリケーション層でトランザクションをあつかう場合、レポジトリやドメインサービスでslickのアクションを返すようにする必要が出てきます。
ドメイン層でslickアクションを隠蔽する
インフラ層での実装手段であるslickの型がドメイン層に出てくる、つまりドメイン層がslickに依存するのは好ましくありません。
ドメイン層でslickアクションを隠蔽したいのでそうしましょう。やり方を考えていきます。
ラッパーを使う
おそらく多くの言語で使える素直な方法です。以下のような形でしょうか。
// ドメイン層
trait Action[R] {
...
}
class DomainServiceA(repository: RepositoryA) {
def do(a: A): Action[Unit] = ...
}
class DomainServiceB // 省略
// インフラ層
class SlickAction[R](val underlying: DBIOAction[R, NoStream, Effect.All]) extends Action[R] {
...
}
class RepositoryAImpl {
def find(id: Id): Action[A] = {
SlickAction(Table.A.filter(...).result.headOption.map { ... })
}
}
// アプリケーション層
val action = for {
_ <- domainServiceA.do(a)
- <- domainServiceB.do(b)
} yield ()
DB.run(action.asInstanceOf[SlickAction[Unit]].underlying)
げぇ! asInstanceOf
!!!
もちろん match
を使っても良いですし、具体的な型に依存した処理を書く場所もインフラ層にやらせてもいいかもしれません。しかし、望んだ型でなかったときは実行時例外を投げるしかありません。良いやり方とは言えないようです。
型引数と型クラスを使う
そもそもやりたいのはドメイン層でアクションの具体的な型に触れたくないだけです。そこでアクションを型引数にしてしまいます。また型引数ならわざわざラッパーを使わずとも型クラスで扱うことができます。だいたい以下のようになります。
// ドメイン層
trait IO[F[_]] {
// 型クラス
// map, flatMap, filter, withFilter やその他必要な操作を抽象メソッドを定義
}
class DomainServiceA[F[_]](repository: RepositoryA[F])(implicit F: IO[F]) {
def do[R](a: A): F[R] = ...
}
class DomainServiceB // 省略
// インフラ層
package object infra {
type SlickIOAction[+R] = DBIOAction[R, NoStream, Effect.All]
implicit object SlickIO extends IO[SlickIOAction] {
// 型クラスのインスタンス
// map, flatMap, filter, withFilter などを実装する
}
}
class RepositoryAImpl extends RepositoryA[SlickIOAction] {
...
}
// アプリケーション層
val action = for {
_ <- domainServiceA.do(a)
- <- domainServiceB.do(b)
} yield ()
DB.run(action)
型引数と型クラスによりドメイン層ではインフラ層での具体的な型に触れずにすみ、具体的な型を知っているアプリケーション層ではキャストなしに触ることができます。
なお、DBIOAction
は概ね scala.concurrent.Future
と同じ操作をもっているので Future
の用の型クラスのインスタンスを用意すれば、ドメイン層のテストでサードパーティライブラリに依存せずにすみます。