blog.ryota-ka.me

ユースケース層が投げうるエラーの型を「量化したopen union」にしておけば複数のユースケースを合成したときに上の層でエラーハンドリングが楽にできて最高です!

この記事はHaskell Advent Calendar 2020 20日目の記事です.

TL; DR#

タイトル

問題設定#

ユースケース層とサーヴァ層が存在するWebアプリケーションを考える.サーヴァ層はユースケース層を呼び出すことができるが,ユースケース層はサーヴァ層について無知でなければならない.

+----------------+
|  server layer  |
+-------+--------+
        |  depends on
+-------v--------+
| use case layer |
+----------------+

このようなアプリケーションのAPIリクエストハンドラにおいて,複数のユースケースを合成して呼び出すケースを例に挙げ,それぞれのユースケースが投げうるエラーをうまく扱うopen unionを用いたテクニックを紹介する.

今回はextensible effectsを用いてユースケース層を記述するが,特にextensible effectsに固有の話というわけではない.tagless finalでも,ExceptTや単なるEitherでも同じテクニックが使えるだろう.

extensible effectsのライブラリとしてextensible-skeletonを用いる.

Erroreffect#

ユースケース層はエラーを投げることができるものとする.下準備として,エラーを投げるためのError effectを定義しておく.

ユースケース#

引数で受け取ったIDに対応するTalentエンティティを返すユースケースであるfindTalentというユースケースがあるとしよう.ただし,対応するTalentが見付からなかった場合にはTalentNotFoundエラーを送出する.

findTalentとよく似たfindTagユースケースも準備しておく.

サーヴァ層からユースケースを呼び出す#

これら2つのユースケースを合成し,サーヴァ層から呼び出す場面に焦点を当てる.

ここでHandlerは,APIのリクエストハンドラを書くための言語であり,runUseCaseは,ユースケース記述言語からHandler言語への解釈を与える関数とする.ただし,Error effectはEitherとして解釈する.

しかし,これはうまくいかない.findTalentが投げうるエラーの型はTalentNotFoundである一方,findTagが投げうるエラーの型はTagNotFoundであり,これらが一致しないからだ.これは本質的には(ee'が異なる型であるときに)Either eEither e'が組み合わせられない問題と同質である.

open union#

そもそも一般に,あるユースケースが投げたいエラーが1種類で事足りるとは限らない.ユースケースによっては,あらかじめ宣言した複数の種類のエラーのうち,状況に応じてどれかひとつを投げたい,という場合もあるだろう.このような欲求を満たすため,まずは投げるエラーをopen unionに埋め込むことにしよう.今回は既にextensible packageを使っているので,extensibleが提供するextensible sumを使うことにする.

また,findTagユースケースにも同様の変更を加える.

投げられうるエラーを量化する#

エラーをopen unionで扱うだけでは,エラーの型が一致しないという当初の問題が解消されるわけではない.OneOf '[TalentNotFound]OneOf '[TagNotFound]は異なるので当然である.そこで,OneOfの引数を「少なくともTalentNotFoundを含む任意のリスト」という風に量化する.

新たな型変数errorsを導入し,エラーの型としてOneOf errorsを用いるように変更を加えた.

findTagが投げうるエラーについても同様に,少なくともTagNotFoundを含む任意のリストという風に量化してやる.

こうすれば,handleErrors関数は以下のように実装できる.ここにおいて,findTalentおよびfindTagのシグネチャ中の型変数errorsはいずれも'[TagNotFound, TalentNotFound]に解決される.

handleErrors
    :: OneOf '[TagNotFound, TalentNotFound]
    -> Handler Response
handleErrors = match
    $  Match (\(Identity (TagNotFound _)) -> throw NotFound)
    <: Match (\(Identity (TalentNotFound _)) -> throw NotFound)
    <: nil

要件が変更され,合成されたユースケースの最後で呼び出されていたcreateTaggingTagAlreadyAttachedToTalentエラーやTooManyTagsAttachedToTalentエラーを投げるようになったとしよう.その場合でも,パターンマッチを増やすだけで対応できるため,拡張性にも富んでいる.

handleErrors
    :: OneOf '[TagNotFound, TalentNotFound, TagAlreadyAttachedToTalent, TooManyTagsAttachedToTalent]
    -> Handler Response
handleErrors = match
    $  Match (\(Identity (TagNotFound _)) -> throw NotFound)
    <: Match (\(Identity (TalentNotFound _)) -> throw NotFound)
    <: Match (\(Identity (TagAlreadyAttachedToTalent _)) -> pure Response { ok = True }) -- return 2xx for idempotent requests
    <: Match (\(Identity (TooManyTagsAttachedToTalent _)) -> throw Conflict)
    <: nil

広告#

HERP広告

この記事はHERP勤務中に書かれた。

HERPは本物のHaskellプログラマーを募集しています。

コード#

主要な部分のみを抜粋した不完全なコードである.GHC言語拡張や,細かい関数・データ型の定義などは適宜補ってほしい.また,本文中で最初に定義したError effectは,量化されたopen unionをエラーとして投げることを前提したUseCaseError effectで置き換えている.