🎄

障害を防ぐための関数型エラーハンドリング(後編)

2024/12/24に公開

アドベントカレンダー24日目です。

前回:障害を防ぐための関数型エラーハンドリング(前編)

前回は、主に try-catch によるエラーハンドリングは、様々なデメリットがあることを示しました。
今回の記事では、関数型がなぜ重要なのかを掘り下げてから、実際のKotlinでのDDDスタイルのコード例を見ていきたいと思います。

なぜ関数型が重要なのか

関数型の特徴を用いると、エラーハンドリングがより安全になります。特に、純粋性と全域性によって、予測可能性と堅牢性がもたらされます。

全域性、全域関数と部分関数

全域性(totality) とは、関数がその定義域内のすべての入力に対して、必ず対応する値を返す性質を指します。全域的な関数は必ず値を返すため、プログラムが停止しないことを保証します(プログラムが停止する=値を返さない)。
全域性をもつ関数を 全域関数(total function) と呼び、部分的には全域な関数を 部分関数(partial function) と呼びます。部分関数は、ある値を与えたときに、結果としての値が返らず例外が発生してしまうような関数になります。

全域性のエラーハンドリングへの影響

部分関数はいわゆる例外を発生させてしまうような関数であり、安全なエラーハンドリングが難しくなります。それに対し、全域関数を用いるとエラーハンドリングにおいて次のようなメリットがあります。

  • すべてのケースを網羅する安全性
    全域性により、すべての入力ケースが関数内で適切に処理されます。これにより、未定義の動作や例外のスローが防止されます。

  • 停止性の保証
    全域的な関数は計算が必ず終了するため、エラー状態でもプログラムがハングアップする心配がありません。これにより、エラー状態を安全に管理できます。

全域関数の例

除算は 0 で割ることはできず、その結果は未定義です。下記例では0が与えられた場合は DivisionByZero という値を返すことで全域性を確保しています。

fun safeDivide1(a: Int, b: Int): Either<DomainError, Int> =
    if (b == 0) Either.Left(DomainError.DivisionByZero)
    else Either.Right(a / b)

純粋性

純粋性(purity) とは、関数が副作用を持たず、同じ入力に対して常に同じ出力を返す性質を指します。純粋(pure) な関数は、プログラムの動作を予測可能にし、テストやデバッグを容易にします。
それに対し、さきほどと同じように例外を発生させたり、あるいはデータベースへのアクセスやログの出力等の副作用を発生させる関数を、不純(inpure) な関数といいます。

エラーハンドリングへの影響

純粋な関数は、様々なメリットがありますが、特にエラーハンドリングにおいては以下のメリットがあるといえるでしょう。

  • 副作用がないことによる安全性
    純粋な関数では、副作用を伴うエラー状態(例: ログ出力や例外スロー)を持たないため、関数の振る舞いが予測可能になります。これにより、エラーの原因を特定しやすくなります。
  • 参照透過性の維持
    純粋性により、関数の出力が常に入力だけに依存するため、関数呼び出しをその結果に置き換えてもプログラムの動作が変わりません。これにより、エラーハンドリングを含むコードの安全性が向上します。

純粋関数の例

以下の例では、0で除算をしようとした場合に、例外を投げるのではなく、例外をResult型の値として返して副作用を発生させないようにしています。これにより、コントロールフローの中断がなくなり、例外発生時の参照透過性が保たれ、関数の振る舞いが予測可能になります。

fun safeDivide2(a: Int, b: Int): Result<Int> {
    return if (b == 0) {
        Result.failure(IllegalArgumentException("Division by zero"))
    } else {
        Result.success(a / b)
    }
}

純粋性と全域性がもたらす利点

純粋性により関数の動作が予測可能になり、全域性によりすべてのケースが確実に処理されます。この組み合わせは、信頼性の高いエラーハンドリングを提供します。
さらに、副作用がないため、エラー状態をデータとして安全に扱うことができ、例外の乱用を避けることができます。また、全域性によりエラーケースを網羅的に管理できるため、コードの見通しが良くなります。
また、エラーに対してはテストがより重要になりますが、純粋性があることによって、モックや外部依存なしで関数をテストできます。さらに、全域性により、すべての入力ケースに対してテストを行うことが可能です。

Kotlin + Arrow + KtorでのDDDスタイルでの実装例

