Когда я начал писать на 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, но именно они делают код предсказуемым и безопасным на уровне компиляции. Если вы ещё не используете этот паттерн — настоятельно рекомендую попробовать.
Пока нет комментариев
Будьте первым, кто оставит комментарий к этой статье