クイックスタート(5分)
KotlinのNull安全機能によって恐ろしいNullPointerExceptionは排除されましたが、多くのチームは依然としてビジネスロジックの失敗処理を実行時例外(Runtime Exception)に依存しています。Arrowはこのギャップを埋めるライブラリです。実績ある関数型プログラミングのイディオムをKotlinに持ち込み、目に見えない実行時エラーを厳格なコンパイル時チェック可能な型へと変換します。しかも、セットアップに必要なのはGradleの依存関係を1行追加するだけです。
build.gradle.ktsにArrow Coreを追加します:
dependencies {
implementation("io.arrow-kt:arrow-core:1.2.4")
}
Arrowにおけるエラーハンドリングの中心となるのがEither<Left, Right>です。慣例として、Leftには失敗の詳細情報、Rightには正常なペイロードを保持します。「Rightは正しい(Right)」と覚えると分かりやすいでしょう。
try/catchブロックを一切使わずにフォーム入力をパース・検証する方法は以下の通りです:
import arrow.core.Either
import arrow.core.raise.either
import arrow.core.raise.ensure
sealed interface ValidationError {
data object NotANumber : ValidationError
data class TooSmall(val min: Int) : ValidationError
}
fun parseAge(rawInput: String): Either<ValidationError, Int> = either {
val number = rawInput.toIntOrNull() ?: raise(ValidationError.NotANumber)
ensure(number >= 18) { ValidationError.TooSmall(18) }
number
}
fun main() {
when (val result = parseAge("25")) {
is Either.Right -> println("登録されたユーザー年齢: ${result.value}")
is Either.Left -> println("バリデーション失敗: ${result.value}")
}
}
入力値として"abc"を渡すとValidationError.NotANumberが返り、"15"を渡すとValidationError.TooSmall(18)が返されます。クラッシュも推測も不要です。呼び出し側が両方のケースを確実に処理することが、コンパイラによって保証されます。
ディープダイブ(詳細解説)
問題点:本番環境における非チェック例外
典型的なチェックアウト処理を想定してみましょう。JWTの認証、残高確認、Stripe経由でのカード決済、そしてPostgresへの注文データ保存です。従来のオブジェクト指向的なKotlinでは、パイプラインは一見すっきりと書けます:
fun processOrder(request: OrderRequest): OrderConfirmation {
val user = userRepository.findById(request.userId)
val payment = paymentGateway.charge(user, request.amount)
return orderRepository.save(user, payment)
}
しかし、その綺麗さは見せかけに過ぎません。ユーザーレコードが存在しなかったらどうなるでしょうか? Stripeが3,000ミリ秒後にタイムアウトしたらどうなるでしょうか? Kotlinでは、メソッドはUserNotFoundExceptionやPaymentTimeoutExceptionといった非チェック例外をスローします。ネットワークの一時的な障害や空のクエリ結果によってリクエストが異常終了するリスクは、関数のシグネチャからは一切伝わりません。
根本原因:見えない障害コントラクト
例外のスローは参照透過性を損ないます。通常のロジック分岐を飛び越え、制御がコールスタックを一気に遡ってしまうからです。関数シグネチャはOrderConfirmationを返すことを約束しているにもかかわらず、呼び出し元には背後の3つの異なるサブシステムがスレッド全体を中断させる可能性があることが見えません。
また、Javaスタイルのスタックトレース生成には、高負荷時で例外1回あたり通常2〜10マイクロ秒という現実的なパフォーマンスオーバーヘッドが伴います。さらに厄介なことに、開発者は下流の呼び出しをtry/catchブロックで囲むのを忘れがちです。その結果どうなるでしょうか? トラフィック急増時に未処理のHTTP 500エラーが多発することになります。
実践的な解決策:EitherとRaise DSLによる型付きエラー
関数型アーキテクチャでは、ドメインの失敗を予期せぬ制御フローの混乱としてではなく、単なる「データ」として扱います。ArrowのRaise DSLはEitherと組み合わせることで、命令形構文のような分かりやすさとコンパイル時の安全性を両立させます。
まずは、失敗の状態をsealed階層としてモデリングします:
sealed interface OrderError {
data class UserNotFound(val userId: String) : OrderError
data class InsufficientFunds(val balance: Long, val required: Long) : OrderError
data class GatewayTimeout(val message: String) : OrderError
}
リポジトリおよびゲートウェイクライアントが、明示的な型付き結果を返すように設定します:
import arrow.core.Either
import arrow.core.left
import arrow.core.right
class UserRepository {
fun findById(userId: String): Either<OrderError.UserNotFound, User> =
if (userId == "usr_123") User(userId, balance = 5000).right()
else OrderError.UserNotFound(userId).left()
}
class PaymentGateway {
fun charge(user: User, amount: Long): Either<OrderError, PaymentReceipt> {
if (user.balance < amount) {
return OrderError.InsufficientFunds(user.balance, amount).left()
}
return PaymentReceipt(transactionId = "tx_999", amount = amount).right()
}
}
次に、either { ... }ビルダー内で各処理を連携させます。Eitherに対してbind()を呼び出すと、成功時のペイロードがアンラップされます。もしLeftに遭遇した場合は、即座に処理がショートサーキット(早期リターン)されます:
import arrow.core.raise.either
class OrderService(
private val userRepository: UserRepository,
private val paymentGateway: PaymentGateway
) {
fun processOrder(userId: String, amount: Long): Either<OrderError, OrderConfirmation> = either {
val user = userRepository.findById(userId).bind()
val receipt = paymentGateway.charge(user, amount).bind()
OrderConfirmation(userId = user.id, transactionId = receipt.transactionId)
}
}
変更後のシグネチャを見てください。呼び出し元に対し、OrderConfirmationが返るか、3つの具体的なOrderErrorシナリオのいずれかが発生することが明示されています。隠された実行時の落とし穴はもう存在しません。
高度な使い方
Arrow FxによるCoroutinesの統合
本番サービスでは、Kotlin Coroutinesを用いたノンブロッキングI/Oが不可欠です。Arrowはarrow-fx-coroutinesアーティファクトを通じて、構造化並行性(Structured Concurrency)と直接統合します:
dependencies {
implementation("io.arrow-kt:arrow-fx-coroutines:1.2.4")
}
例えば、アカウントのプロフィールデータと通知設定を並列で取得する必要があるとします。独立したタスクを並行実行する場合、通常は冗長な非同期ビルダーのボイラープレートが必要です。ArrowのparZipを使用すると、キャンセレーションを適切に処理しながらこれらを並列実行できます:
import arrow.core.Either
import arrow.core.raise.either
import arrow.fx.coroutines.parZip
import kotlinx.coroutines.delay
suspend fun fetchProfile(userId: String): Either<OrderError, Profile> = either {
delay(45) // ネットワーク呼び出しのシミュレーション
Profile(userId, "Alex")
}
suspend fun fetchPreferences(userId: String): Either<OrderError, Preferences> = either {
delay(60) // データベースクエリのシミュレーション
Preferences(newsletter = false)
}
suspend fun loadDashboard(userId: String): Either<OrderError, DashboardData> = either {
val (profile, preferences) = parZip(
{ fetchProfile(userId) },
{ fetchPreferences(userId) }
) { profResult, prefResult ->
Pair(profResult.bind(), prefResult.bind())
}
DashboardData(profile, preferences)
}
両方のネットワーク操作が並行して実行されるため、順次実行した場合の105ミリ秒に対して約60ミリ秒で完了します。どちらかのクエリがLeftを返した場合は、もう一方のコルーチンも即座にキャンセルされるため、貴重なCPUリソースや上流のネットワーク帯域の浪費を防ぐことができます。
エラーのショートサーキットではなく蓄積を行う
順次処理におけるショートサーキットはトランザクション処理には適していますが、フォームバリデーションには向きません。ユーザー名を修正して再送信した直後に、今度はメールアドレスが無効だとエラーを返されるような体験は誰しも避けたいものです。ArrowはzipOrAccumulateによってこの問題を解決します:
import arrow.core.NonEmptyList
import arrow.core.raise.either
import arrow.core.raise.ensure
import arrow.core.raise.zipOrAccumulate
data class SignupForm(val username: String, val email: String, val age: Int)
data class ValidatedUser(val username: String, val email: String, val age: Int)
fun validateForm(form: SignupForm): Either<NonEmptyList<String>, ValidatedUser> = either {
zipOrAccumulate(
{ ensure(form.username.isNotBlank()) { "ユーザー名は空にできません" } },
{ ensure("@" in form.email) { "無効なメールアドレス形式です" } },
{ ensure(form.age >= 18) { "18歳以上である必要があります" } }
) { _, _, _ ->
ValidatedUser(form.username, form.email, form.age)
}
}
すべてのフィールドに無効なデータを渡した場合、呼び出し側は3つすべてのエラー文字列を含むNonEmptyListを保持したEither.Leftを受け取ります。これにより、フロントエンドは一度の検証ですべてのエラーを表示できます。
実践的なTips
1. Webフレームワーク(Ktor & Spring)における境界マッピング
私たちの決済マイクロサービスでは、型付き境界を導入したことで、週間トラフィックにおける未処理の500エラーが2.4%から0.05%未満に激減しました。アーキテクチャ上の経験則として、Arrowのデータ構造はドメイン層およびアプリケーション層の内部に閉じ込め、コントローラーの境界でのみHTTPレスポンスのペイロードへとマッピングすることが肝要です。
以下は、foldを使用したイディオマティックなKtorのルーティング実装例です:
import io.ktor.server.application.*
import io.ktor.server.response.*
import io.ktor.server.routing.*
import io.ktor.http.HttpStatusCode
fun Route.orderRouting(orderService: OrderService) {
post("/orders") {
val request = call.receive<OrderRequest>()
orderService.processOrder(request.userId, request.amount).fold(
ifLeft = { error ->
when (error) {
is OrderError.UserNotFound ->
call.respond(HttpStatusCode.NotFound, mapOf("error" to "ユーザーが見つかりません"))
is OrderError.InsufficientFunds ->
call.respond(HttpStatusCode.UnprocessableEntity, mapOf("error" to "残高不足です"))
is OrderError.GatewayTimeout ->
call.respond(HttpStatusCode.GatewayTimeout, mapOf("error" to "決済ゲートウェイがタイムアウトしました"))
}
},
ifRight = { confirmation ->
call.respond(HttpStatusCode.Created, confirmation)
}
)
}
}
2. 単純なNullableに対してEitherを過剰に使用しない
すべてのNullableな検索結果をEitherでラップしたくなる衝動は抑えましょう。リポジトリの検索がキャッシュヒットを確認するだけのもので、データが存在しないことにエラー原因としての重要性がない場合は、素直にUser?を返すべきです。Kotlin標準のNull許容型は軽量で自然です。Eitherは、失敗した理由がビジネス上の判断に関わるような操作に限定して使用しましょう。
3. サードパーティ境界の例外は早期にキャッチする
外部SDK、JDBCドライバ、HTTPクライアントなどは未加工の生例外をスローします。これらはEither.catchを使用し、インフラストラクチャのアダプター層で直ちに捕捉しましょう:
import arrow.core.Either
fun safeDatabaseCall(): Either<OrderError.GatewayTimeout, Record> =
Either.catch { externalDriver.fetch() }
.mapLeft { ex -> OrderError.GatewayTimeout(ex.message ?: "データベース処理が5000msでタイムアウトしました") }
アダプターレベルでサニタイズしてコアドメインを保護することで、外部ドライバのクラッシュによるコードベースの汚染を防ぎます。これにより、下流のすべての失敗が型安全かつ予測可能でクリーンに保たれます。
