Загрузка
Все статьи

Arrow.kt: функциональная обработка ошибок вместо исключений — опыт реального проекта

Try-catch по всему коду — это антипаттерн, но альтернативы кажутся сложными. Arrow.kt предлагает Either, Raise, и ValidationError для типобезопасной обработки ошибок. Перешли на функциональный подход в микросервисе с 40+ endpoint'ами и код стал предсказуемым. Показываю реальный до/после.

Я долго сопротивлялся функциональной обработке ошибок. «Исключения работают, зачем усложнять?» — это я два года назад. Потом мы попали в прод-инцидент: необработанное исключение внутри 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.toResponse(), чтобы не дублировать в каждом 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 на одном сервисе. Вы удивитесь, насколько спокойно живётся, когда компилятор гарантирует, что все ошибки обработаны.

Поделиться статьёй

Комментарии

0
0 / 2000

Пока нет комментариев

Будьте первым, кто оставит комментарий к этой статье

Читайте также

Micronaut vs Spring Boot — лёгкий фреймворк для Kotlin-микросервисов

Сравниваю Micronaut и Spring Boot для Kotlin-микросервисов: стартовое время, потребление памяти, холодный старт и реальный опыт миграции одного из сервисов.

12 июня 2026 11 мин

Kotlin: sealed interfaces и when-выражения — паттерн-матчинг как в Rust

Как sealed interfaces из Kotlin 1.5+ вместе с when-выражениями дают мощный паттерн-матчинг, похожий на Rust enum+match, и почему мы перешли на ADT для доменных событий.

11 июня 2026 9 мин

Kotlinx Serialization + Protobuf — быстрая сериализация для микросервисов

Разбираюсь с накладными расходами JSON-сериализации при высоких нагрузках и показываю, как интегрировать Protobuf с kotlinx-serialization в микросервисной архитектуре на Kotlin.

10 июня 2026 10 мин