r3tam blog

CQRS и Event Sourcing в микросервисах: практическое руководство по внедрению в 2026

CQRS и Event Sourcing в микросервисах: практическое руководство по внедрению в 2026

Введение

Когда микросервисная архитектура перерастает стадию прототипа, команды неизбежно сталкиваются с проблемой: одна и та же модель данных пытается обслуживать и сложную бизнес-логику записи, и быстрые запросы чтения. Классический CRUD-подход перестаёт справляться — JOIN-ы становятся тормозными, блокировки таблиц мешают пользователям, а каждое изменение в доменной модели ломает десять отчётов. Именно в этот момент на сцену выходят CQRS и Event Sourcing.

«CQRS (Command Query Responsibility Segregation)» — это паттерн, разделяющий операции изменения состояния (команды) и чтения данных (запросы) на уровне архитектуры. «Event Sourcing» — подход, при котором источником правды становится не текущее состояние объекта, а неизменяемая последовательность событий, которые к этому состоянию привели. Вместе эти паттерны образуют фундамент для построения масштабируемых, аудитируемых и отказоустойчивых распределённых систем.

В этой статье мы разберём, как работает CQRS и Event Sourcing на практике, когда их применение оправдано, а когда — оверинжиниринг. Вы увидите реальные примеры на TypeScript с использованием NestJS, научитесь выстраивать архитектуру проекций и узнаете о типичных ошибках внедрения. Материал основан на опыте продакшн-внедрений в финтехе, e-commerce и SaaS-платформах.

Что такое CQRS: разделяем команды и запросы

CQRS — это эволюция принципа CQS (Command Query Separation), сформулированного Бертраном Мейером: каждый метод должен либо изменять состояние, либо возвращать данные, но не оба действия одновременно. Грег Янг в 2010 году поднял этот принцип на архитектурный уровень, предложив разделять не методы, а целые модели данных.

В традиционном подходе одна сущность (например, Order) используется и для записи, и для чтения. При сохранении заказа нужна валидация, проверка бизнес-правил и транзакционная целостность. При отображении списка заказов — скорость, денормализация и минимум JOIN-ов. Попытка оптимизировать модель под обе задачи приводит к компромиссам, которые вредят и тому, и другому.

CQRS предлагает радикальное решение: создать две отдельные модели. Write Model отвечает за бизнес-логику, проверку инвариантов и сохранение данных. Read Model — это денормализованные проекции, оптимизированные под конкретные сценарии чтения. Они могут храниться в разных базах данных, масштабироваться независимо и эволюционировать с разной скоростью.

Как выглядит CQRS в коде

Рассмотрим пример на TypeScript с использованием модуля @nestjs/cqrs. Команда — это простой объект с данными, без логики:

// Команда — намерение изменить состояние
export class CreateOrderCommand {
  constructor(
    public readonly userId: string,
    public readonly items: OrderItem[],
    public readonly shippingAddress: Address,
  ) {}
}

// Запрос — намерение прочитать данные
export class GetOrdersByUserQuery {
  constructor(public readonly userId: string) {}
}

Обработчик команды содержит бизнес-логику: проверку пользователя, доступность товаров, расчёт стоимости:

@CommandHandler(CreateOrderCommand)
export class CreateOrderHandler implements ICommandHandler<CreateOrderCommand> {
  constructor(
    private readonly orderRepository: OrderRepository,
    private readonly inventoryService: InventoryService,
    private readonly eventBus: EventBus,
  ) {}

  async execute(command: CreateOrderCommand): Promise<void> {
    const user = await this.userRepository.findById(command.userId);
    if (!user) throw new UserNotFoundException();

    const availability = await this.inventoryService.checkAvailability(command.items);
    if (!availability.allAvailable) throw new InsufficientInventoryException();

    const order = Order.create(command.userId, command.items, command.shippingAddress);
    await this.orderRepository.save(order);
    this.eventBus.publishAll(order.getUncommittedEvents());
  }
}

Обработчик запроса, напротив, максимально прост — только чтение данных без бизнес-логики:

@QueryHandler(GetOrdersByUserQuery)
export class GetOrdersByUserHandler implements IQueryHandler<GetOrdersByUserQuery> {
  constructor(private readonly readDb: OrderReadRepository) {}

