Unipos engineer blog

Uniposの開発者ブログ

Scalaで作るPureでFunctionalなレイヤードアーキテクチャ

こんにちは、エンジニアの小紫です。
今携わっているプロジェクトで採用しているScalaでの実装テクニックを紹介します。

これはなに

レイヤードアーキテクチャにおけるドメイン層の実装から、関数型プログラミングの力を使って実装の都合を取り除いてピュアなドメイン実装を目指します。
そのリファクタリングの過程を解説していきます。
キーワード的にはDDD、レイヤードアーキテクチャMonadあたり。

ScalaMatsuri2018の@AoiroAoinoさんの発表に多大なる影響を受けています。ありがとうございます!

purely_functional_play_framework_application

解決したい問題

DDD + レイヤードアーキテクチャ(Clean Architectureとか含む)で開発しているときに普通にやってるとドメイン、例えばのRepositoryのコードは以下のようになるかと思います。

package domains.users
import scalikejdbc.DBSession
import scala.concurrent._

trait UserRepository {
  def findById(userId: UserId)(
    implicit ec: ExecutionContext, session: DBSession): Future[Option[User]]
}

UserRepository#findByIdを利用すれば非同期(Future)でOption[User]が返ってくる、というI/Fになっています。
ExecutionContextは非同期処理をする時の設定的なやつで、DBSessionScalikeJDBCにおけるトランザクション的なものを表現するオブジェクトです。
ドメイン層に書かれたUserRepositoryのI/Fに対してインフラ層から実装を差し込まれるので、具体的にどんな永続化層とどうやり取りするかはドメイン層では関与しません。
関与しないはずですが、ExecutionContextとかDBSessionという"実装の都合"が完全に入り込んでしまっているように見えます。
例えばMySQLみたいなRDBMSとやり取りするから遅いし非同期(Future)にしたいよね、さらにDBアクセスのための情報(DBSession)も必要だよね、と表明してしまっているかと。

こういった実装の都合への依存をドメイン層から取り除きたいというのが本記事のモチベーションになります。

なお、JDBC使ったI/OをFutureにするべきかどうかはここでは考えないことにします。 これについてはThreadPools - 2.6.x参照して下さい。

関数を使ったI/Fの変更

問題になっているコードを再掲。

trait UserRepository {
  def findById(userId: UserId)(
    implicit ec: ExecutionContext, session: DBSession): Future[Option[User]]
}

この中から取り除きたいimplicit引数となっているものを返り値の関数の引数としてリファクタしてみます。

trait UserRepository {
  def findById(userId: UserId): (ExecutionContext, DBSession) => Future[Option[User]]
}

これでUserRepository#findByIdは関数を返す関数となりました。関数型プログラミングですね。
さらにScalaのtype-aliasを使って書いてみると

trait UserRepository {
  type Query[A] = (ExecutionContext, DBSession) => Future[A]
  
  def findById(userId: UserId): Query[Option[User]]
}

とすることが出来ます。 何となくUserRepository#findByIdのI/Fはすっきりしたものの、Query[A]の定義が結局ドメイン層から脱していないのでこれだけでは見た目が変わった以上の意味がありません。

高カインド型を用いた抽象化

Scalaには高カインド型というものがあります。 簡単にいうと型コンストラクタを受け取る型コンストラクタのことで、その高カインド型を用いて先程のUserRepositoryを書き換えてみます。

trait UserRepository[F[_]] {
  def findById(userId: UserId): F[Option[User]]
}

UserRepositoryの型パラメータとしてFを受け取ってfindByIdの返り値を包みました。
先程までQueryとして表現されていたものがFとして抽象化されており、UserRepositoryのI/FからはExecutionContextDBSessionといった"実装の都合"が取り除かれてピュアなドメインになったと言えるかと思います。

このUserRepositoryを使った実装をしてみます。
まず、Userエンティティの実装が以下のようなものだとします。

sealed abstract case class User(id: UserId, name: UserName) {
  def updateName(newName: UserName): User = ???
}

sealed abstract case classについては↓を参照。
https://qiita.com/petitviolet/items/b6af2877f64ebe8fe312#comment-08e2ffe396177bf5a252

次に、例としてUserの名前を更新するアプリケーションを実装してみます。 UserRepositoryはimplicit引数で受け取ってDI出来るようにしておきます。

