ScalaのEffを使ってDDDのUseCase層をいい感じに書いてみる
*Qiitaに掲載していたものと同じ内容をこちらに移転しました(作者は同じです)。
今回のサンプルコードはyu-croco/ddd_on_scalaに掲載していますので、気になる方は覗いてみてください。
経緯
Scala(PlayFramework) x DDDでアプリケーションを実装する際、UseCase層(Application層)を実装する際に辛さが出てくる。
何が辛いかと言うと、型のネストである。
というのも、
- UseCase層ではエンティティ操作の過程で仕様周りのバリデーションをやることになりEitherが出てくる
- 例:ハンターがモンスターから素材を剥ぎ取るためには、モンスターが既に死んでいる必要がある
- (PlayFrameworkだと特に)Repository層での呼び出してFutureが出てくる
そのため、UseCase層での各処理の型合わせが必然的に複雑になる傾向にある。
サンプル
例として、なんちゃってモンハンを想定して「ハンターがモンスターにダメージを与える」というユースケースを実装してみる。
*いろんな突っ込みがあると思うのですが、マサカリはヤメてください。
forの内包式でザッと書いてみるとこんな感じになる。
色々自前で置いているが細かいところは置いておいて、それぞれのfor内での文脈(型)に着目してもらいたい。
// UseCase層のメインメソッド
def run(hunterId: HunterId, monsterId: MonsterId): Future[Either[UCError, Future[Monster]]] =
for {
// Futureのレーン
hunter <- hunterRepository.findById(hunterId).toUCErrorIfNotExists("hunter")
monster <- monsterRepository.findById(monsterId).toUCErrorIfNotExists("monster")
hunterAttackDamage = HunterAttackService.calculateDamage(hunter, monster)
} yield for {
// Eitherのレーン
damagedMonster <- hunter.attack(monster, hunterAttackDamage).toUCErrorIfLeft()
} yield for {
// Futureのレーン
savedMonster <- monsterRepository.update(damagedMonster).raiseIfFutureFailed("monster")
} yield savedMonster
今回のケースではFuture -> Either -> Futureの構成となり、そのままsavedMonster
を返すと戻り値がFuture[Either[UCError, Future[Monster]]]
となる。
ただ、実際にはFuture[Either[UCError, Monster]]
にしたい。
こうして型合わせゲームが始まる。
またユースケースによってはFuture[Either[UseCaseError, Future[Option[Monster]]]]
とかになることもあり得るので、地獄感が溢れている。
Monad Transformerという希望
この「ネストした型どうしよう問題」に対して、Monad Transformerというのが活躍する。
Monad Transformerとは、ざっくりいうと「異なる種類のモナドインスタンスを合成して一つのモナドインスタンスとして扱えるようにするデータ型の総称」のことである。
要は、Either
やFuture
などを一つのモナドとして扱えるようにしてくれる代物である。
有名なライブラリだと、catsやscalazが既に出してくれている。例えばEitherTがそれにあたる。
魔法のようなことが出来るのだが、どうも欠点もあるようだ。
*画像はExtensible Effects with Scala/eff-with-scalaのp.14から拝借
Eff現る
上記の問題を解消するべく現れたのがEff(Extensible Effects)である。
Effに関する詳細は、参考
にリンクを貼っているので是非そちらを参照頂きたい。
githubのレポジトリとしては、atnos-org/effである。
大枠としては以下の資料がわかりやすい。
*画像はExtensible Effects with Scala/eff-with-scalaのp.18~20から拝借
Effを使って書き直す
Effを使って書き直すとどうなるかやってみる。
*できるだけ説明は試みるが、より適切かつ詳しい情報は公式のatnos-org eff Introductionを読んで頂きたい
Effでは上記の画像の説明の通り、モナドをスタックさせていく。
要は、effect
(Future
/Either
など、文脈をもつ型)をR
というeffectの集合として積み上げていく。
スタックをさせるためにはEffを使いたいメソッドに対して、
- 引数に型パラメータとしてスタックしたいeffectを
[R: hoge: fuga]
と指定する- hoge, fugaはスタックさせたいeffectに該当する
- 今回は
UCEither
とFuture
がそれに該当する
- 戻り値として
Eff[R, A]
という型に-
A
は最終的に返す型を表す - 今回は
Monster
がそれに該当する
-
型パラメータとして配置するeffectは、いくつかはeff側での組み込みが存在する(Futureに対応する_future
など)が、UCEither
のような自作のeffectの場合にはtype _hoge[R] = Hoge |= R
という感じのものを定義し、Rの仲間入りを果たす必要がある(以下スニペットのtype _ucEither[R] = UCEither |= R
)。
その後、forの中で既存の処理に対してeffを適応するためのメソッドを使ってeffectをスタックしていく。
import org.atnos.eff.future.{_future, fromFuture}
import org.atnos.eff.{|=, either, Eff}
// UseCase層用に自前のEitherがあるとする
type UCEither[T] = Either[UCError, T]
// effect(UCEither)をRのメンバーに加えてやる
type _ucEither[R] = UCEither |= R
// UCEitherとFutureをスタックして、Monsterを返す
// ここではプログラムを組み立てるだけであり、この段階ではまだ処理は実行されないので、programというメソッド名に変える
def program[R: _ucEither: _future](hunterId: HunterId, monsterId: MonsterId): Eff[R, Monster] =
for {
hunter <- fromFuture(hunterRepository.findById(hunterId).toUCErrorIfNotExists("hunter"))
monster <- fromFuture(monsterRepository.findById(monsterId).toUCErrorIfNotExists("monster"))
hunterAttackDamage = HunterAttackService.calculateDamage(hunter, monster)
damagedMonster <- either.fromEither(hunter.attack(monster, hunterAttackDamage).toUCErrorIfLeft())
savedMonster <- fromFuture(monsterRepository.update(damagedMonster).raiseIfFutureFailed("monster"))
} yield savedMonster
これでfor文一つで簡潔してしまった。
なんと美しいことか。
ちなみに、fromFutureとかで囲むのはちょっとダサいので、implicit classを用意してやるとより見栄えが良くなる。
import org.atnos.eff.future.{_future, fromFuture}
import org.atnos.eff.{|=, either, Eff}
object usecase {
implicit class FutureOps[T](futureValue: Future[T])(implicit ex: ExecutionContext) {
def toEff[R: _future]: Eff[R, T] = fromFuture(futureValue)
}
implicit class EitherUCErrorOps[T](eitherValue: Either[UseCaseError, T]) {
def toEff[R: _ucEither]: Eff[R, T] = either.fromEither(eitherValue)
}
}
def program[R: _future: _ucEither](hunterId: HunterId, monsterId: MonsterId): Eff[R, Hunter] =
for {
hunter <- hunterRepository.findById(hunterId).toUCErrorIfNotExists("hunter").toEff
monster <- monsterRepository.findById(monsterId).toUCErrorIfNotExists("monster").toEff
monsterAttackDamage = MonsterAttackService.calculateDamage(monster, hunter)
damagedHunter <- monster.attack(hunter, monsterAttackDamage).toUCErrorIfLeft().toEff
savedHunter <- hunterRepository.update(damagedHunter).raiseIfFutureFailed("hunter").toEff
} yield savedHunter
programメソッドによりeffectのスタックが完成したので、adapter層でそれを呼び出して実行する。
この際、スタックしたeffectを一つずつ実行させて、Future[Either[UCError, Monster]]
にしていく。
なお、実行順序は呼び出し側で自由に指定できる。
import org.atnos.eff.ExecutorServices
import org.atnos.eff.concurrent.Scheduler
import org.atnos.eff.syntax.either._
import org.atnos.eff.syntax.future._
// Rとしてスタックさせたいeffectをセットする(順番は気にしなくて良い)
type Stack = Fx.fx2[UCEither, TimedFuture]
def update() = Action.async(parse.json) { implicit request =>
// bodyのパースとかいろいろ...
// EffでFutureを使う際にはimplicitで置いてやる必要がある
implicit val scheduler: Scheduler = ExecutorServices.schedulerFromGlobalExecutionContext
useCase
// Effectのスタックを組み上げる
.program[Stack](hunterId, monsterId)
.runEither[UCEither]
// ここまでくると、Future[Either[UCError, Monster]]に変換が完了する
.runAsync
.flatMap {
case Right(monster) => Future.successful(monster)
case Left(ucError) => Future.failed(ucError)
}
.toJsonResponse
詳細な挙動はお手元の環境にeffを入れて確認して見ると良いと思います。
atnos-org eff introductionにも詳しく掲載されているので、そちらもどうぞ。
所感
- 型合わせゲームがこんなにも簡単に解決されるのは驚き!
- その分魔法が凄いので、内部実装を追ってもう少し詳細な挙動を把握してみたい
Discussion