Когда обычный CRUD перестаёт работать: Введение в CQRS и Event Sourcing
Представьте, что вы пишете систему для банка. Каждую секунду сотни пользователей проверяют баланс, переводят деньги, открывают вклады. Ваше приложение на C# работает на стандартном Entity Framework с типичными CRUD операциями. И вдруг — база падает. Почему? Потому что один тяжёлый отчёт заблокировал таблицу, пока шла запись транзакции. Знакомо?
Разработчики сложных систем на C# рано или поздно упираются в потолок классической архитектуры. На помощь приходят два мощных паттерна: CQRS (Command Query Responsibility Segregation) и Event Sourcing. Давайте разберём их на практике — с живыми примерами, а не сухой теорией.
Что такое CQRS и почему это не "просто разделение"?
CQRS — это не про то, чтобы разложить методы по разным папкам. Это философия: команды (изменяют состояние) и запросы (читают состояние) должны жить отдельно. В классическом CRUD у вас одна модель данных и для чтения, и для записи. В CQRS — минимум две.
Проблема классического подхода
Допустим, у вас есть UserRepository с методом GetUserById. Когда вы читаете пользователя для вывода на экран, вам нужны 3 поля. Когда вы обновляете — 10 полей и проверки. В результате одна модель превращается в монстра с null полями и запутанными мапперами.
В CQRS вы разделяете ответственность:
- Command — выполняет действие (например,
CreateOrderCommand) - Query — возвращает данные (например,
GetOrderQuery) - Read Model — плоская DTO, заточенная под конкретный экран
- Write Model — богатая доменная модель с бизнес-логикой
Пишем CQRS на C# с MediatR
Самый популярный способ реализовать CQRS в C# — использовать библиотеку MediatR. Она реализует паттерн Mediator и идеально ложится на концепцию команд и запросов.
Шаг 1: Определяем команду
public record CreateOrderCommand(
Guid CustomerId,
List<OrderItemDto> Items,
string ShippingAddress
) : IRequest<Guid>; // Возвращаем ID созданного заказа
Шаг 2: Обработчик команды
public class CreateOrderCommandHandler : IRequestHandler<CreateOrderCommand, Guid>
{
private readonly IOrderRepository _orderRepository;
private readonly IUnitOfWork _unitOfWork;
public CreateOrderCommandHandler(IOrderRepository orderRepository, IUnitOfWork unitOfWork)
{
_orderRepository = orderRepository;
_unitOfWork = unitOfWork;
}
public async Task<Guid> Handle(CreateOrderCommand request, CancellationToken cancellationToken)
{
// Бизнес-логика: проверка, валидация, создание
var order = new Order(request.CustomerId, request.ShippingAddress);
foreach (var item in request.Items)
{
order.AddItem(item.ProductId, item.Quantity, item.Price);
}
await _orderRepository.AddAsync(order);
await _unitOfWork.SaveChangesAsync(cancellationToken);
return order.Id;
}
}
Шаг 3: Определяем запрос
public record GetOrderSummaryQuery(Guid OrderId) : IRequest<OrderSummaryDto>;
Шаг 4: Обработчик запроса (читает из отдельной БД!)
public class GetOrderSummaryQueryHandler : IRequestHandler<GetOrderSummaryQuery, OrderSummaryDto>
{
private readonly IReadDbContext _readDbContext; // Отдельный контекст для чтения
public GetOrderSummaryQueryHandler(IReadDbContext readDbContext)
{
_readDbContext = readDbContext;
}
public async Task<OrderSummaryDto> Handle(GetOrderSummaryQuery request, CancellationToken cancellationToken)
{
// Прямой SQL или Dapper для максимальной производительности
var sql = "SELECT Id, TotalAmount, Status, CreatedAt FROM OrderSummaries WHERE Id = @Id";
return await _readDbContext.Connection.QueryFirstOrDefaultAsync<OrderSummaryDto>(sql, new { Id = request.OrderId });
}
}
Event Sourcing: Сохраняем не состояние, а историю
Теперь добавим второй паттерн — Event Sourcing. Вместо того чтобы хранить текущее состояние объекта (например, "Заказ №5 = статус 'Отправлен'"), мы храним все события, которые с ним произошли: "Заказ создан", "Товар добавлен", "Заказ оплачен", "Заказ отправлен".
Преимущества очевидны:
- Полный аудит — вы знаете, кто и когда изменил данные
- Восстановление состояния — можно "отмотать" назад и посмотреть, как система выглядела в любой момент времени
- Согласованность — события иммутабельны, их нельзя изменить или удалить
Реализация Event Sourcing на C#
Вместо того чтобы обновлять запись в таблице, мы добавляем событие в Event Store. Это может быть обычная таблица SQL или специализированное хранилище (например, EventStoreDB).
// Базовое событие
public interface IEvent
{
Guid AggregateId { get; }
DateTime OccurredAt { get; }
}
// Конкретное событие
public record OrderCreatedEvent(
Guid AggregateId,
Guid CustomerId,
string ShippingAddress,
DateTime OccurredAt
) : IEvent;
public record OrderItemAddedEvent(
Guid AggregateId,
Guid ProductId,
int Quantity,
decimal Price,
DateTime OccurredAt
) : IEvent;
Агрегат, который восстанавливается из событий
public class Order : AggregateRoot
{
private List<OrderItem> _items = new();
private OrderStatus _status;
private string _shippingAddress;
// Восстанавливаем агрегат из истории событий
public void LoadFromHistory(IEnumerable<IEvent> events)
{
foreach (var @event in events)
{
Apply(@event);
}
}
// Применяем событие и меняем состояние
private void Apply(IEvent @event)
{
switch (@event)
{
case OrderCreatedEvent e:
_status = OrderStatus.Created;
_shippingAddress = e.ShippingAddress;
break;
case OrderItemAddedEvent e:
_items.Add(new OrderItem(e.ProductId, e.Quantity, e.Price));
break;
// Другие события...
}
}
// Команда -> проверка -> событие
public void AddItem(Guid productId, int quantity, decimal price)
{
if (_status != OrderStatus.Created)
throw new InvalidOperationException("Cannot add items to a non-created order");
var @event = new OrderItemAddedEvent(Id, productId, quantity, price, DateTime.UtcNow);
AddEvent(@event); // Сохраняем событие в списке "несохранённых"
Apply(@event); //