class UpdateUserNameApplication[F[_]](implicit userRepository: UserRepository[F]) {
  def run(userId: String, name: String) = {
    val userF: F[Option[User]] = userRepository.findById(UserId(userId))
    ???
}

userRepository.findByIdの返り値がF[_]になっていて、ここでF[_]に対して何も出来ずに詰んでしまいました。

高カインド型な型パラメータに合成性を追加する

UserRepository#findByIdF[_]に包まれた値を返してきていたためそこで処理が止まってしまいました。
なのでF[_]に合成性(composability)を追加したいところです。
オブジェクト指向的な発想だとF[_]が何かのインタフェースであるようにすればいいと考えるところですね。
しかし、そうすると標準ライブラリが提供する型、例えばTryEitherFutureといった型に対して無力になってしまいます。

そこで型クラス+モナドの出番です。 F[_]モナドであればmapflatMapが使えるようになるので後続の処理を合成出来るようになります。 さらに標準ライブラリについても型クラスを使えば後からでもMonadとして扱えます。

モナドを使えるようになるためのライブラリはいくつか選択肢がありますが、ここではscalaz/scalazMonadを使用します。

import scalaz.Monad

class UpdateUserNameApplication[F[_]](implicit userRepository: UserRepository[F], M: Monad[F]) {
  def run(userId: String, name: String) = {
    val userF: F[Option[User]] = userRepository.findById(UserId(userId))
    userF.flatMap { userOpt =>
      ???
    }
}

Monad[F]インスタンスがスコープ内にあればF[_]な値に対してmap, flatMapが使えるようになりました。
F[_]だと抽象的すぎて何も出来ないところにせめてモナドであるという制約を与えることで合成性を手に入れることに成功したといえます。
モナド便利。

Context Boundsによる依存の表明

ScalaにはContext Boundsという言語機能があります。
これを使って、先程のコードでコンストラクタインジェクションになっている箇所をリファクタリングしてみます。

object UserRepository {
  def apply[F[_] : UserRepository]: UserRepository[F] = implicitly
}

class UpdateUserNameApplication[F[_] : Monad : UserRepository] {
  def run(userId: String, name: String) = {
    val userF: F[Option[User]] = UserRepository[F].findById(UserId(userId))
    userF.flatMap { userOpt =>
      ???
    }
}

implicitパラメータで渡していたものをすべてContext Boundsで与えるようにしました。 これによってUpdateUserNameApplicationというクラスはUserRepositoryに依存しているということをContext Boundsを用いて表明することが出来るようになりました。

また、Context Boundsで与えられる型はimplicitlyを使ってスコープ内から引っ張ってこれますが、毎回implicitly使うのも見た目があまり良くないのでobject UserRepositoryを用意してプロキシ出来るようにしてあります。 implicitly[UserRepository[F]]と書くかUserRepository.apply[F]と書くかの違いだけなのでここは好みの範疇かと思います。

実装を与える

上記のUpdateUserNameApplicationの実装には抽象的なF[_]がある以上、具体的な実装はありませんし出来ません。(???は省略しているだけ) そこで、具体的な実装を与えるためにまずF[_]を確定させます。

初期の実装を改めて載せると↓。

trait UserRepository {
  def findById(userId: UserId)(
    implicit ec: ExecutionContext, session: DBSession): Future[Option[User]]
}

これを再現できるように、ExecutionContextDBSessionを受け取ってFuture[A]を返すような型をF[_]として扱うことにします。
用途としてはtype F[A] = (DBSession, ExecutionContext) => Future[A]で十分ですが、このF[_]は単なるFunction1に過ぎないため、F[_] : MonadというContext Boundsによる指定をしているところには適用することが出来ません。
そこでこの関数、単純化するとA => M[B]な形をしている関数をMonadとして扱えるようにするため、Scalazが提供しているKleisliを利用します。

type AsyncIO[A] = scalaz.Kleisli[Future, (DBSession, ExecutionContext), A]

Kleisli[M, A, B]は3つも型パラメータを受け取って複雑に見えるかも知れませんが、A => M[B]に対応しているだけなので分かってしまえばそこまで難しくはないかと。 それっぽいAsyncIOという名前をつけてみました。IOモナドは関係ありません。

このAsyncIOに対応したUserRepository[AsyncIO]を実装すると以下のようになります。

object UserRepositoryImpl extends UserRepository[AsyncIO] {
  // タプルから必要な情報をimplicitに取り出すヘルパ関数
  private implicit def _dbSession(implicit ctx: (DBSession, ExecutionContext)): DBSession = ctx._1
  private implicit def _ec(implicit ctx: (DBSession, ExecutionContext)): ExecutionContext = ctx._2
  