  async execute(query: GetOrdersByUserQuery): Promise<OrderView[]> {
    return this.readDb.findByUserId(query.userId);
  }
}

Ключевое отличие: команда не возвращает данные, а запрос не изменяет состояние. Это делает систему предсказуемой и тестируемой.

Event Sourcing: события как единственный источник правды

Event Sourcing меняет сам принцип хранения данных. Вместо того чтобы сохранять текущее состояние объекта, система сохраняет последовательность событий, которые к нему привели. Текущее состояние восстанавливается путём последовательного применения всех событий к пустому агрегату.

Представьте банковский счёт. Традиционный подход: Account { balance: 50000 }. Event Sourcing: AccountOpened { amount: 0 } → Deposited { amount: 100000 } → Withdrawn { amount: 30000 } → Withdrawn { amount: 20000 }. Баланс в 50 000 — это не хранимое значение, а вычисленный результат применения четырёх событий.

Реализация Event Sourcing на TypeScript

// Определяем события домена
export class OrderCreatedEvent {
  constructor(
    public readonly orderId: string,
    public readonly userId: string,
    public readonly items: OrderItem[],
    public readonly createdAt: Date,
  ) {}
}

export class OrderPaidEvent {
  constructor(
    public readonly orderId: string,
    public readonly paymentId: string,
    public readonly amount: number,
  ) {}
}

// Агрегат восстанавливает состояние из событий
export class OrderAggregate {
  private id: string;
  private status: OrderStatus;
  private items: OrderItem[] = [];
  private version: number = 0;

  apply(event: DomainEvent): void {
    if (event instanceof OrderCreatedEvent) {
      this.id = event.orderId;
      this.status = OrderStatus.CREATED;
      this.items = event.items;
    } else if (event instanceof OrderPaidEvent) {
      this.status = OrderStatus.PAID;
    }
    this.version++;
  }

  pay(paymentId: string, amount: number): OrderPaidEvent {
    if (this.status !== OrderStatus.CREATED) {
      throw new Error('Заказ нельзя оплатить в текущем статусе');
    }
    return new OrderPaidEvent(this.id, paymentId, amount);
  }
}

Главное преимущество Event Sourcing — полная воспроизводимость состояния на любой момент времени. Вы можете ответить на вопрос «как выглядела заявка до прихода скоринга?» или расследовать инцидент, просто перечитав события. Кроме того, события служат естественным контрактом для интеграции микросервисов.

Снапшоты: оптимизация восстановления

Без оптимизации восстановление агрегата с тысячью событий будет выполняться всё дольше. Решение — периодические снапшоты текущего состояния:

export class SnapshotStore {
  async saveSnapshot(aggregateId: string, version: number, state: unknown): Promise<void> {
    await this.db.upsert({ aggregateId, version, state: JSON.stringify(state) });
  }

  async loadAggregate(aggregateId: string): Promise<OrderAggregate> {
    const snapshot = await this.getLatestSnapshot(aggregateId);
    const events = await this.eventStore.getEvents(aggregateId, snapshot?.version ?? 0);
    const aggregate = snapshot
      ? OrderAggregate.fromSnapshot(snapshot.state)
      : new OrderAggregate();
    for (const event of events) aggregate.apply(event);
    return aggregate;
  }
}

Когда CQRS и Event Sourcing оправданы, а когда — нет

Одна из самых частых ошибок — применение CQRS и Event Sourcing «на всякий случай». Эти паттерны добавляют сложность, и их использование должно быть осознанным.

Признаки, что паттерны нужны

Сложный домен с богатой бизнес-логикой. Если ваша система обрабатывает финансовые транзакции, управляет складом или координирует логистику — события являются естественным языком домена. «Заказ создан», «Платёж получен», «Товар отгружен» — это не технические абстракции, а реальные бизнес-факты.

Необходимость аудита. Если регулятор или бизнес требует полную историю изменений — Event Sourcing решает эту задачу архитектурно, без костылей в виде триггеров и лог-таблиц.

