はじめに
Clean Architectureでマルチモジュール構成にリファクタリングを行いましたが、今回はArrowライブラリを使ったエラー処理について整理・記録
なぜArrow-ktなのか?
Android開発をしていると、エラー処理のコードがビジネスロジックより多くなる経験をします。try-catchがネストし、nullチェックが繰り返され、どんな例外が発生するか把握しにくくなる
Arrow-ktはこのような問題を解決してくれるKotlinの関数型プログラミングライブラリ
インストール
// build.gradle.kts
dependencies {
implementation("io.arrow-kt:arrow-core:1.2.1")
implementation("io.arrow-kt:arrow-fx-coroutines:1.2.1")
}
従来のエラー処理の問題点
問題1:Exceptionは型システムに現れない
// この関数を見ても、どんな例外が発生するか分からない
fun getUserName(userId: String): String {
return api.getUser(userId).name // IOException? NetworkException?
}
// 呼び出し側も分からない
val name = getUserName("123") // 突然アプリがクラッシュする可能性
関数シグネチャにはStringしか見えないため、この関数が失敗する可能性があることが分からなく、ドキュメントを読むかコードを直接確認しないと分からない
問題2:try-catchは冗長でネストする
fun processUser(userId: String): String {
try {
val user = getUser(userId)
try {
val posts = getPosts(user.id)
try {
val comments = getComments(posts[0].id)
return comments[0].content
} catch (e: Exception) {
return "コメント読み込み失敗"
}
} catch (e: Exception) {
return "投稿読み込み失敗"
}
} catch (e: Exception) {
return "ユーザー読み込み失敗"
}
}
インデント地獄になり、実際のビジネスロジックよりエラー処理コードの方が多くなる
問題3:nullは情報が不足している
fun findUser(id: String): User? {
return database.find(id)
}
val user = findUser("123")
if (user == null) {
// なぜnull?
// DBに存在しない?
// DB接続失敗?
// 権限がない?
// 分からない
}
nullは「値がない」ことしか伝えず、なぜないのかという情報がない
問題4:Resultの限界
suspend fun refreshMessages(): Result {
return runCatching {
val messages = remoteDataSource.getMessages()
localDataSource.insertAll(messages)
}
}
// 使用時
refreshMessagesUseCase()
.onSuccess { /* ... */ }
.onFailure { error: Throwable ->
// errorはThrowable - 具体的な型が分からない
// whenで分岐するにはisチェックが必要
showError(error.message ?: "Unknown error")
}
Result<T>は失敗の型が常にThrowableに固定されているため、ドメインに合ったエラー型を使いにくいです。
現在よく使われているエラー処理方式
これらの問題を解決するため、Android開発では様々なエラー処理パターンが使われています。
1. sealed classでResultラッパーを自作
sealed class Resource {
data class Success(val data: T) : Resource()
data class Error(val message: String, val exception: Throwable? = null) : Resource()
data object Loading : Resource()
}
// 使用
fun getUser(): Flow<Resource> = flow {
emit(Resource.Loading)
try {
val user = api.getUser()
emit(Resource.Success(user))
} catch (e: Exception) {
emit(Resource.Error(e.message ?: "Unknown error", e))
}
}
最もよく見られるパターンですが、毎回自分で実装する必要があり、map、flatMapなどのユーティリティ関数も自作が必要です。
2. Retrofit Responseラッピング
suspend fun safeApiCall(apiCall: suspend () -> Response): Result {
return try {
val response = apiCall()
if (response.isSuccessful) {
Result.success(response.body()!!)
} else {
Result.failure(HttpException(response))
}
} catch (e: Exception) {
Result.failure(e)
}
}
ネットワーク呼び出しに特化していますが、エラー型が依然としてThrowableに制限されています。
3. Flowでcatchを使用
fun getMessages(): Flow<List> = flow {
emit(api.getMessages())
}.catch { e ->
// エラー処理...でもどうやって?
emit(emptyList()) // 空リストで代替?
}
Flowのcatchはエラーを処理できますが、エラー情報をUIまで伝えるのが難しいです。
なぜArrow-ktを選んだのか
上記の方式にはそれぞれ長所がありますが、以下の理由でArrow-ktを選びました。
-
検証済みライブラリ:Resultラッパーを自作する必要なく、すでに良く設計された
Either型を使用 -
豊富なユーティリティ:
map、flatMap、fold、recoverなど多様な演算子を提供 -
Raise DSL:
bind()、ensure()などでクリーンなエラー処理コードが書ける -
型安全なエラー:
Either<AppError, T>でドメインに合ったエラー型を使用 - 関数型プログラミング:チェーンと合成で可読性の高いコードが書ける
Either - 成功と失敗の明確な区別
Eitherの基本概念
Either<E, A>は2つの状態のうち1つを持てる型です。
sealed class Either {
data class Left(val value: E) : Either() // 失敗
data class Right(val value: A) : Either() // 成功
}
慣例としてLeftはエラー、Rightは成功を表します。
Eitherの利点
// 1. 関数シグネチャに失敗の可能性が明示される
fun getUser(id: String): Either
// ↑ 失敗型 ↑ 成功型
// 2. コンパイラがエラー処理を強制する
val result = getUser("123")
result.fold(
ifLeft = { error -> /* 必ず処理が必要 */ },
ifRight = { user -> /* 成功処理 */ }
)
// 3. エラーに豊富な情報を含められる
sealed interface AppError {
data class NotFound(val id: String) : AppError
data class NetworkError(val cause: Throwable) : AppError
data class Unauthorized(val reason: String) : AppError
}
Either主要メソッド
fold - 両方を処理
val result: Either = divide(10, 2)
val message: String = result.fold(
ifLeft = { error -> "失敗: $error" },
ifRight = { value -> "成功: $value" }
)
map - 成功した場合のみ変換
val result: Either = Either.Right(5)
val doubled: Either = result.map { it * 2 }
// Right(10)
// Leftの場合、mapは実行されずそのまま通過
val failed: Either = Either.Left("エラー")
val stillFailed = failed.map { it * 2 }
// Left("エラー") - map実行されず
mapLeft - 失敗した場合のみ変換
val result: Either = Either.Left(IOException("ネットワークエラー"))
// Throwable → AppErrorに変換
val mapped: Either = result.mapLeft { throwable ->
AppError.NetworkError(throwable)
}
flatMap - Eitherを返す関数のチェーン
fun divide(a: Int, b: Int): Either =
if (b == 0) Either.Left("0で割れません")
else Either.Right(a / b)
// flatMapでチェーン
val result: Either = divide(10, 2)
.flatMap { divide(it, 2) } // Right(2)
// 途中で失敗すると即座に停止
val failed: Either = divide(10, 0)
.flatMap { divide(it, 2) } // Left("0で割れません")
getOrElse - デフォルト値を提供
val result: Either = getUser("123")
// 失敗時にデフォルト値を使用
val user: User = result.getOrElse {
User(id = -1, name = "Guest")
}
// またはnullに変換
val nullableUser: User? = result.getOrNull()
Either.catch - 例外をEitherに変換
// 例外が発生する可能性のあるコードをEitherでラップ
val result: Either> = Either.catch {
api.getMessages() // IOException発生の可能性
}
// 成功: Either.Right(messages)
// 失敗: Either.Left(IOException)
// mapLeftと組み合わせてドメインエラーに変換
val domainResult: Either> = Either.catch {
api.getMessages()
}.mapLeft { throwable ->
throwable.toNetworkError()
}
Raise DSL - 型安全な早期リターン
Eitherを直接使うとコードが複雑になることがあります。ArrowのRaise DSLを使えば、例外を投げるように自然にコードを書けます。
Raiseの核心アイデア
Raiseは**「失敗が許容された特別な空間(コンテキスト)」**を作ることです。
val result: Either = either {
// この空間内ではraise()を使用できる
if (someCondition) {
raise("失敗!") // 即座に脱出、Either.Left("失敗!")を返す
}
42 // 成功時 Either.Right(42)を返す
}
例えると:
-
throw:非常ベルを押してビル全体を止める(アプリクラッシュの危険) -
raise:特殊任務空間で脱出ボタンを押す(安全にEither.Leftに変換)
either { } ブロック
fun divide(a: Int, b: Int): Either = either {
if (b == 0) {
raise("0で割ることはできません") // 即座にEither.Leftを返す
}
a / b // 成功時 Either.Rightを返す
}
bind() - Eitherから値を抽出
bind()はEitherからRightの値を抽出し、Leftの場合は即座に脱出します。
fun processUser(userId: String): Either = either {
// getUser()はEitherを返す
val user: User = getUser(userId).bind() // Rightなら User抽出、Leftなら即座に脱出
val posts: List = getPosts(user.id).bind() // 同様
posts.first().title // すべての作業成功時に返す
}
bind()なしで書くと?
// bind()なし(冗長)
fun processUser(userId: String): Either {
val userResult = getUser(userId)
val user = when (userResult) {
is Either.Left -> return Either.Left(userResult.value)
is Either.Right -> userResult.value
}
val postsResult = getPosts(user.id)
val posts = when (postsResult) {
is Either.Left -> return Either.Left(postsResult.value)
is Either.Right -> postsResult.value
}
return Either.Right(posts.first().title)
}
// bind()使用(簡潔)
fun processUser(userId: String): Either = either {
val user = getUser(userId).bind()
val posts = getPosts(user.id).bind()
posts.first().title
}
Either.catchとbind()の組み合わせ
Either.catchとbind()を組み合わせると、例外処理とチェーンをきれいに書けます。
fun fetchAndSaveMessages(): Either> = either {
// 1. API呼び出し(例外 → Eitherに変換 → ドメインエラーに変換 → 値抽出)
val messages = Either.catch { api.getMessages() }
.mapLeft { it.toNetworkError() }
.bind()
// 2. DB保存
Either.catch { database.insertAll(messages) }
.mapLeft { it.toDatabaseError() }
.bind()
messages
}
ensure() - 条件検証
ensure(condition) { error }は条件がfalseならraiseします。
fun createUser(email: String, age: Int): Either = either {
ensure(email.contains("@")) {
AppError.ValidationFailed("email", "メール形式が正しくありません")
}
ensure(age >= 18) {
AppError.ValidationFailed("age", "18歳以上である必要があります")
}
// すべての検証通過
User(email = email, age = age)
}
ensureNotNull() - Nullチェック
ensureNotNull(value) { error }は値がnullならraise、そうでなければnon-null値を返します。
fun getUserProfile(userId: String): Either = either {
val user: User? = repository.findUser(userId)
// nullならraise、そうでなければnon-null Userを返す
val nonNullUser: User = ensureNotNull(user) {
AppError.NotFound(userId)
}
// ここからnonNullUserは絶対にnullではない!
Profile(name = nonNullUser.name, email = nonNullUser.email)
}
recover() - エラー回復
特定のエラーを回復して処理を続行できます。
fun getMessageWithFallback(id: Int): Either = either {
val result = repository.getMessage(id)
result.recover { error ->
when (error) {
is AppError.NetworkError -> {
// ネットワークエラーはキャッシュデータで代替
Message(id = id, title = "キャッシュメッセージ", body = "オフライン")
}
else -> raise(error) // 他のエラーはそのまま伝播
}
}.bind()
}
Clean Architectureに適用する
レイヤー別責任分離
ArrowをClean Architectureに適用する際、最も重要なのは各レイヤーの責任を明確に分離することです。
┌─────────────────────────────────────────────────────────────────┐
│ レイヤー別責任
├─────────────────────────────────────────────────────────────────┤
│
│ Repository(技術的エラー変換)
│ ├── Either.catch { api.call() }
│ │ .mapLeft { it.toNetworkError() }
│ │ .bind()
│ ├── IOException → AppError.NetworkError.ConnectionFailed
│ ├── SocketTimeoutException → AppError.NetworkError.Timeout
│ └── HttpException → AppError.NetworkError.ServerError(code)
│
│ ✗ ビジネス検証はしない
│ ✗ ユーザーメッセージは入れない
│
├─────────────────────────────────────────────────────────────────┤
│
│ UseCase(ビジネスロジック検証)
│ ├── repository.getData().bind()
│ ├── ensure(data.isNotEmpty()) { EmptyData }
│ └── ensure(id > 0) { InvalidId(id) }
│
│ ✓ Repository結果にビジネスルールを適用
│ ✓ ensure()、bind()を使用
│
├─────────────────────────────────────────────────────────────────┤
│
│ Presentation(ユーザーメッセージ生成)
│ ├── useCase().fold(ifLeft, ifRight)
│ ├── AppError.toUserMessage() 拡張関数
│ └── 多言語対応、UIコンテキストに合ったメッセージ
│
│ ✓ fold()でEitherを処理
│ ✓ エラー型別に適切なメッセージを決定
│
└─────────────────────────────────────────────────────────────────┘
なぜこのように分離するのか?
- Repositoryにメッセージを入れると:多言語対応が難しく、UIコンテキストが分からない
- UseCaseで技術的エラーを処理すると:Repository実装の変更時にUseCaseも修正が必要
- Presentationでメッセージを生成すると:同じエラーでも画面ごとに異なるメッセージが可能、多言語対応が容易
実践例
エラー型定義(Domain Layer)
sealed interface AppError {
// 技術的エラー(Repositoryで発生)
sealed interface NetworkError : AppError {
data class ConnectionFailed(val cause: Throwable? = null) : NetworkError
data class Timeout(val cause: Throwable? = null) : NetworkError
data class ServerError(val code: Int) : NetworkError
}
sealed interface DatabaseError : AppError {
data class WriteFailed(val cause: Throwable? = null) : DatabaseError
// ...
}
// ビジネスエラー(UseCaseで発生)
sealed interface BusinessError : AppError {
data object EmptyData : BusinessError
data class InvalidId(val id: Int) : BusinessError
// ...
}
data class Unknown(val cause: Throwable) : AppError
}
Repositoryインターフェース(Domain Layer)
interface MessageRepository {
fun observeMessages(): Flow<List>
suspend fun refreshMessages(): Either>
suspend fun deleteMessage(id: Int): Either
// ...
}
Throwable → AppError変換(Data Layer)
fun Throwable.toNetworkError(): AppError = when (this) {
is SocketTimeoutException -> AppError.NetworkError.Timeout(this)
is IOException -> AppError.NetworkError.ConnectionFailed(this)
is HttpException -> AppError.NetworkError.ServerError(code())
else -> AppError.Unknown(this)
}
fun Throwable.toDatabaseError(): AppError = when (this) {
is SQLiteException -> AppError.DatabaseError.WriteFailed(this)
else -> AppError.DatabaseError.WriteFailed(this)
}
Repository実装(Data Layer)
核心はEither.catch { }.mapLeft { }.bind()パターンです。
class MessageRepositoryImpl @Inject constructor(
private val localDataSource: MessageLocalDataSource,
private val remoteDataSource: MessageRemoteDataSource
) : MessageRepository {
override suspend fun refreshMessages(): Either> = either {
// 1. API呼び出し → 例外をEitherに → ドメインエラーに変換 → 値抽出
val remoteMessages = Either.catch { remoteDataSource.getMessages() }
.mapLeft { it.toNetworkError() }
.bind()
// 2. DB保存
Either.catch { localDataSource.insertAll(remoteMessages.map { it.toEntity() }) }
.mapLeft { it.toDatabaseError() }
.bind()
remoteMessages.map { it.toDomain() }
}
override suspend fun deleteMessage(id: Int): Either = either {
Either.catch { localDataSource.deleteById(id) }
.mapLeft { it.toDatabaseError() }
.bind()
Either.catch { remoteDataSource.deleteMessage(id) }
.mapLeft { it.toNetworkError() }
.bind()
}
// ... 他のメソッドも同じパターン
}
UseCase実装(Data Layer)
UseCaseはbind()でRepository結果を受け取り、ensure()でビジネス検証を行います。
class RefreshMessagesUseCaseImpl @Inject constructor(
private val repository: MessageRepository
) : RefreshMessagesUseCase {
override suspend fun invoke(): Either> = either {
val messages = repository.refreshMessages().bind()
// ビジネス検証
ensure(messages.isNotEmpty()) {
AppError.BusinessError.EmptyData
}
messages
}
}
class DeleteMessageUseCaseImpl @Inject constructor(
private val repository: MessageRepository
) : DeleteMessageUseCase {
override suspend fun invoke(id: Int): Either = either {
ensure(id > 0) { AppError.BusinessError.InvalidId(id) }
repository.deleteMessage(id).bind()
}
}
AppError → ユーザーメッセージ(Presentation Layer)
fun AppError.toUserMessage(): String = when (this) {
is AppError.NetworkError.ConnectionFailed ->
"ネットワーク接続に失敗しました。インターネット接続を確認してください。"
is AppError.NetworkError.Timeout ->
"リクエストがタイムアウトしました。もう一度お試しください。"
is AppError.NetworkError.ServerError ->
"サーバーエラーが発生しました。(コード: $code)"
is AppError.DatabaseError.WriteFailed ->
"データの保存中にエラーが発生しました。"
AppError.BusinessError.EmptyData ->
"表示するデータがありません。"
is AppError.BusinessError.InvalidId ->
"無効なIDです。"
is AppError.Unknown ->
"不明なエラーが発生しました。"
// ...
}
ViewModel(Presentation Layer)
fold()でEitherを処理します。
@HiltViewModel
class HomeViewModel @Inject constructor(
private val refreshMessagesUseCase: RefreshMessagesUseCase,
private val deleteMessageUseCase: DeleteMessageUseCase
) : ContainerHost, ViewModel() {
private fun loadInitialData() = intent {
reduce { state.copy(isLoading = true) }
refreshMessagesUseCase().fold(
ifLeft = { error ->
postSideEffect(HomeSideEffect.ShowError(error.toUserMessage()))
},
ifRight = { messages ->
postSideEffect(HomeSideEffect.ShowSnackBar("${messages.size}件読み込み完了"))
}
)
reduce { state.copy(isLoading = false) }
}
fun onDeleteMessage(id: Int) = intent {
val previousItems = state.items
// Optimistic UI
reduce { state.copy(items = state.items.filter { it.id != id }) }
deleteMessageUseCase(id).fold(
ifLeft = { error ->
reduce { state.copy(items = previousItems) } // ロールバック
postSideEffect(HomeSideEffect.ShowError(error.toUserMessage()))
},
ifRight = {
postSideEffect(HomeSideEffect.ShowSnackBar("削除完了"))
}
)
}
// ...
}
データフロー例
ユーザー → 「更新ボタンをクリック」
│
↓
┌─────────────────────────────────────────────────────────────────┐
│ ViewModel
│ refreshMessagesUseCase()
└───────────────────────────┬─────────────────────────────────────┘
↓
┌─────────────────────────────────────────────────────────────────┐
│ UseCase
│ repository.refreshMessages().bind()
│ ensure(messages.isNotEmpty()) { EmptyData }
└───────────────────────────┬─────────────────────────────────────┘
↓
┌─────────────────────────────────────────────────────────────────┐
│ Repository
│ Either.catch { remoteDataSource.getMessages() }
│ .mapLeft { it.toNetworkError() }
│ .bind()
└───────────────────────────┬─────────────────────────────────────┘
↓
┌─────────────────────────────────────────────────────────────────┐
│ DataSource
│ api.getMessages() → IOException発生!
└───────────────────────────┬─────────────────────────────────────┘
↓
(Either.catchが例外をキャッチ)
↓
┌─────────────────────────────────────────────────────────────────┐
│ Repository
│ Either.Left(IOException)
│ .mapLeft { it.toNetworkError() }
│ → Either.Left(NetworkError.ConnectionFailed)
│ .bind() → raise!
└───────────────────────────┬─────────────────────────────────────┘
↓
┌─────────────────────────────────────────────────────────────────┐
│ UseCase
│ .bind() → Leftなので即座に脱出
│ → Either.Left(NetworkError.ConnectionFailed)を返す
└───────────────────────────┬─────────────────────────────────────┘
↓
┌─────────────────────────────────────────────────────────────────┐
│ ViewModel
│ .fold(
│ ifLeft = { error ->
│ postSideEffect(ShowError(error.toUserMessage()))
│ },
│ ifRight = { ... }
│ )
└───────────────────────────┬─────────────────────────────────────┘
↓
┌─────────────────────────────────────────────────────────────────┐
│ UI
│ Snackbar: "ネットワーク接続に失敗しました。インターネット接続を確認してください。"
└─────────────────────────────────────────────────────────────────┘
おわりに
Arrow-kt核心まとめ
| 関数 | 説明 |
|---|---|
either { } |
失敗可能な「特別な空間」を生成 |
raise(error) |
即座に脱出 → Either.Leftを返す |
Either.catch { } |
例外をEitherに変換 |
.mapLeft { } |
Left値(エラー)を変換 |
.bind() |
Rightなら値抽出、Leftなら即座に脱出 |
ensure(条件) { error } |
条件がfalseならraise
|
ensureNotNull(値) { error } |
nullならraise
|
fold(ifLeft, ifRight) |
両方を処理 |
recover { } |
特定エラーを回復 |
従来コード vs Arrowコード比較
// ❌ 従来方式(Result + try-catch)
suspend fun refreshMessages(): Result {
return runCatching {
val messages = remoteDataSource.getMessages()
localDataSource.insertAll(messages)
}
}
refreshMessagesUseCase()
.onSuccess { /* ... */ }
.onFailure { error: Throwable ->
// Throwable - 具体的な型が分からない
showError(error.message ?: "Unknown")
}
// ✅ Arrow方式(Either.catch + mapLeft)
suspend fun refreshMessages(): Either> = either {
val remoteMessages = Either.catch { remoteDataSource.getMessages() }
.mapLeft { it.toNetworkError() }
.bind()
Either.catch { localDataSource.insertAll(remoteMessages) }
.mapLeft { it.toDatabaseError() }
.bind()
remoteMessages.map { it.toDomain() }
}
refreshMessagesUseCase().fold(
ifLeft = { error: AppError ->
// AppError - whenですべてのケースを型安全に処理
showError(error.toUserMessage())
},
ifRight = { messages -> /* ... */ }
)
Arrow-ktを導入すると良い点
- 型安全性:関数シグネチャだけで失敗の可能性が分かる
- コンパイラサポート:エラー処理を強制してランタイムクラッシュを防止
-
豊富なエラー情報:
Throwableの代わりにドメインエラー型を使用 -
簡潔なコード:
Either.catch、bind()、ensure()などでボイラープレートを削減 - 関数型スタイル:チェーンで可読性向上
- 明確な責任分離:レイヤーごとにエラー処理の責任が明確
