🙆

Slickをデータベースのバックエンドとして採用する時のインターフェース

2021/12/23に公開

はじめに

SlickはScalaのデータベースライブラリとして利用される最も著名なライブラリの1つです。
Slickが提供する抽象でライブラリ・ユーザが特に意識するのは 1. クエリ・インターフェース と 2. DBIO、3. DBIOのインタープリタ(Database) だと思いますが、3層アーキテクチャ(アプリケーション層、ドメイン層、インフラストラクチャ層)を用いるときにドメイン層でこうしたデータベースライブラリのインターフェースを隠蔽したいというのは、開発チームの要求として、よくある要求だと思います。
リポジトリ・パターンを採用しているとして、リポジトリのインターフェースがSlickのインターフェースを隠蔽する方法として考えられるお手軽な選択肢について幾つか考えてみます。

1. Future

FutureはScala標準ライブラリで提供されている非同期で返される値を表す抽象で、これをリポジトリのインターフェースにする、というのは最も簡単な選択肢なのではないかと思います。

trait OrderRepository {
  def save(order: Order): Future[Unit]
  def findById(id: OrderId): Future[Option[Order]]
}
class OrderRepositoryImpl(db: Database)(implicit ec: ExecutionContext) extends OrderRepository {
  import Tables._
  def save(order: Order): Future[Unit] =
    db.run(orders += order).map(_ => ())
  def findById(id: OrderId): Future[Option[Order]] =
    db.run(orders.filter(_.id === id).result.headOption)
}

しかし、この方法は、異なるリポジトリを跨いでデータベーストランザクションを使用することができないという大きな欠点があります。
ドメイン駆動設計を採用しているプロジェクトでは、リポジトリのインターフェースでsaveするのは集約(=トランザクションの境界となるオブジェクト)のみなので、「これでも構わない」と考えることはできなくもないですが、そのように割り切れるプロジェクトは稀でしょう。
また、リポジトリのインターフェースを跨いでselect for updateすることができないので、業務要件を満たすために複雑なビジネスロジックをリポジトリの実装に紛れ込ませる現場仕事をしてしまいがちです。

2. Free

関数型プログラミングライブラリが提供する抽象をドメイン層で使用することを許可するのであれば、Slickが提供するDBIOのスマートコンストラクタは決して数が多いわけではないので、必要なものをピックアップしてFreeモナドでラップするのは難しくありません。

// 以下はドメイン層のインターフェース
sealed trait DBIOA[+A]

case class Pure[+A](value: A) extends DBIOA[A]
case class Fail[+A](t: Throwable) extends DBIOA[A]
case Effect[F[_], A](io: F[A]) extends DBIOA[A]

// これはSlickが提供するDBIOとは異なる
type DBIO[A] = Free[DBIOA, A]

object DBIO {
  def pure[A](value: A): DBIO[A] = liftF(DBIOA.Pure(value))
  def failed[A](t: Throwable): DBIO[A] = liftF(DBIOA.Fail(value))
  def effect[F[_]]: EffectPartiallyApplied[F] = new EffectPartillyApplied[F] // Partially Applied Type
  final class EffectPartiallyApplied[F[_]](private val dummy: Boolean = false) extends AnyVal {
    def apply[A](io: F[A]): DBIO[A] = liftF(DBIOA.Effect(io))
  }
}

trait Interpreter {
   def run[A](dbio: DBIO[A]): Future[A]
}

// 以下はインフラ層のインタプリタ
// Slick.DBIOのMonadインスタンスはどこかで自作する
class DBInterpreter(implicit F: Monad[slick.DBIO]) extends Interpreter {

   private def compiler: DBIO ~> slick.DBIO = {
     case Pure(value) =>
       slick.DBIO.successful(value)
     case Fail(t) =>
       slick.DBIO.failed(value)
     case Effect(io) =>
       io.asInstanceOf[slick.DBIO[A]]
   }

   def run[A](dbio: DBIO[A]): Future[A] =
     db.run(dbio.foldMap(compiler).transactionally)

}

// 以下はリポジトリの実装
class OrderRepositoryImpl(implicit ec: ExecutionContext) extends OrderRepository {
  import Tables._

  def save(order: Order): DBIO[Unit] =
    DBIO.effect(orders += order: slick.DBIO[Int]).map(_ => ())

  // 簡単のため、select for updateするようなインターフェースの設計は読者への課題とします
  def findById(id: OrderId): DBIO[Option[Order]] =
    DBIO.effect(orders.filter(_.id === id).result.headOption: slick.DBIO[Option[Order]])

}

ただし、これは本当にラップしているだけです。実際のところ、SlickのDBIOはライブラリの中で実装されているフリーモナドなので、Slickのフリーモナドをcatsのような関数型プログラミングライブラリのフリーモナドで包んでいるような形になっています。
SlickのDBIO自体が副作用を起こすことは無いので、そもそもDBIOをドメイン層で使ってよいというルールにする、という選択肢も考慮していいのではないかと思います。

3. 高カインド型を使って、型コンストラクタを抽象化する

これも関数型プログラミングライブラリを使う方法です。
ドメイン層の戻り値の型コンストラクタ(FutureDBIOのこと)パラメータ化(= 高カインド型にする)し、モナドであることだけを要求して、インフラ層でのみSlickのDBIOを使用します。

abstract class OrderRepository[F[_]: Monad] {
  def save(order: Order): F[Unit]
  def findById(id: OrderId): F[Option[Order]]
}

abstract class Interpreter[F[_]: Monad] {
  def run[A](io: F[A]): Future[A]
}
class OrderRepositoryImpl(implicit F: Monad[slick.DBIO]) extends OrderRepository[slick.DBIO] {
  import Tables._

  def save(order: Order): slick.DBIO[Unit] =
    (orders += order).map(_ => ())

  // こちらも、select for updateするようなインターフェースの設計は各自考えてみてください
  def findById(id: OrderId): slick.DBIO[Option[Order]] =
    orders.filter(_.id === id).result.headOption

}

class DBInterpreter(db: Database)(implicit F: Monad[slick.DBIO]) extends Interpreter[slick.DBIO] {

   def run[A](dbio: slick.DBIO[A]): Future[A] =
     db.run(dbio.transactionally)

}

こちらの方法は、CQRSをアーキテクチャとして採用している場合に、リードのモデルに対してはインタプリタを使用せず、最初からFutureを返すような実装をすることも可能で、柔軟性がありますが、F[_]のようなシグネチャに対する慣れは必要になります。

所感・まとめ

Slickのプロジェクトでは、先に述べたように、リポジトリのインターフェースをFutureにしてしまうと部分的なエラーに弱くなってしまう[1]のですが、「Scalaさん、はじめまして」のプロジェクトでは割とやってしまいがちなのではないかと思います。
本稿では、この問題への対処策となる、フリーモナドを使う方法と高カインド型を使う方法を紹介しました。ただし、これらの方法はいずれも関数型プログラミングライブラリを必要とするので、この敷居を高く感じる場合は、少しダーティーな(ドメイン層をSlickに依存させ、SlickのDBIOを使用する)アーキテクチャを採用することをお勧めします。DBIOは本質的にフリーモナドでそれ自体には副作用は無いからです。

参考

脚注
  1. Slickではなくてもトランザクションを伴うクエリを並列で実行するような処理を書けてしまうとデッドロックを起こすような処理を実装してしまいそうですが ↩︎

Discussion