Антон Ларичев

Введение
CQRS и Event Sourcing — два архитектурных паттерна, которые часто упоминаются вместе, особенно в контексте микросервисов и сложных доменных моделей. На практике эти подходы могут кардинально улучшить масштабируемость и поддерживаемость системы, но только если применяются осознанно. В этой статье разберем, когда разделение команд и запросов действительно оправдано, а когда вы рискуете получить оверинжиниринг.
Мы рассмотрим реальные примеры на TypeScript с использованием NestJS, покажем типичные ошибки внедрения и дадим четкие критерии для принятия решения — нужны ли вашему проекту CQRS и Event Sourcing.
Что такое CQRS: разделение команд и запросов
CQRS (Command Query Responsibility Segregation) — паттерн, в котором операции записи (команды) и чтения (запросы) разделены на уровне архитектуры. Вместо единой модели данных вы получаете write model для обработки бизнес-логики и read model, оптимизированную под конкретные сценарии чтения.
// Команда — изменяет состояние
class CreateOrderCommand {
constructor(
public readonly userId: string,
public readonly items: OrderItem[],
public readonly shippingAddress: Address,
) {}
}
// Запрос — только читает данные
class GetOrdersByUserQuery {
constructor(public readonly userId: string) {}
}
// Обработчик команды — работает с write model
@CommandHandler(CreateOrderCommand)
class CreateOrderHandler implements ICommandHandler<CreateOrderCommand> {
constructor(private readonly orderRepository: OrderRepository) {}
async execute(command: CreateOrderCommand): Promise<string> {
const order = Order.create(command.userId, command.items, command.shippingAddress);
await this.orderRepository.save(order);
return order.id;
}
}
// Обработчик запроса — работает с read model
@QueryHandler(GetOrdersByUserQuery)
class GetOrdersByUserHandler implements IQueryHandler<GetOrdersByUserQuery> {
constructor(private readonly readDb: OrderReadRepository) {}
async execute(query: GetOrdersByUserQuery): Promise<OrderView[]> {
return this.readDb.findByUserId(query.userId);
}
}
Ключевое преимущество: write model может использовать нормализованную структуру с полной валидацией, а read model — денормализованные проекции, заточенные под конкретные экраны интерфейса.
Что такое Event Sourcing: хранилище событий вместо текущего состояния
Event Sourcing — подход, при котором состояние агрегата хранится не как текущий снимок, а как последовательность событий. Каждое изменение фиксируется как неизменяемое событие в event store, а текущее состояние восстанавливается путем воспроизведения всех событий.
// Определяем события домена
class OrderCreatedEvent {
constructor(
public readonly orderId: string,
public readonly userId: string,
public readonly items: OrderItem[],
public readonly createdAt: Date,
) {}
}
class OrderPaidEvent {
constructor(
public readonly orderId: string,
public readonly paymentId: string,
public readonly amount: number,
) {}
}
class OrderShippedEvent {
constructor(
public readonly orderId: string,
public readonly trackingNumber: string,
) {}
}
// Агрегат восстанавливает состояние из событий
class OrderAggregate {
private status: OrderStatus;
private items: OrderItem[] = [];
private totalAmount: number = 0;
// Восстановление состояния из цепочки событий
apply(event: DomainEvent): void {
if (event instanceof OrderCreatedEvent) {
this.status = OrderStatus.CREATED;
this.items = event.items;
this.totalAmount = this.calculateTotal(event.items);
} else if (event instanceof OrderPaidEvent) {
this.status = OrderStatus.PAID;
} else if (event instanceof OrderShippedEvent) {
this.status = OrderStatus.SHIPPED;
}
}
// Бизнес-логика генерирует новые события
pay(paymentId: string, amount: number): OrderPaidEvent {
if (this.status !== OrderStatus.CREATED) {
throw new Error('Заказ не может быть оплачен в текущем статусе');
}
if (amount < this.totalAmount) {
throw new Error('Сумма оплаты меньше стоимости заказа');
}
return new OrderPaidEvent(this.id, paymentId, amount);
}
}
Event Sourcing дает полный аудит-трейл, возможность временных запросов (какое состояние было в прошлый четверг?) и воспроизведение событий для построения новых проекций.
Как реализовать CQRS в NestJS
NestJS предоставляет встроенный модуль @nestjs/cqrs, который реализует базовую инфраструктуру для CQRS. Подключение занимает несколько минут:
import { Module } from '@nestjs/common';
import { CqrsModule } from '@nestjs/cqrs';
@Module({
imports: [CqrsModule],
providers: [
// Обработчики команд
CreateOrderHandler,
CancelOrderHandler,
// Обработчики запросов
GetOrdersByUserHandler,
GetOrderDetailsHandler,
// Обработчики событий — строят read model
OrderCreatedProjection,
OrderPaidProjection,
],
})
export class OrderModule {}
Проекция подписывается на доменные события и обновляет read model:
@EventsHandler(OrderCreatedEvent)
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,
itemCount: event.items.length,
status: 'created',
createdAt: event.createdAt,
});
}
}
Для Event Sourcing понадобится дополнительное хранилище событий — EventStoreDB, PostgreSQL с таблицей событий или библиотека @ocoda/event-sourcing.
Когда CQRS и Event Sourcing оправданы
Есть четкие признаки того, что эти паттерны принесут пользу вашему проекту:
Сложный домен с богатой бизнес-логикой. Если ваша система обрабатывает финансовые транзакции, управляет складом или координирует логистику — события являются естественным языком домена. «Заказ создан», «Платеж получен», «Товар отгружен» — это не технические абстракции, а реальные бизнес-факты.
Необходимость аудита. Если регулятор или бизнес требует полную историю изменений — event sourcing решает эту задачу архитектурно, а не костылями в виде триггеров и лог-таблиц.
Разные требования к чтению и записи. Когда на одну запись приходится тысяча чтений, или когда для чтения нужны десятки различных представлений одних и тех же данных — CQRS позволяет масштабировать read model независимо.
Микросервисная архитектура. В распределенной системе события становятся контрактом между сервисами. Event sourcing обеспечивает eventually consistent интеграцию без двухфазных коммитов.
Когда это оверинжиниринг: признаки избыточной сложности
Не менее важно понимать, когда CQRS и Event Sourcing создадут больше проблем, чем решат:
CRUD-приложения с простой логикой. Если ваш сервис — это справочник с операциями создания, чтения, обновления и удаления, стандартный репозиторий с ORM справится лучше. Разделение на команды и запросы здесь не даст выигрыша, но добавит слои абстракции.
MVP и прототипы. На этапе проверки гипотезы скорость итераций важнее архитектурной чистоты. Event sourcing требует продумывания схемы событий заранее — а в MVP домен еще не стабилен.
Маленькая команда без опыта. CQRS и Event Sourcing требуют понимания eventual consistency, идемпотентности, версионирования событий и построения проекций. Если команда из двух джунов — начните с монолита и простого CRUD.
Нет требований к аудиту и аналитике. Если никому не нужна история изменений, а все запросы работают с текущим состоянием — event store становится ненужной прослойкой.
Частые ошибки при внедрении Event Sourcing
Применение ко всей системе сразу. Event sourcing не обязан покрывать каждый агрегат. Выделите bounded context, где он приносит максимальную пользу (платежи, заказы), и оставьте CRUD для справочников и настроек.
Игнорирование снэпшотов. Когда у агрегата накапливается тысяча событий, восстановление состояния замедляется. Снэпшоты (периодическое сохранение текущего состояния) решают эту проблему:
class SnapshotStore {
async saveSnapshot(aggregateId: string, version: number, state: any): Promise<void> {
await this.db.upsert({
aggregateId,
version,
state: JSON.stringify(state),
createdAt: new Date(),
});
}
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();
events.forEach((e) => aggregate.apply(e));
return aggregate;
}
}
Отсутствие версионирования событий. Бизнес-требования меняются, и схема событий тоже. Без стратегии миграции (upcasting) старые события станут несовместимы с новым кодом.
Заключение
CQRS и Event Sourcing — мощные паттерны, но не серебряная пуля. Используйте их в сложных доменах с высокими требованиями к аудиту, масштабируемости и аналитике. Начинайте с малого: внедрите CQRS в одном bounded context, убедитесь, что команда понимает eventual consistency, и только потом расширяйте. Если ваш проект — это CRUD-сервис без сложной бизнес-логики, не тратьте время на event sourcing. Лучшая архитектура — та, которая решает реальные задачи, а не демонстрирует знание паттернов.






Комментарии
0