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

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

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

Когда я начал писать на Rust, первой вещью, которой мне не хватало в Kotlin, был match с exhaustive checking. Вернувшись к Kotlin-проекту, обнаружил, что sealed interfaces (появились в Kotlin 1.5) + when дают практически тот же опыт. Сейчас у нас весь слой доменных событий построен на этом подходе, и я хочу показать, как это выглядит на практике.

Sealed classes vs sealed interfaces

Sealed classes в Kotlin существуют давно — они ограничивают иерархию наследования фиксированным набором подтипов. Но у sealed classes есть ограничение: все наследники должны быть вложены в сам класс. Sealed interfaces снимают это ограничение — наследники могут быть определены в любом файле, но в том же package (модуле с Kotlin 2.0+). Это критически важно для больших проектов, где вы не хотите держать десятки классов в одном файле.

// Sealed class — всё в одном месте
sealed class PaymentEvent {
    data class Initiated(val paymentId: String, val amount: Long) : PaymentEvent()
    data class Completed(val paymentId: String, val receiptUrl: String) : PaymentEvent()
    data class Failed(val paymentId: String, val reason: String) : PaymentEvent()
    data object Expired : PaymentEvent()
}

// Sealed interface — наследники могут быть в разных файлах
sealed interface DomainEvent {
    val eventId: String
    val timestamp: Instant
    val aggregateId: String
}

// В файле PaymentEvents.kt
data class PaymentInitiated(
    override val eventId: String,
    override val timestamp: Instant,
    override val aggregateId: String,
    val amount: Money,
    val paymentMethod: String
) : DomainEvent

// В файле OrderEvents.kt
data class OrderPlaced(
    override val eventId: String,
    override val timestamp: Instant,
    override val aggregateId: String,
    val items: List,
    val customerId: String
) : DomainEvent

Exhaustive when — компилятор как страховка

Главная сила sealed интерфейсов — компилятор знает все возможные варианты и требует обработать каждый из них в when. Добавили новый тип события? Компилятор подсветит все места, где нужно обновить обработку. Это особенно ценно в больших кодовых базах, где легко забыть обработать новый кейс:

fun handleEvent(event: DomainEvent): EventResult {
    return when (event) {
        is PaymentInitiated -> {
            logger.info { "Processing payment ${event.paymentId}" }
            paymentService.startProcessing(event)
            EventResult.Success
        }
        is PaymentCompleted -> {
            orderService.markAsPaid(event.aggregateId)
            notificationService.sendReceipt(event)
            EventResult.Success
        }
        is PaymentFailed -> {
            orderService.markAsPaymentFailed(event.aggregateId)
            notificationService.sendPaymentError(event)
            EventResult.Retry
        }
        is OrderPlaced -> {
            inventoryService.reserveItems(event.items)
            EventResult.Success
        }
        // Если добавим новый DomainEvent — здесь будет ошибка компиляции
    }
}

ADT-паттерн: алгебраические типы данных

Sealed интерфейсы позволяют реализовать паттерн Algebraic Data Types — когда тип описывает все возможные состояния сущности. Это функциональный подход, который делает состояния явными и несовместимыми друг с другом:

sealed interface OrderState {
    data class Draft(
        val items: List = emptyList()
    ) : OrderState

    data class PendingPayment(
        val items: List,
        val paymentId: String,
        val total: Money
    ) : OrderState

    data class Paid(
        val items: List,
        val paymentId: String,
        val total: Money,
        val paidAt: Instant
    ) : OrderState

    data class Shipped(
        val items: List,
        val trackingNumber: String,
        val shippedAt: Instant
    ) : OrderState

    data object Cancelled : OrderState
    data object Delivered : OrderState
}

// Функция, которая гарантированно обрабатывает все состояния
fun calculateDiscount(state: OrderState): BigDecimal {
    return when (state) {
        is OrderState.Draft -> BigDecimal.ZERO
        is OrderState.PendingPayment -> BigDecimal("0.05")
        is OrderState.Paid -> BigDecimal("0.03")
        is OrderState.Shipped -> BigDecimal("0.02")
        is OrderState.Cancelled -> BigDecimal.ZERO
        is OrderState.Delivered -> BigDecimal.ZERO
    }
}

