KINTO Tech Blog
Kotlin

[Server side Kotlin] エラーハンドリングにKotlin公式のResult型を使う

Cover Image for [Server side Kotlin] エラーハンドリングにKotlin公式のResult型を使う

はじめに

こんにちは。Toyota Woven City Payment Solution開発Groupで決済関係のバックエンド開発を担当している塩出です。
前回の記事でも述べた通り、本Groupでは開発にKotlinを使用しており、webフレームワークにはKtor、ORMにはExposedを使用しています。またコードのアーキテクチャーとしてはクリーンアーキテクチャーを採用しています。

当初からKotlinのResult型を使ってエラーハンドリングしていましたが、開発人数が増えていることもあり、Resultとthrowが混じったコードになっていました。Resultを使っているところにthrowが入ると、型でエラーハンドリングの必要性を表現しているにもかかわらず、try-catchも必要になってきてしまう状態でした。

KotlinではJavaの検査例外がないので、try-catchは簡単に呼び忘れてエラーハンドリング漏れが発生してしまいます。この状況を改善すべくチーム内で話し合ってKotlinのResultを使ったエラーハンドリングの方法を統一しました。

今回は本Groupでどのようにエラーハンドリングを書いているのかを紹介します。

この記事には以下の内容は含まれません

  • クリーンアーキテクチャーの説明
  • Ktor, Exposedの説明
  • kotlin-resultとKotlin公式のResult型との比較

アプリケーションのディレクトリ構成

本題に入る前に、本Groupでのアプリケーションのディレクトリ構造について説明します。
以下にクリーンアーキテクチャーの有名な図と本Groupのディレクトリ構成を載せます。本Groupではクリーンアーキテクチャーを採用しており、アプリケーションのディレクトリ構造もそれにおおよそ則った形で構成されています。

クリーンアーキテクチャー
(出典: The Clean Code Blog)

App Route/
├── domain
├── usecase/
│   ├── inputport
│   └── interactor
└── adapter/
    ├── web/
    │   └── controller
    └── gateway/
        ├── db
        └── etc

ディレクトリとクリーンアーキテクチャー図との対応は以下の通りです。

  • domainディレクトリ: entities
  • usecaseディレクトリ: Use Cases
  • adapter/web/controllerディレクトリ: Controllers
  • adapter/gatewayディレクトリ: Gateways

用語のズレは少々ありますが、基本的には domainディレクトリが円の中心で、usecaseディレクトリがその外側、adapter以下が円の一番外側といった感じになっています。

なので依存を許可する方向としては以下のようになります。

  • usecase -> domain
  • adapter以下 -> usecase or domain

このような依存の方向性にすることで、webフレームワークやdatabaseの種類などに影響を受けることなく、ビジネスロジックを開発することができます。

エラーハンドリングの方針

基本的にエラーハンドリングは以下の方針にしています。

  • 処理失敗の場合はthrowではなくResult型を使用する
  • 関数がResult型を返却するとき、throwは使わない
  • Exceptionを返却するときは独自定義したException型を利用する

次の章で個別の方針についてコード例を交えながら細かく紹介していきます。

関数が失敗する可能性がある場合は戻り値としてResult型を使用する

KotlinではJavaのような検査例外がなく、呼び出し元にエラーハンドリングを強制する仕組みがありません。Result型を使うことで呼び出し元にエラーが返却される可能性があることを明示できるので、エラーハンドリングが漏れる可能性が低くなります。
ただしResult<Unit>のように戻り値を使用しない場合は強制させることはできません。この場合はcustom lintを定義する必要がありますが、現状定義できていません。

コード例

以下は簡単なコードの例です。割り算をする関数を定義する場合、通常分母がゼロの場合はエラーになります。このように関数が失敗する可能性がある場合は戻り値にResult型を指定します。この場合は Result<Int>を指定しています。

fun divide(numerator: Int, denominator: Int) : Result<Int> {
    if (denominator == 0) {
        return Result.failure(ZeroDenominatorException())
    }
  return Result.success(numerator/denominator)
}

Result型でExceptionを返却する場合、そのExceptionを独自定義したExceptionでラップする

Repositoryなどは通常interfaceがdomainにあり実装がadapter層にあります。Use Case層からRepositoryの関数を呼び出してエラーハンドリングする場合、adapter層でサードパーティlibraryのExceptionをそのまま返却してしまうと、そのサードパーティのExceptionをUse Case層が知らないといけません。その場合、Use Caseが実質adapter層に依存してしまうことになってしまいます。図で示すと以下のような感じです。

依存関係
interface利用の依存とexceptionの依存(だめな例)

