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

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

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

Начать курс

Когда обычный 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);    //                

Блоги

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