CQRS и Event Sourcing в Kotlin: архитектура для безупречного кода

Онлайн-тренажер Python для начинающих

Изучайте Python легко и без перегрузки теорией. Решайте практические задачи с автоматической проверкой, получайте подсказки на русском языке и пишите код прямо в браузере — без необходимости что-либо устанавливать.

Начать курс

Почему CQRS и Event Sourcing — это не просто модные слова

Представьте, что ваш код — это огромный музей. Обычно мы храним только последнюю версию экспоната: "вот как выглядит заказ сейчас". Но что, если мы хотим знать, как заказ оказался в этом состоянии? Кто нажал кнопку "Отправить"? Когда изменился статус? Именно здесь на сцену выходят CQRS (Command Query Responsibility Segregation) и Event Sourcing в Kotlin.

В этом руководстве мы не просто скопируем теорию из Wikipedia. Мы разберём реальные примеры, напишем код и поймём, как эти паттерны превращают хаос CRUD-операций в стройную архитектуру. Если вы устали от "спагетти-кода" и хотите строить системы, которые легко масштабировать и отлаживать — вы по адресу.

Что такое CQRS простыми словами

В стандартной архитектуре мы используем одни и те же модели для чтения и записи данных. Это похоже на то, как если бы вы ели суп и одновременно мешали его той же ложкой. CQRS предлагает разделить ответственность:

  • Command (Команда) — изменяет состояние. Запросы на запись (Create, Update, Delete).
  • Query (Запрос) — читает состояние. Только SELECT-операции.

Зачем это нужно? Представьте интернет-магазин. Когда пользователь оформляет заказ, мы должны проверить склад, списать товар, создать запись в БД, отправить email. Это сложная команда. А когда клиент просто смотрит список своих заказов — это простой запрос. Смешивать их в одном методе — верный путь к багам.

Преимущества CQRS в Kotlin

  • Производительность: можно оптимизировать чтение и запись по-разному (разные БД, кэши, индексы).
  • Безопасность: разделение прав доступа (админ пишет, пользователь читает).
  • Масштабирование: команды и запросы можно разносить на разные серверы.

Event Sourcing: сохраняем историю, а не факты

Если CQRS разделяет операции, то Event Sourcing меняет способ хранения данных. Вместо того чтобы хранить текущее состояние (например, "баланс: 1000 рублей"), мы храним последовательность событий: "Пополнение на 500", "Покупка на 200", "Списание комиссии 50".

Текущее состояние — это просто результат применения всех событий по порядку. Это даёт суперсилу: мы можем "отмотать" время назад, восстановить состояние на любую дату или проанализировать поведение пользователей.

Пример из жизни: банковский счёт

// Событие
data class MoneyDeposited(val amount: BigDecimal, val timestamp: Instant)
data class MoneyWithdrawn(val amount: BigDecimal, val timestamp: Instant)

// Агрегат (сущность)
class BankAccount {
    private var balance: BigDecimal = BigDecimal.ZERO
    private val events = mutableListOf<Any>()

    fun deposit(amount: BigDecimal) {
        if (amount <= BigDecimal.ZERO) throw IllegalArgumentException("Сумма должна быть положительной")
        val event = MoneyDeposited(amount, Instant.now())
        apply(event)
        events.add(event)
    }

    fun withdraw(amount: BigDecimal) {
        if (amount > balance) throw IllegalStateException("Недостаточно средств")
        val event = MoneyWithdrawn(amount, Instant.now())
        apply(event)
        events.add(event)
    }

    private fun apply(event: Any) {
        when (event) {
            is MoneyDeposited -> balance += event.amount
            is MoneyWithdrawn -> balance -= event.amount
        }
    }

    fun getBalance(): BigDecimal = balance
    fun getUncommittedEvents(): List<Any> = events.toList()
}

Как CQRS и Event Sourcing работают вместе

Это классический тандем. CQRS отвечает за разделение каналов, а Event Sourcing — за хранение истории. Команда порождает событие, событие сохраняется в Event Store (специальное хранилище), а затем проецируется в read-модель для быстрого чтения.

В Kotlin эта связка выглядит элегантно благодаря корутинам и типам-суммам (sealed class).

Архитектура на пальцах

  1. Command Handler получает команду (например, "Создать заказ").
  2. Проверяет бизнес-правила (товар в наличии, пользователь авторизован).
  3. Создаёт событие ("Заказ создан") и сохраняет в Event Store.
  4. Event Bus публикует событие.
  5. Projector (проектор) подписывается на событие и обновляет read-модель (таблицу в PostgreSQL или коллекцию в MongoDB).
  6. Query Handler просто читает из read-модели.

Пишем код на Kotlin: реальный пример

Давайте создадим простую систему управления задачами (Todo List) с CQRS и Event Sourcing. Мы будем использовать Kotlin с корутинами и Exposed для БД (но можно и JPA).

Шаг 1: Определяем команды и события

import java.time.Instant
import java.util.UUID

// Команды (запросы на изменение)
sealed class TaskCommand {
    data class CreateTask(val id: UUID, val title: String, val description: String) : TaskCommand()
    data class CompleteTask(val id: UUID) : TaskCommand()
    data class ChangeTitle(val id: UUID, val newTitle: String) : TaskCommand()
}

// События (что произошло)
sealed class TaskEvent {
    data class TaskCreated(val id: UUID, val title: String, val description: String, val createdAt: Instant) : TaskEvent()
    data class TaskCompleted(val id: UUID, val completedAt: Instant) : TaskEvent()
    data class TitleChanged(val id: UUID, val oldTitle: String, val newTitle: String) : TaskEvent()
}

Шаг 2: Агрегат задачи

class TaskAggregate {
    private var id: UUID? = null
    private var title: String? = null
    private var isCompleted: Boolean = false
    private val uncommittedEvents = mutableListOf<TaskEvent>()

    fun handle(command: TaskCommand) {
        when (command) {
            is TaskCommand.CreateTask -> {
                require(id == null) { "Задача уже существует" }
                apply(TaskEvent.TaskCreated(command.id, command.title, command.description, Instant.now()))
            }
            is TaskCommand.CompleteTask -> {
                require(!isCompleted) { "Задача уже выполнена" }
                apply(TaskEvent.TaskCompleted(command.id, Instant.now()))
            }
            is TaskCommand.ChangeTitle -> {
                val oldTitle = title ?: throw IllegalStateException("Задача не найдена")
                apply(TaskEvent.TitleChanged(command.id, oldTitle, command.newTitle))
            }
        }
    }

    private fun apply(event: TaskEvent) {
        when (event) {
            is TaskEvent.TaskCreated -> {
                id = event.id
                title =                

Блоги

Рекомендаций по книгам