それを避けるため、Result型で返却するExceptionは必ず独自定義したExceptionにラップして返却するようにしています。

クリーンアーキテクチャーにおいてExceptionはどこの層なのか悩むポイントですが、個人的にはdomain層だと思っています。
本Groupでは複数サービスで共通のException型を使用しているので、domain libraryとして切り出しています。

Kotlin公式のResult型ではExceptionの型を指定できないので、実装者に独自定義したExceptionを返すように強制できないのが悩みポイントです。その場合はkotlin-resultの使用を検討するのが良さそうです。ただ、本GroupではdomainのコードにKotlin公式ではないサードパーティの型が入り込むのを避けたかったため、採用を見送りました。

コード例

以下のinterfaceがdomainに定義されているとします。

data class Entity(val id: String)
interface EntityRepository {
  fun getEntityById(id: String): Result<Entity>
}

またサードパーティのlibraryが以下のようなmethodを持っていてそれを使う例を考えます。

fun thirdPartyMethod(id: String): Entity {
    throw ThirdPartyException()
}

NG例

adapter層の実装で以下のように直接Exceptionを返却してしまうと、UseCaseなどの呼び出し元にサードパーティのExceptionが漏れてしまいます。

class EntityRepositoryImpl : EntityRepository {
  override fun getEntityById(id: String): Result<Entity> {
      return kotlin.runCatching {
        thirdPartyMethod(id)
      } // This returns the third party exception
  }
}

OK例

サードパーティのExceptionが呼び出し元に漏れないように、独自定義したExceptionでラップします。

class EntityRepositoryImpl : EntityRepository {
  override fun getEntityById(id: String): Result<Entity> {
    return kotlin.runCatching {
      thirdPartyMethod(id)
    }.onFailure { cause ->
      // wrap with our own exception
      CustomUnexpectedException(cause)
    }
  }
}

関数がResult型を返却するとき、throwは使わない

もし関数がResult型を返却するか、Exceptionをthrowする場合、呼び出し側は両方に対応しないといけません。仮に関数を作った人が特定のExceptionは呼び出し元にハンドリングしてもらう必要がないと思っても、呼び出し元がハンドリングしたい場合もあります。従って明示的なthrowを使わずに Result型で統一しています。

DBのコネクションエラーなどは実際発生した場合 Use Case層でリカバリーすることは無理なので、adapter層でexceptionをthrowしてそのままAPIレスポンスまでscope outしても良いかもしれませんが、DB更新できないことによるサードパーティのSaaSとの不整合を検出するためのlogを出力したいこともあります。その場合throwでscope outしてしまうとアラートが適切に上がらない可能性が出てきてしまいます。

エラーハンドリングを要否を決めるのは呼び出し側にあると思いますので、関数作成者がエラーハンドリング不要だと思ってもResult型でExceptionを返却するようにしています。

コード例

リポジトリのsave関数を例に、コード例を紹介します。save関数ではEntity classを受け取り、結果をResult<Entity>で返します。

NG例

以下のようにConnection errorが発生したときにthrowをし、それ以外のエラーはResult型で返却するとします。

class EntityRepository(val db: Database) {
    fun saveEntity(entity: Entity): Result<Entity> {
        try {
            db.connect()
            db.save(entity)
        } catch (e: ConnectionException) {
            // return result instead
            throw OurConnectionException(e)
        } catch (e: throwable) {
            return Result.failure(OurUnexpectedException(e))
        }
        return Result.success(entity)
    }
}

それを使うUse Case層ではSave時にエラーが発生したら何かしらのactionを取りたいとします。その場合には runCatching (内部ではtry-catchを利用してResult型に変換)を利用しなければなりません。

class UseCase(val repo: EntityRepository) {
    fun createNewEntity(): Result<Entity> {
        val entity = Entity.new()
        return runCatching { // need this runCatching in order to catch an exception
            repo.saveEntity(entity).getOrThrow()
        }.onFailure {
            // some error handling here
        }
    }
}

OK例

OKな例ではすべてのExceptionを独自定義したExceptionにラップしてResult型でそのExceptionを返却します。こうすることで呼び出し元はrunCatchingを削除することができ、コードがシンプルになります。

class EntityRepository(val db: Database) {
    fun saveEntity(entity: Entity): Result<Entity> {
        try {
            db.connect()
            db.save(entity)
        } catch (e: ConnectionException) {
            return Result.failure(OurConnectionException(e))
        } catch (e: Exception) {
            return Result.failure(OurUnexpectedException(e))
        }
        return Result.success(entity)
    }
}
class UseCase(val repo: EntityRepository) {
    fun createNewEntity(): Result<Entity> {
        val entity = Entity.new()
        return repo.saveEntity(entity).onFailure {
            // some error handling here
        }
    }
}

