Help us understand the problem. What is going on with this article?

ScalaのEffを使ってDDDのUseCase層をいい感じに書いてみる

経緯

Scala(PlayFramework) x DDDでアプリケーションを実装する際、UseCase層(Application層)を実装する際に辛さが出てくる。
何が辛いかと言うと、型のネストである。

というのも、

  • UseCase層ではエンティティ操作の過程で仕様周りのバリデーションをやることになりEitherが出てくる
    • 例:ハンターがモンスターから素材を剥ぎ取るためには、モンスターが既に死んでいる必要がある
  • (PlayFrameworkだと特に)Repository層での呼び出してFutureが出てくる

そのため、UseCase層での各処理の型合わせが必然的に複雑になる傾向にある。

サンプル

例として、なんちゃってモンハンを想定して「ハンターがモンスターにダメージを与える」というユースケースを実装してみる。
*いろんな突っ込みがあると思うのですが、マサカリはヤメてください。

forの内包式でザッと書いてみるとこんな感じになる。
色々自前で置いているが細かいところは置いておいて、それぞれのfor内での文脈(型)に着目してもらいたい。

useCase層
// 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とは、ざっくりいうと「異なる種類のモナドインスタンスを合成して一つのモナドインスタンスとして扱えるようにするデータ型の総称」のことである。

要は、EitherFutureなどを一つのモナドとして扱えるようにしてくれる代物である。

有名なライブラリだと、catsやscalazが既に出してくれている。例えばEitherTがそれにあたる。

魔法のようなことが出来るのだが、どうも欠点もあるようだ。
スクリーンショット 2020-08-28 20.05.07.png
*画像はExtensible Effects with Scala/eff-with-scalaのp.14から拝借

Eff現る

上記の問題を解消するべく現れたのがEff(Extensible Effects)である。
Effに関する詳細は、参考にリンクを貼っているので是非そちらを参照頂きたい。
githubのレポジトリとしては、atnos-org/effである。

大枠としては以下の資料がわかりやすい。
スクリーンショット 2020-08-28 20.08.40.png
スクリーンショット 2020-08-28 20.08.55.png
スクリーンショット 2020-08-28 20.09.01.png
*画像はExtensible Effects with Scala/eff-with-scalaのp.18~20から拝借

Effを使って書き直す

Effを使って書き直すとどうなるかやってみる。
*できるだけ説明は試みるが、より適切かつ詳しい情報は公式のatnos-org eff Introductionを読んで頂きたい

Effでは上記の画像の説明の通り、モナドをスタックさせていく。
要は、effectFuture/Eitherなど、文脈をもつ型)をRというeffectの集合として積み上げていく。

スタックをさせるためにはEffを使いたいメソッドに対して、

  • 引数に型パラメータとしてスタックしたいeffectを[R: hoge: fuga]と指定する
    • hoge, fugaはスタックさせたいeffectに該当する
    • 今回はUCEitherFutureがそれに該当する
  • 戻り値として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をスタックしていく。

UseCase層
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を用意してやるとより見栄えが良くなる。

UseCase層(改良)
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]]にしていく。
なお、実行順序は呼び出し側で自由に指定できる。

Adapter層
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にも詳しく掲載されているので、そちらもどうぞ。

また、今回のサンプルコードはyu-croco/ddd_on_scalaに掲載していますので、気になる方は覗いてみてください。

所感

  • 型合わせゲームがこんなにも簡単に解決されるのは驚き!
  • その分魔法が凄いので、内部実装を追ってもう少し詳細な挙動を把握してみたい

参考

yu-croco
backendエンジニアです。仕事ではAWS, Scala on DDDが中心で、たまにRaspberry Piも触ります。発信内容は個人の見解です。
koska
原価計算をテクノロジーで刷新する
https://www.koska.jp/
Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
Comments
No comments
Sign up for free and join this conversation.
If you already have a Qiita account
Why do not you register as a user and use Qiita more conveniently?
You need to log in to use this function. Qiita can be used more conveniently after logging in.
You seem to be reading articles frequently this month. Qiita can be used more conveniently after logging in.
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
ユーザーは見つかりませんでした