CQRS и Event Sourcing в TypeScript: Архитектура для сложных систем

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

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

Начать курс

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

Вы когда-нибудь сталкивались с ситуацией, когда простая задача — "показать историю изменений заказа" — превращается в ад? Вы лезете в базу данных, смотрите на логи, пытаетесь восстановить, кто и когда изменил статус. А потом приходит запрос: "Сделайте аудит всех действий пользователя за последние три года". И вы понимаете — текущая архитектура этого не выдержит.

Привет, меня зовут PythonLib, и сегодня мы разберем два мощных паттерна, которые спасают в таких ситуациях: CQRS (Command Query Responsibility Segregation) и Event Sourcing. Мы напишем код на TypeScript, который можно сразу использовать в реальном проекте.

Если вы разрабатываете финтех, маркетплейсы, CRM или любые системы, где важна история и аудит — эта статья для вас.

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

Представьте библиотеку. Обычно один библиотекарь и выдает книги, и принимает их, и ведет учет. Но когда посетителей становится много — образуется очередь. CQRS предлагает разделить обязанности: один библиотекарь только выдает (команды), второй — только принимает и учитывает (запросы).

В программировании это означает разделение моделей для чтения и записи. Вместо одного монолитного репозитория мы создаем:

  • Command Model — для изменения данных (Create, Update, Delete)
  • Query Model — для чтения данных (Read)

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

Пример на TypeScript: Простой CQRS без Event Sourcing

Допустим, у нас есть система управления заказами. Сначала определим сущности:

// domain/order.ts
export interface Order {
  id: string;
  userId: string;
  items: OrderItem[];
  status: 'pending' | 'confirmed' | 'shipped' | 'cancelled';
  total: number;
  createdAt: Date;
  updatedAt: Date;
}

export interface OrderItem {
  productId: string;
  quantity: number;
  price: number;
}

Теперь создадим команду и запрос:

// application/commands/create-order.command.ts
export class CreateOrderCommand {
  constructor(
    public readonly userId: string,
    public readonly items: OrderItem[]
  ) {}
}

// application/commands/create-order.handler.ts
import { CommandHandler, ICommandHandler } from '@nestjs/cqrs';
import { OrderRepository } from '../repositories/order.repository';

@CommandHandler(CreateOrderCommand)
export class CreateOrderHandler implements ICommandHandler<CreateOrderCommand> {
  constructor(private readonly orderRepo: OrderRepository) {}

  async execute(command: CreateOrderCommand): Promise<string> {
    const order: Order = {
      id: uuid(),
      userId: command.userId,
      items: command.items,
      status: 'pending',
      total: command.items.reduce((sum, item) => sum + item.price * item.quantity, 0),
      createdAt: new Date(),
      updatedAt: new Date()
    };
    await this.orderRepo.save(order);
    return order.id;
  }
}

А для чтения — отдельный обработчик:

// application/queries/get-order.query.ts
export class GetOrderQuery {
  constructor(public readonly orderId: string) {}
}

// application/queries/get-order.handler.ts
import { QueryHandler, IQueryHandler } from '@nestjs/cqrs';
import { OrderReadRepository } from '../repositories/order-read.repository';

@QueryHandler(GetOrderQuery)
export class GetOrderHandler implements IQueryHandler<GetOrderQuery> {
  constructor(private readonly readRepo: OrderReadRepository) {}

  async execute(query: GetOrderQuery): Promise<Order | null> {
    return this.readRepo.findById(query.orderId);
  }
}

Видите? Команда сохраняет, запрос читает. Они могут использовать разные хранилища. Например, команда пишет в PostgreSQL, а запрос читает из Redis или Elasticsearch.

Event Sourcing: Сохраняем не состояние, а события

Теперь представьте, что вместо того, чтобы хранить текущее состояние заказа, мы храним все события, которые с ним произошли:

  • OrderCreated
  • ItemAdded
  • OrderConfirmed
  • OrderShipped
  • OrderCancelled

Это и есть Event Sourcing. Мы не обновляем строку в базе, мы добавляем новое событие в Event Store. Текущее состояние мы можем восстановить, "проиграв" все события заново.

Зачем это нужно?

Представьте, что клиент говорит: "Верните заказ в состояние, которое было до подтверждения". В CRUD — это боль. Нужно писать обратные миграции, копировать данные. В Event Sourcing — просто удаляем последнее событие и пересчитываем состояние.

Или аудит: "Покажите все изменения заказа". В CRUD — если нет логов, вы ничего не покажете. В Event Sourcing — вся история уже есть.

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

Сначала определим базовый класс для событий:

// domain/events/base-event.ts
export abstract class DomainEvent {
  public readonly eventId: string;
  public readonly aggregateId: string;
  public readonly aggregateType: string;
  public readonly eventType: string;
  public readonly timestamp: Date;
  public readonly data: any;

  constructor(aggregateId: string, aggregateType: string, eventType: string, data: any) {
    this.eventId = uuid();
    this.aggregateId = aggregateId;
    this.aggregateType = aggregateType;
    this.eventType = eventType;
    this.timestamp = new Date();
    this.data = data;
  }
}

// domain/events/order-created.event.ts
export class OrderCreatedEvent extends DomainEvent {
  constructor(aggregateId: string, data: { userId: string; items: OrderItem[]; total: number }) {
    super(aggregateId, 'Order', 'OrderCreated', data);
  }
}

Теперь сам агрегат — корень сущности, который восстанавливается из событий:

// domain/order-aggregate.ts
export class OrderAggregate {
  public id: string;
  public userId: string;
  public status: string;
  public total: number;
  private uncommittedEvents: DomainEvent[] = [];

  // Восстановление из истории
  static loadFromHistory(events: DomainEvent[]): OrderAggregate {
    const aggregate = new OrderAggregate();
    for (const event of events) {
      aggregate.applyEvent(event);
    }
    return aggregate;
  }

  // Команда: создать заказ
  createOrder(userId: string, items: OrderItem[]): void {
    const total = items.reduce((sum, item) => sum + item.price * item.quantity, 0);
    const event = new OrderCreatedEvent(this.id, { userId, items, total });
    this.applyEvent(event);
    this.uncommittedEvents.push(event);
  }

  // Применение события к состоянию
  private applyEvent(event: DomainEvent): void {
    switch (event.eventType) {
      case 'OrderCreated':
        this.id = event.aggregateId;
        this.userId = event.data.userId;
        this.total = event.data.total;
        this.status = 'pending';
        break;
      case 'OrderConfirmed':
        this.status = 'confirmed';
        break;
      // ... другие события
    }
  }

  getUncommittedEvents(): DomainEvent[] {
    return this.uncommittedEvents;
  }

  clearEvents(): void {
    this.uncommittedEvents = [];
  }
}

И, наконец, репозиторий, который работает с Event Store:

// infrastructure/event-store/order-event-store.ts                

Блоги

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