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
は非同期処理をする時の設定的なやつで、DBSession
はScalikeJDBCにおけるトランザクション的なものを表現するオブジェクトです。
ドメイン層に書かれた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からはExecutionContext
やDBSession
といった"実装の都合"が取り除かれてピュアなドメインになったと言えるかと思います。
この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#findById
がF[_]
に包まれた値を返してきていたためそこで処理が止まってしまいました。
なのでF[_]
に合成性(composability)を追加したいところです。
オブジェクト指向的な発想だとF[_]
が何かのインタフェースであるようにすればいいと考えるところですね。
しかし、そうすると標準ライブラリが提供する型、例えばTry
やEither
、Future
といった型に対して無力になってしまいます。
そこで型クラス+モナドの出番です。
F[_]
がモナドであればmap
やflatMap
が使えるようになるので後続の処理を合成出来るようになります。
さらに標準ライブラリについても型クラスを使えば後からでもMonadとして扱えます。
モナドを使えるようになるためのライブラリはいくつか選択肢がありますが、ここではscalaz/scalazのMonad
を使用します。
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]] }
これを再現できるように、ExecutionContext
とDBSession
を受け取って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の依存を注入する
先程実装したUserRepositoryImpl
をUserRepository[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)を使う際にはFuture
のMonadインスタンスも必要になります。
そのため、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スタイルをしっかり理解して使うのはやや難しく感じるかも知れませんが、コードだけを見ればそこまで難しいことではない...と思います。