  // DBテーブルに対応するUser情報をドメインモデルに変換する
  private def dto2domain(dto: daos.User): User = 
    domain.User.apply(dto.id, dto.name, dto.createdAt)
  
  override def findById(id: UserId): AsyncIO[Option[domain.User]] = 
    scalaz.Kleisli { implicit ctx: (DBSession, ExecutionContext) =>
      Future {
        daos.User.findById(id.value) map dto2domain
      }
    }
  
  override def store(entity: User): AsyncIO[User] = 
    scalaz.Kleisli { implicit ctx =>
      ??? // 割愛
    }

}

UserRepositoryの依存を注入する

先程実装したUserRepositoryImplUserRepository[AsyncIO]型のインスタンスとして利用する箇所に注入します。
UserRepository[F]に依存しているコードは以下のようなものでした。

class UpdateUserNameApplication[F[_] : Monad : UserRepository] {
  def run(userId: String, name: String) = {
    val userF: F[Option[User]] = UserRepository[F].findById(UserId(userId))
    userF.flatMap { userOpt =>
      ???
    }
}

コントローラ(Akka-HTTPを使用)の実装の一部を抜き出すと以下のような形になります。

// 標準ライブラリのFutureに対応するMonadインスタンス
import scalaz.std.scalaFuture.futureInstance
implicit val userRepository: UserRepository[AsyncIO] = UserRepositoryImpl

def route: Route =
  (post & path("user" / "update") & entity(as[UpdateUserParam])) { param =>
    val f = DB futureLocalTx { s =>
      new UpdateUserApplication[AsyncIO]
        .execute(param)
        .run((s, executionContext))
    }
    onComplete(f) { ??? } // HTTPレスポンスを作る
  }

new UpdateUserApplication[AsyncIO]している箇所に、implicitで定義してあるuserRepository: UserRepository[AsyncIO]が注入される形となります。
また、AsyncIO[A]Kleisli[Future, (...), A]となっていてKleisliの各種合成関数(map/flatMap)を使う際にはFutureMonadインスタンスも必要になります。
そのため、scalaz.std.scalaFuture.futureInstanceをスコープ内でimportしておく必要がある点にも注意して下さい。

ちなみに、このように依存するオブジェクト、今回でいうとUserRepository[F]を型クラスとして扱ってDIするスタイルのことをTagless Finalと呼ぶらしいです。

Repository, Applicationと抽象的な型F[_]のまま扱ってきましたが、Controllerレイヤで初めて具体的な実装AsyncIOが登場し、実装の都合を意識するようなコードになっていることが分かるかと思います。
Monadの力を使うことでここまで抽象的に扱うことが出来てドメインビジネスロジックに集中することが出来て気持ちいいですね。

まとめ

モナドの力を使ってドメイン層の定義/実装から実装の都合、たとえばトランザクションや非同期処理の概念を追い出しました。
これによってドメイン層の定義/実装はまさにドメインに集中出来るようになりました。
また結果としてドメイン層に依存する他の層も同様に実装の都合を排除することになり、(UseCase|Application|Service)層もピュアなロジックを書けるようになりました。

改めてコードを一部載せておくと、ドメイン層、特にRepositoryはもともと↓のようなものでした。

trait UserRepository {
  def findById(userId: UserId)(
    implicit ec: ExecutionContext, session: DBSession): Future[Option[User]]
}

これを型パラメータを使って実装の都合を追い出しました。

trait UserRepository[F[_]] {
  def findById(userId: UserId): F[Option[User]]
}

そしてApplication層は↓のように。

class UpdateUserNameApplication[F[_] : Monad : UserRepository] {
  def run(userId: String, name: String) = {
    val userF: F[Option[User]] = UserRepository[F].findById(UserId(userId))
    userF.flatMap { userOpt =>
      ???
    }
}

このコードの中に外部に依存するものや実装の都合を意識させるものは何もなく、ピュアなコードとして表現が出来ています。
実装の都合は全て(Controller|Adapter|Infra)層に寄せることが出来ました。

MonadやTagless Finalスタイルをしっかり理解して使うのはやや難しく感じるかも知れませんが、コードだけを見ればそこまで難しいことではない...と思います。