Я долго сопротивлялся функциональной обработке ошибок. «Исключения работают, зачем усложнять?» — это я два года назад. Потом мы попали в прод-инцидент: необработанное исключение внутри catch-блока привело к тому, что клиент получил 200 OK вместо 500. День расследования, час даунтайма, постмортем.
После этого я начал смотреть на Either и Raise из Arrow.kt. Спойлер: мы мигрировали весь микросервис (40+ endpoints) на функциональную обработку ошибок, и я не хочу возвращаться. Но путь был не идеальным — расскажу честно.
Проблема с исключениями на практике
Вот типичный код, который был в нашем проекте. Сервис создания пользователя:
class UserController(
private val userService: UserService,
private val emailService: EmailService
) {
fun createUser(request: CreateUserRequest): UserResponse {
return try {
val user = userService.createUser(
email = request.email,
name = request.name,
age = request.age
)
emailService.sendWelcome(user.email)
UserResponse.fromDomain(user)
} catch (e: UserAlreadyExistsException) {
throw ConflictException("User already exists")
} catch (e: InvalidEmailException) {
throw BadRequestException("Invalid email: ${e.message}")
} catch (e: UnderageException) {
throw BadRequestException("User must be 18+")
} catch (e: EmailServiceUnavailableException) {
// ЛОГАЕМ, НО ВОЗВРАЩАЕМ УСПЕХ — ВОТ ТАК И ПОЛУЧИЛСЯ ИНЦИДЕНТ
logger.warn("Email service unavailable, user created without notification")
UserResponse.fromDomain(user) // КОМПИЛЯЦИЯ ОШИБКА: user не в scope!
}
}
}Видите баг? В последнем catch-блоке мы пытаемся вернуть user, но он не в scope — компилятор это поймает, но в оригинальном коде был похожий кейс, где переменная была объявлена выше и в catch-блоке мы молча возвращали partial результат. Это и привело к инциденту.
Проблема шире: try-catch — это control flow, а не обработка ошибок. Он ломает referential transparency, делает код неочевидным (какие исключения может бросить эта функция?) и компилятор вам не поможет — тип возврата не говорит об ошибках.
Either: базовый подход
Arrow.kt даёт Either — где Left — это ошибка, а Right — успех (mnemonic: «right» = «правильный»). Перепишем:
sealed interface UserError {
data class AlreadyExists(val email: String) : UserError
data class InvalidEmail(val reason: String) : UserError
data object Underage : UserError
data object EmailServiceUnavailable : UserError
}
class UserService {
fun createUser(
email: String,
name: String,
age: Int
): Either {
if (!EmailValidator.isValid(email)) {
return Either.Left(UserError.InvalidEmail("bad format"))
}
if (age < 18) {
return Either.Left(UserError.Underage)
}
if (repository.findByEmail(email) != null) {
return Either.Left(UserError.AlreadyExists(email))
}
val user = repository.save(
User(email = email, name = name, age = age)
)
return Either.Right(user)
}
} Теперь сигнатура функции createUser говорит вам ВСЁ: она может вернуть ошибку UserError или успех User. Компилятор заставит вас обработать оба случая. Забыть обработать ошибку — нельзя.
Raise DSL — эргономика
Either работает, но код с Either.Left / Either.Right многословный. Arrow 2.0 ввёл Raise DSL, который делает код похожим на imperative, но с type-safe ошибками:
fun createUser(
email: String,
name: String,
age: Int
): User raises UserError {
ensure(EmailValidator.isValid(email)) {
UserError.InvalidEmail("bad format")
}
ensure(age >= 18) {
UserError.Underage
}
ensure(repository.findByEmail(email) == null) {
UserError.AlreadyExists(email)
}
return repository.save(
User(email = email, name = name, age = age)
)
}Это читается как обычный imperative код, но под капотом компилятор знает, что функция может «raise» UserError. Вызывающий код обязан это обработать. И никаких try-catch.
Интеграция с Ktor
Самый важный вопрос: как это интегрируется с HTTP-фреймворком? Мы используем Ktor, и вот как выглядит endpoint:
fun Route.userRoutes(userService: UserService) {
post("/users") {
val request = call.receive()
val result = userService.createUser(
email = request.email,
name = request.name,
age = request.age
)
when (result) {
is Either.Right -> call.respond(
HttpStatusCode.Created,
UserResponse.fromDomain(result.value)
)
is Either.Left -> when (val error = result.value) {
is UserError.AlreadyExists ->
call.respond(HttpStatusCode.Conflict, error.toResponse())
is UserError.InvalidEmail ->
call.respond(HttpStatusCode.BadRequest, error.toResponse())
is UserError.Underage ->
call.respond(HttpStatusCode.BadRequest, error.toResponse())
is UserError.EmailServiceUnavailable ->
call.respond(
HttpStatusCode.Created,
UserResponse.fromDomain(/* ... */)
)
}
}
}
} Мы вынесли when-блок в extension-функцию Either, чтобы не дублировать в каждом endpoint. Код стал boilerplate-heavy на уровне роутинга, но бизнес-логика — чистая и type-safe.
Реальный до/после
Вот реальные метрики нашего проекта после миграции:
Количество необработанных ошибок в проде: до — 3-5 в месяц, после — 0 за 4 месяца. Это потому что компилятор не даёт забыть обработать ошибку. Каждый when на Either обязан быть exhaustive.
Читаемость бизнес-логики: значительное улучшение. С Raise DSL код выглядит как обычный Kotlin, но при этом type-safe. Новые разработчики в команде понимают, какие ошибки может вернуть функция, просто глядя на сигнатуру.
Код роутинга: стал чуть более многословным из-за when-блоков, но предсказуемым. Нет «волшебных» исключений, которые вылетают из глубины стека.
Когда FP — это overkill
Честно: для простого CRUD-сервиса с 5 endpoints, где ошибки обрабатываются глобальным StatusPages в Ktor — Either может быть избыточным. Если вся ваша обработка ошибок — это «400 для BadRequest и 500 для всего остального» — исключения справятся.
Но если у вас сложная доменная логика, много типов ошибок, и ошибка в обработке ошибки стоит дорого (как в нашем случае с финансовым сервисом) — Either + Raise окупаются. Не сразу, не без боли обучения, но окупаются.
Итог
Either и Raise из Arrow.kt — не серебряная пуля. Это инструмент, который решает конкретную проблему: делает обработку ошибок type-safe и exhaustive. В нашем проекте это убило класс багов «забыли обработать исключение» и сделало код предсказуемым. Если вы устали от try-catch-лапши и непредсказуемых runtime-ошибок — попробуйте Either на одном сервисе. Вы удивитесь, насколько спокойно живётся, когда компилятор гарантирует, что все ошибки обработаны.
Пока нет комментариев
Будьте первым, кто оставит комментарий к этой статье