when как выражение vs как оператор

В Kotlin when может быть и выражением, и оператором. Как выражение он обязан быть exhaustive — это и есть наш паттерн-матчинг. Как оператор (when { ... } без присваивания) — exhaustive checking не обязателен, но компилятор всё равно предупредит, если забыт else ветка для sealed типа. Я рекомендую всегда использовать when как выражение с sealed типами — это заставляет явно обрабатывать каждый случай.

Сравнение с Rust enum + match

Rust-подход лаконичнее — enum может содержать данные прямо в вариантах, без отдельных struct'ов. Но Kotlin-подход с sealed interface + data class гибче: каждый вариант — полноценный тип со своими companion object, extension-функциями и т.д. Плюс Kotlin даёт null-safety и Java-interop из коробки. Если бы мне пришлось выбрать — для нового бекенд-проекта на JVM я бы выбрал Kotlin, а для системного программирования — Rust. Но паттерн-матчинг в обоих случаях теперь на уровне.

// Rust-подход (для сравнения)
// enum OrderState {
//     Draft { items: Vec },
//     PendingPayment { items: Vec, payment_id: String },
//     Paid { ... },
// }
//
// match order_state {
//     OrderState::Draft { items } => { ... }
//     OrderState::PendingPayment { .. } => { ... }
// }

// Kotlin — то же самое, но с отдельными типами для каждого варианта
// Это плюс, когда вариант сложный и имеет свою логику
fun OrderState.displayText(): String = when (this) {
    is OrderState.Draft -> "Черновик (${items.size} товаров)"
    is OrderState.PendingPayment -> "Ожидает оплаты: ${total.format()}"
    is OrderState.Paid -> "Оплачен ${paidAt.formatDate()}"
    is OrderState.Shipped -> "Отправлен, трек: $trackingNumber"
    is OrderState.Cancelled -> "Отменён"
    is OrderState.Delivered -> "Доставлен"
}

Из продакшена: обработка результатов операций

Ещё один паттерн, который мы внедрили — Result-тип на основе sealed interface. Вместо бросания исключений мы возвращаем типизированный результат, и вызывающий код обязан обработать все сценарии:

sealed interface OperationResult {
    data class Success(val value: T) : OperationResult
    data class Failure(val error: DomainError, val retryable: Boolean) : OperationResult
    data object NotFound : OperationResult
    data object Conflict : OperationResult
}

suspend fun processOrder(command: ProcessOrderCommand): OperationResult {
    val order = orderRepository.findById(command.orderId)
        ?: return OperationResult.NotFound

    if (order.status != OrderStatus.DRAFT) {
        return OperationResult.Conflict
    }

    return try {
        val paymentId = paymentService.charge(order.total)
        OperationResult.Success(paymentId)
    } catch (e: PaymentException) {
        OperationResult.Failure(
            error = DomainError.PaymentFailed(e.message),
            retryable = e.isRetryable
        )
    }
}

// Вызывающий код обязан обработать все варианты
when (val result = processOrder(command)) {
    is OperationResult.Success -> respondOk(result.value)
    is OperationResult.Failure -> {
        if (result.retryable) scheduleRetry(command)
        respondError(result.error)
    }
    is OperationResult.NotFound -> respondNotFound()
    is OperationResult.Conflict -> respondConflict()
}

Sealed interfaces + when-выражения — один из самых недооценённых инструментов Kotlin. Они не такие «модные», как корутины или Flow, но именно они делают код предсказуемым и безопасным на уровне компиляции. Если вы ещё не используете этот паттерн — настоятельно рекомендую попробовать.

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

Комментарии

0
0 / 2000

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

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

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

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

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

12 июня 2026 11 мин

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

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

10 июня 2026 10 мин

Kotlin + gRPC: streaming и двусторонняя связь в продакшене

Реальный опыт использования gRPC streaming в продакшене на Kotlin: server-side, client-side и bidirectional стриминг для системы отслеживания статусов заказов в реальном времени.

9 июня 2026 12 мин