Result型を使う上での便利な自作関数の紹介

andThen

Result型を使っているとそのResultが成功だったときにその値を使って別のResult型を返す処理をしたいことが多々あります。例えば特定のエンティティーに対してステータスの更新をする場合は以下のようになるかと思います。

fun UseCaseImpl.updataStatus(id: Id) : Result<Entity> {
  val entity = repository.fetchEntityById(id).getOrElse {
    return Result.failure(it)
  }
  val updatedEntity = entity.updateStatus().getOrElse {
    return Result.failure(it)
  }
  return repository.save(updatedEntity)
}

このような場合はmethodチェーンで処理を繋げられると書きやすくなります。kotlin-resultではandThenという関数が用意されていますが、kotlin公式のResult型にはありません。そこで本Groupでは以下のようなmethodを定義して使っています。

inline fun <T, R> Result<T>.andThen(transform: (T) -> Result<R>): Result<R> {
    if (this.isSuccess) {
        return transform(getOrThrow())
    }
    return Result.failure(exceptionOrNull()!!)
}

これを使うことで上記の例は以下のように書き換えることができます。同じコードの記述が減ったので少しスマートになりました。

fun UseCaseImpl.updataStatus(id: Id) : Result<Entity> {
  return repository.fetchEntityById(id).andThen { entity ->
    entity.updateStatus()
  }.andThen { updatedEntity ->
    repository.save(updatedEntity)
  }
}

doInTransaction for Exposed

本GroupではORMapperとしてExposedを利用しています。Exposedでは transactionというラムダのスコープの中にDB処理を記述する必要があります。transactionラムダはそのscopeの中でExceptionがthrowされたら自動的にrollbackしてくれます。Result型を利用するとExceptionをthrowすることがないため、Resultが失敗だったときに自動的にrollbackをする関数を作成しました。

fun <T> doInTransaction(db: Database? = null, f: () -> Result<T>): Result<T> {
    return transaction(db) {
        f().onFailure {
            rollback()
        }.onSuccess {
            commit()
        }
    }
}

先ほどのUseCaseImplの例に当てはめると以下のように使用できます。

fun UseCaseImpl.updataStatus(id: Id) : Result<Entity> {
  return doInTransaction {
    repository.fetchEntityByIdForUpdate(id).andThen { entity ->
      entity.updateStatus()
    }.andThen { updatedEntity ->
      repository.save(updatedEntity)
    }
  }
}

respondResult for Ktor

本GroupはKtorをWebフレームワークとして利用しています。UseCaseで返却されたResult型をそのままレスポンスに指定できるように respondResultという関数を作成しました。

suspend inline fun <reified T : Any> ApplicationCall.respondResult(code: HttpStatusCode, result: Result<T?>) {
    result.onSuccess {
        when (it) {
            null, is Unit -> respond(code)
            else -> respond(code, it)
        }
    }.onFailure {
        // defined below
        respondError(it)
    }
}

suspend fun ApplicationCall.respondError(error: Throwable) {
    val response = error.toErrorResponse()
    val json = serializer.adapter(response.javaClass).toJson(response)
    logger.error(json, error)

    respondText(
        text = json,
        contentType = ContentType.Application.Json,
        status = e.errType.toHttpStatusCode(),
    )
}

単純ではありますが、この関数を使用することで Result.getOrThrowを呼ばなくても良くなるのでコードが少しスッキリします。

fun Route.route(useCase: UseCase) {
  val result = useCase.run()
  call.respondResult(HttpStatusCode.OK, result.map {it.toViewModel()} )
}

ちなみにrespondErrorはthrowableからエラーのレスポンスを返却する関数で、KtorのパイプラインでthrowされたExceptionもこの関数でレスポンスを返却するようにしています。Exceptionを処理するKtorのプラグインも自作しています。

val ErrorHandler = createApplicationPlugin("ErrorHandler") {
    on(CallFailed) { call, cause ->
        call.respondError(cause)
    }
}

さいごに

本Groupでのエラーハンドリングのやり方と、Result型の便利な自作関数を紹介しました。いろんな会社のtech blogをみていると kotlin-resultを使っているところが多い印象でkotlin公式のResult型に関しては使っているという情報は少ない印象です。今のところKotlin公式のResult型でも十分にエラーハンドリングできているので皆様もぜひ使ってみてください。

Facebook

関連記事 | Related Posts

イベント情報

製造業でも生成AI活用したい!名古屋LLM MeetUp#6
Mobility Night #3 - マップビジュアライゼーション -