Почему обычные 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