Диспропорция чтения и записи. Когда на одну запись приходится тысяча чтений или для отображения нужны десятки различных представлений одних и тех же данных — CQRS позволяет масштабировать read model независимо.

Микросервисная архитектура. В распределённой системе события становятся контрактом между сервисами. Event Sourcing обеспечивает согласованность в конечном счёте (eventual consistency) без распределённых транзакций.

Когда это оверинжиниринг

CRUD-приложения с простой логикой. Если ваш сервис — это справочник с операциями создания, чтения, обновления и удаления, стандартный репозиторий с ORM справится лучше. Разделение на команды и запросы добавит слои абстракции без выгоды.

MVP и прототипы. На этапе проверки гипотезы скорость итераций важнее архитектурной чистоты. Event Sourcing требует продумывания схемы событий заранее, а в MVP домен ещё не стабилен.

Маленькая команда без опыта. CQRS и Event Sourcing требуют понимания eventual consistency, идемпотентности, версионирования событий. Если команда состоит из двух джуниор-разработчиков — начните с монолита и простого CRUD.

Outbox Pattern: гарантированная доставка событий

Ключевая проблема при асинхронной синхронизации: как гарантировать, что событие будет отправлено, если транзакция с базой данных прошла успешно? Если сначала сохранить агрегат, а потом отправить событие в брокер, между этими операциями есть окно отказа — брокер может быть недоступен.

Outbox Pattern решает эту проблему элегантно: событие пишется в ту же базу данных, в отдельную таблицу outbox, в рамках одной транзакции с сохранением агрегата. Отдельный воркер (публикатор) читает из этой таблицы и отправляет события в брокер, удаляя успешно отправленные записи.

async execute(command: CreateOrderCommand): Promise<void> {
  const order = Order.create(command.userId, command.items, command.shippingAddress);

  // Одна транзакция: агрегат + outbox
  await this.dataSource.transaction(async (manager) => {
    await manager.save(order);
    await manager.save(new OutboxMessage({
      aggregateId: order.id,
      eventType: 'OrderCreated',
      payload: serializeEvent(order.getUncommittedEvents()[0]),
    }));
  });
}

Сервис-публикатор периодически опрашивает таблицу outbox и отправляет непрочитанные сообщения в брокер. Это гарантирует at-least-once доставку. ДляProduction-систем рекомендуется also использовать PostgreSQL с логической репликацией или Debezium для Change Data Capture.

Проекции: строим read model

Read model в CQRS строится с помощью проекторов — обработчиков событий, которые обновляют денормализованные представления данных. Каждый проектор отвечает за свою проекцию и может использовать свою базу данных.

@EventsHandler(OrderCreatedEvent)
export class OrderCreatedProjection implements IEventHandler<OrderCreatedEvent> {
  constructor(private readonly readDb: OrderReadRepository) {}

  async handle(event: OrderCreatedEvent): Promise<void> {
    await this.readDb.upsert({
      orderId: event.orderId,
      userId: event.userId,
      status: 'created',
      itemCount: event.items.length,
      total: this.calculateTotal(event.items),
      customerName: await this.getUserName(event.userId),
      createdAt: event.createdAt,
    });
  }
}

Обратите внимание на денормализацию: customerName хранится прямо в проекции, хотя в нормализованной схеме это потребовало бы JOIN с таблицей пользователей. Здесь мы сознательно жертвуем нормализацией ради скорости чтения.

Идемпотентность проекций

В распределённых системах события могут приходить повторно (at-least-once гарантия). Без идемпотентности повторная обработка события OrderCreated создаст дубликат записи:

async handle(event: OrderCreatedEvent): Promise<void> {
  // Идемпотентность: проверяем, не обработано ли событие
  const existing = await this.readDb.findById(event.orderId);
  if (existing) return;

  await this.readDb.create({ ... });
}

CQRS + Event Sourcing в микросервисной архитектуре

В контексте микросервисов CQRS и Event Sourcing раскрываются наиболее полно. Каждый сервис может иметь собственную write model и неограниченное количество read model, оптимизированных под конкретных потребителей.