以下の方針で、サンプルを構築してみます(前回記事参照)。

  • アダプタ層(インフラ層)の内部はResult型を使う
    • アダプタ層固有の詳細なエラーはユースケース層からは不可知(agnostic)とする
      • 外部サービスとの連携等でハンドリングしきれないエラーが出てくる可能性があるため
      • ドメイン・ユースケースからは、アダプタ層に依存させたくないため(Clean Architectureの原則)
  • ユースケース・ドメイン層と連携するインタフェース部分はEither型を使う
    • 精緻にエラーをハンドリングできるようにするため

エラーのモデリング

まず、エラーをモデリングします。エラーは代数的データ型を使ってモデリングしていきます。
Kotlinでは下記コードのように sealed を使って、指定されたサブクラスのみを許可する制約付きの型階層を作ることで、これを実現します。

エラーモデルを作るときのポイントは以下のとおりです

  • ユースケースあるいはドメイン固有のエラーを示す型を作る(いわゆる業務エラー)
  • システムエラーを示す共通の型を作る(いわゆるシステムエラー)
  • 上記の型を包含する上位型を作る

このとき、上位型はアプリケーション全体にスコープを広げると型が大きくなりすぎるため、コントローラ程度のユースケースをグルーピングできる粒度にすると良いでしょう。


// 全体の上位エラー
sealed interface UserDomainError

// バリデーションに関するエラー
sealed interface ValidationError : UserDomainError
data class EmptyFieldUpdate(val description: String) : ValidationError
data class IncorrectInput(val errors: NonEmptyList<InvalidField>) : ValidationError {
  constructor(head: InvalidField) : this(nonEmptyListOf(head))
}

// ユーザー登録ユースケース固有のエラー
sealed interface UserRegistrationError : UserDomainError
data class UserNotFound(val property: String) : UserDomainError
data class EmailAlreadyExists(val email: String) : UserRegistrationError

// システムエラー
data class SystemError(val cause: Throwable) : UserDomainError

ドメイン層

ドメイン層では、ユーザを扱うモデルを定義します。
RepositoryはUseCase内で使うため、各関数の戻り値の型はEitherとしています。
この戻り値を(Kotlin標準の)Result型にしてしまうと、精緻なエラーハンドリングをしようとしたときに、UseCase内で例外の中身を検査しないといけなくなります。すると、UseCaseがインフラ層の詳細な例外を認知しなければいけなくなり、例外を通じた依存が発生してしまいます。
そのため、Repository内である程度、UseCaseに通達するべきエラーを整えておきます。なお、モデリングしたエラーのうち、トップ階層のエラー型を指定するのではなく、必要最小限のスコープのエラー型に絞っておけば、UseCaseで不要なエラーをハンドリングする必要がなくなり、型でどのエラーが起こり得るのかを明確に示せます。

data class User(val id: String, val email: String, val name: String)

interface UserRepository {
    fun save(user: User): Either<UserRegistrationError, User>
    fun findByEmail(email: String): Either<UserRegistrationError, User?>
}

ユースケース層

ユースケース層では、ドメイン層で定義したモデルを使ってユーザ登録の処理を実装します。
Repositoryは Either 型を返すため、そのハンドリングをUseCase内で行います。このとき、Repositoryから受け取る Either 型はflatMap操作(モナドのbind操作)によってエラー時はそのままUseCaseの戻り値としてControllerに返却されることになります。本記事では詳しく解説しませんが、Either自体はモナドであり、複数のRepositoryやServiceを呼び出す場面等で、モナディックな操作によって統一的にエラーをハンドリングできるという利点もあります。

class RegisterUserUseCase(private val userRepository: UserRepository) {

    fun execute(email: String, name: String): Either<UserRegistrationError, User> {
        if (!email.contains("@")) return UserRegistrationError.InvalidEmail.left()

        return userRepository.findByEmail(email).flatMap { existingUser ->
            if (existingUser != null) {
                UserRegistrationError.EmailAlreadyExists.left()
            } else {
                val newUser = User(id = java.util.UUID.randomUUID().toString(), email = email, name = name)
                userRepository.save(newUser)
            }
        }
    }
}

アダプタ層(インフラ層)

アダプタ(インフラ)層では、Repositoryの実装を定義しています。今回は簡易な実装を用意しましたが、実際にデータベースや外部サービス等にアクセスして例外が発生しうる状況になった場合、UseCaseに対する戻り値に対しては、その型を Either に整えておきます。例外をそのままUseCaseに投げてしまいたくなるかもしれませんが、堅牢さのために、ユースケース層・ドメイン層の純度を高い状態に保つことを優先します

