KotlinMiddleExperience

Расскажите о случае, когда особенности Kotlin повлияли на архитектурное решение, производительность или сопровождение проекта.

Особенности Kotlin (data class, sealed class, extension functions, корутины) регулярно влияют на архитектуру: sealed class делает конечные автоматы exhaustive, extension functions разделяют ответственность без наследования.

Как особенности Kotlin влияют на архитектуру

Kotlin — не просто «Java с синтаксическим сахаром». Ряд языковых особенностей напрямую определяет архитектурные решения в production-проектах.

Кейс 1: sealed class для конечных автоматов

В проекте на Ktor требовалось моделировать состояния платёжной транзакции. Java Enum не позволял нести данные. Kotlin sealed class решил задачу элегантно:

sealed class PaymentState {
    object Pending : PaymentState()
    data class Processing(val processorId: String) : PaymentState()
    data class Success(val transactionId: String, val amount: Long) : PaymentState()
    data class Failed(val code: String, val message: String) : PaymentState()
    data class Refunded(val refundId: String) : PaymentState()
}

fun handleState(state: PaymentState): String = when (state) {
    is PaymentState.Pending -> "Ожидание"
    is PaymentState.Processing -> "Обработка через ${state.processorId}"
    is PaymentState.Success -> "Успех: ${state.transactionId}"
    is PaymentState.Failed -> "Ошибка: ${state.message}"
    is PaymentState.Refunded -> "Возврат: ${state.refundId}"
    // Компилятор требует обработки всех случаев — exhaustive when
}

Архитектурный эффект: добавление нового состояния компилятор отмечает как ошибку во всех when-блоках без else — полная безопасность при рефакторинге.

Кейс 2: extension functions вместо наследования

Нужно было добавить метод toDomain() к сгенерированным Protobuf-классам без изменения их кода:

// Protobuf-класс из другого модуля (изменить нельзя)
// class UserProto { val id: String; val email: String }

// Extension function в domain-модуле
fun UserProto.toDomain(): User = User(
    id = UserId(this.id),
    email = Email(this.email)
)

// Extension на коллекции
fun List<UserProto>.toDomain(): List<User> = map { it.toDomain() }

Это позволило держать маппинг в domain-слое без загрязнения Protobuf-классов или создания отдельных Mapper-классов.

Кейс 3: корутины и архитектура репозитория

Kotlin Coroutines изменили интерфейс репозитория: вместо RxJava Observable или callback — suspend functions и Flow:

interface UserRepository {
    suspend fun getUser(id: UserId): User          // одноразовый запрос
    fun observeUser(id: UserId): Flow<User>        // поток изменений
    suspend fun updateUser(user: User): Unit
}

class UserRepositoryImpl(
    private val api: UserApi,
    private val dao: UserDao
) : UserRepository {

    override suspend fun getUser(id: UserId): User =
        withContext(Dispatchers.IO) {
            dao.getUser(id.value) ?: api.fetchUser(id.value).also {
                dao.insert(it)
            }
        }

    override fun observeUser(id: UserId): Flow<User> =
        dao.observeUser(id.value) // Room возвращает Flow
            .map { it.toDomain() }
            .flowOn(Dispatchers.IO)
}

Кейс 4: data class и иммутабельность

Иммутабельные data class упростили concurrent доступ к состоянию — не нужны locks для чтения:

@Immutable
data class AppConfig(
    val apiUrl: String,
    val timeoutMs: Long,
    val retries: Int
)

// Обновление через copy() — потокобезопасно для чтения
val newConfig = config.copy(timeoutMs = 10_000L)
_config.value = newConfig // атомарное обновление StateFlow

Подводные камни

  • data class copy() — поверхностное копирование; вложенные List/Map всё равно изменяемы.
  • sealed class из разных модулей требует rebuild всех зависимых при добавлении subclass.
  • Extension functions не имеют доступа к private-членам — иногда нужен refactoring для тестируемости.
  • companion object инициализируется при первом обращении к классу — lazy инициализация с побочными эффектами может удивить.
  • Flow без flowOn(Dispatchers.IO) выполняется на вызывающем диспетчере — блокирует Main поток.
  • when без else для не-sealed типов компилируется без ошибки — exhaustive проверка только для sealed.
  • operator fun invoke() на object может сбить с толку — выглядит как функция, является объектом.

What hurts your answer

  • Выдумывать опыт или говорить слишком общими фразами
  • Не объяснять свою личную роль в работе с Kotlin
  • Не показывать результат, метрики или извлечённые уроки

What they're listening for

  • Может подготовить честный пример использования Kotlin
  • Показывает свою роль, решения и результат
  • Умеет рефлексировать над trade-offs и уроками

Related topics