Типичная архитектура выглядит так:

  • Command Service — принимает команды, валидирует их, выполняет бизнес-логику, сохраняет события в Event Store и публикует их в брокер (Kafka, RabbitMQ, NATS).
  • Event Store — PostgreSQL, EventStoreDB или Kafka как хранилище событий. Единственный источник правды.
  • Projectors — подписываются на события и обновляют read model в специализированных базах: Elasticsearch для поиска, ClickHouse для аналитики, Redis для кэша.
  • Query Service — обслуживает запросы чтения из read model, не содержит бизнес-логики.

API Gateway маршрутизирует запросы: POST/PUT/DELETE направляются в Command Service, GET — в Query Service. Это позволяет независимо масштабировать обе стороны.

Saga Pattern для распределённых транзакций

В распределённой системе одна бизнес-операция может затрагивать несколько сервисов. Saga Pattern координирует выполнение локальных транзакций и, в случае отказа, запускает компенсирующие действия в обратном порядке. Для CQRS-системы это означает: обработчик команды на стороне заказа публикует событие OrderCreated, сервис платежей ловит его и либо проводит платёж, либо публикует PaymentFailed — после чего сервис заказа запускает компенсацию.

Типичные ошибки при внедрении

Даже понимая теорию, команды часто совершают одни и те же ошибки. Вот самые распространённые:

Применение Event Sourcing ко всей системе сразу. Выделите bounded context, где он приносит максимальную пользу — платежи, заказы, документы. Оставьте CRUD для справочников и настроек.

Отсутствие версионирования событий. Бизнес-требования меняются, и схема событий тоже. Без стратегии миграции (upcasting) старые события станут несовместимы с новым кодом. Храните версию события и поддерживайте трансляцию из старых версий в новые.

Игнорирование Consumer Lag. Read model отстаёт от write model на неизвестное время. Мониторьте лаг проекций через метрики — если он растёт, проекторы не справляются или брокер недоступен. В production-системах критично иметь алерты на превышение порога лага.

Отсутствие мониторинга outbox. Если публикатор outbox упал, события перестанут доставляться, а read model замёрзнет. Добавьте healthcheck для публикатора и алерт на размер outbox-таблицы: если она растёт — что-то сломалось.

Заключение

CQRS и Event Sourcing — это не серебряная пуля, а мощные инструменты для решения конкретных задач. CQRS даёт гибкость в оптимизации чтения и записи, а Event Sourcing обеспечивает полный аудит и воспроизводимость состояния. Вместе они позволяют строить системы, которые масштабируются горизонтально, эволюционируют без страха сломать существующую функциональность и удовлетворяют самым строгим требованиям регуляторов.

Начинайте с малого: внедрите CQRS в одном bounded context, где есть явная диспропорция между чтением и записью. Убедитесь, что команда понимает последствия eventual consistency. Добавляйте Event Sourcing только тогда, когда бизнес действительно требует полной истории изменений. И помните: лучшая архитектура — та, которая решает реальные задачи вашего бизнеса, а не демонстрирует знание модных паттернов.

FAQ

Нужен ли Event Sourcing, если я использую CQRS?

Нет, это независимые паттерны. CQRS можно реализовать без Event Sourcing, используя синхронную репликацию или материализованные представления. Event Sourcing добавляется, когда нужен полный аудит и временные запросы.

Какую базу данных использовать для Event Store?

PostgreSQL — отличный выбор для старта: он поддерживает ACID-транзакции и JSONB для гибкого хранения событий. Для высоконагруженных систем — EventStoreDB или Kafka.

Что делать, если read model отстала от write model?

Event Sourcing позволяет перестроить read model с нуля: остановите проектор, исправьте код обработчика, удалите старую проекцию и запустите воспроизведение всех событий заново. Это ключевое преимущество перед традиционными подходами.

Как часто нужно делать снапшоты?

Оптимальная частота зависит от количества событий на агрегат. Практическое правило: делайте снапшот каждые 50–100 событий или один раз в день для высоконагруженных агрегатов.

Какие альтернативы CQRS существуют?

Materialized Views в PostgreSQL, паттерн «CQRS без Event Sourcing» с синхронной проекцией, CQRS с CDC (Change Data Capture) через Debezium. Выбор зависит от требований к консистентности и сложности домена.