Repository:

class InMemoryUserRepository : UserRepository {
    private val users = mutableListOf<User>()

    override fun save(user: User): Either<UserRegistrationError, User> {
        users.add(user)
        return user.right()
    }

    override fun findByEmail(email: String): Either<UserRegistrationError, User?> {
        val user = users.find { it.email == email }
        return user.right()
    }
}

Controllerでは、UseCaseからの戻りとして Either型の値を処理します。ここまでの材料によってUseCaseが返すエラーの種類が型で明確になっているため、あとはユーザーへのエラー通知や業務処理のリトライ等、エラーの内容に応じた精緻なハンドリングを実装します。
以下のサンプルコードではHTTPのレスポンスコードをハンドリングしているだけですが、エラー型にエラーコードを持たせて、(JSON形式等で)フロント側に返し、フロントでエラーコードに応じた丁寧なメッセージを表示するのも良いでしょう。

Controller:

class UserController(private val registerUserUseCase: RegisterUserUseCase) {
    suspend fun register(call: ApplicationCall) {
        val request = call.receive<RegisterUserRequest>()
        val result = registerUserUseCase.execute(request.email, request.name)

        when (result) {
            is Either.Left -> when (result.value) {
                UserRegistrationError.EmailAlreadyExists -> call.respond(HttpStatusCode.Conflict, mapOf("error" to "Email already exists"))
                UserRegistrationError.InvalidEmail -> call.respond(HttpStatusCode.BadRequest, mapOf("error" to "Invalid email"))
            }

            is Either.Right -> call.respond(HttpStatusCode.Created, result.value)
        }
    }
}

Application(Main)

起動エントリポイントは次のようになります。簡単のために依存注入(DI)は使っていませんが、使うとすればKotlinであれば軽量なKoin等がおすすめです。

fun main() {
    val userRepository = DatabaseUserRepository()
    val registerUserUseCase = RegisterUserUseCase(userRepository)
    val userController = UserController(registerUserUseCase)

    embeddedServer(Netty, port = 8080) {
        install(ContentNegotiation) {
            // JSONサポート等をインストール
        }
        install(StatusPages) {
            exception<Throwable> { cause ->
                call.respond(HttpStatusCode.InternalServerError, mapOf("error" to cause.message))
            }
        }

        routing {
            post("/register") {
                userController.register(call)
            }
        }
    }.start(wait = true)
}

data class RegisterUserRequest(val email: String, val name: String)

注意点

層の間のインタフェースを型で定義するということは、層の間に境界を定めるということになります。
そのため、境界を行き来するために、型の読み替えが必要になります。
今回の例はClean Architecture文脈での関数型エラーハンドリングを示しました。
もう少し小規模な場面での適用や、局所的に精緻なエラーハンドリングをする場合は、また違ったコードの形になると思います。

今回お話しなかったこと&告知

今回取り扱った例外は副作用の一種であり、この副作用をさらに抽象的に扱う方法があります。それがFreeモナドやTagless-finalによるアプローチです。
今回の記事ではお伝えしきれませんでしたが、副作用をより抽象レベルでコントロールし、設計やモデリングに昇華させた話を、来年春にインドで開催される GIDS (Great International Developers Summit) というカンファレンスで発表します。さらに、弊社CTOのいとひろも登壇します。

インドまで行けないよ、という方は、PittaXで気軽にご連絡いただければ嬉しいです(もっといろいろ話したくてしょうがない)。

ログラスではすでにグローバル開発組織の展開に向けてのアクセルは全開に踏み始めています。ご興味がある方は、採用サイトからのご応募もお待ちしています。

https://x.com/developersummit/status/1857872013777580523

https://x.com/developersummit/status/1855332530565636584

まとめ

関数型エラーハンドリングで重要なポイントは、エラー情報を伝播し、型によってハンドリングを強制すること、それらを純粋性や全域性がある状態で行い、予測可能性と堅牢性を高めることです。

エラーは見過ごされがちですが、型でモデル化することによってハンドリングすべき例外条件を炙りだし、耐障害性の高いシステムにしていくことができます。
たとえ障害が起きても即座に原因をトレースできるようにし、ユーザーへの影響を最小限に抑え、持続的な価値のデリバリーができる状態にしていきましょう。

この記事がなにかのヒントになれば幸いです。
それでは素敵なクリスマスイブをお過ごしください!

株式会社ログラス テックブログ

Discussion