Что такое паттерн Event Sourcing?

«Что такое паттерн Event Sourcing?» — вопрос из категории Архитектура, который задают на 25% собеседований C# Разработчик. Ниже — развёрнутый ответ с разбором ключевых моментов.

Ответ

Event Sourcing (Источники событий) — это архитектурный паттерн, при котором состояние приложения определяется не как последний «снимок» данных, а как последовательность всех событий (история изменений), которые привели к этому состоянию. Вместо обновления записи в БД каждое изменение сохраняется как неизменяемое событие (event). Текущее состояние вычисляется путём применения (проецирования) всей цепочки событий.

Ключевые принципы:

  • Неизменяемость событий: События, будучи сохранёнными, никогда не изменяются и не удаляются.
  • Единственный источник истины: Журнал событий (event log) является первичным и наиболее достоверным хранилищем данных.
  • Воспроизводимость: Любое состояние может быть восстановлено «с нуля» путём повторного применения событий, что обеспечивает мощные возможности для отладки, аудита и миграции.

Преимущества:

  • Полный аудит: Вся история изменений доступна по умолчанию.
  • Временные запросы: Можно «отмотать» состояние приложения к любому моменту в прошлом.
  • Гибкость: Новые представления данных (проекции) могут быть созданы из существующего потока событий без изменения основной логики.
  • Согласованность: Упрощает реализацию сложных бизнес-транзакций и реакцию на изменения.

Недостатки и сложности:

  • Сложность запросов: Прямые запросы к журналу событий неэффективны. Паттерн почти всегда используется в связке с CQRS (Command Query Responsibility Segregation), где для чтений создаются оптимизированные проекции (read models).
  • Рост данных: Требует стратегий для управления постоянно растущим журналом событий (снепшоты, архивация).
  • Сложность освоения: Требует сдвига парадигмы в мышлении о данных.

Пример на C# (Упрощённая модель банковского счёта):

// 1. Определяем события (неизменяемые объекты-записи)
public abstract record AccountEvent(Guid AccountId, DateTime OccurredAt);
public record AccountOpened(Guid AccountId, string Owner, DateTime OccurredAt) : AccountEvent(AccountId, OccurredAt);
public record MoneyDeposited(Guid AccountId, decimal Amount, DateTime OccurredAt) : AccountEvent(AccountId, OccurredAt);
public record MoneyWithdrawn(Guid AccountId, decimal Amount, DateTime OccurredAt) : AccountEvent(AccountId, OccurredAt);

// 2. Агрегат, который обрабатывает команды и генерирует события
public class BankAccount
{
    public Guid Id { get; private set; }
    public string Owner { get; private set; } = string.Empty;
    public decimal Balance { get; private set; }
    private readonly List<AccountEvent> _pendingEvents = new();

    // Восстановление состояния из истории событий
    public BankAccount(IEnumerable<AccountEvent> pastEvents)
    {
        foreach (var evt in pastEvents)
            Apply(evt);
    }

    // Метод для применения события (изменяет состояние)
    private void Apply(AccountEvent evt)
    {
        switch (evt)
        {
            case AccountOpened opened:
                Id = opened.AccountId;
                Owner = opened.Owner;
                Balance = 0m;
                break;
            case MoneyDeposited deposited:
                Balance += deposited.Amount;
                break;
            case MoneyWithdrawn withdrawn:
                Balance -= withdrawn.Amount;
                break;
        }
    }

    // Команды: проверяют бизнес-правила и создают новые события
    public void Open(string owner)
    {
        if (Id != Guid.Empty) throw new InvalidOperationException("Account already open.");
        var evt = new AccountOpened(Guid.NewGuid(), owner, DateTime.UtcNow);
        _pendingEvents.Add(evt);
        Apply(evt);
    }

    public void Deposit(decimal amount)
    {
        if (amount <= 0) throw new ArgumentException("Deposit amount must be positive.");
        var evt = new MoneyDeposited(Id, amount, DateTime.UtcNow);
        _pendingEvents.Add(evt);
        Apply(evt);
    }

    // Возвращает новые, ещё не сохранённые события
    public IReadOnlyList<AccountEvent> GetPendingEvents() => _pendingEvents.AsReadOnly();
    public void ClearPendingEvents() => _pendingEvents.Clear();
}

// 3. Использование
// Восстановление счёта из хранилища событий
var pastEvents = eventStore.LoadEvents(accountId);
var account = new BankAccount(pastEvents);

// Выполнение новой команды
account.Deposit(100);

// Сохранение новых событий
var newEvents = account.GetPendingEvents();
eventStore.SaveEvents(account.Id, newEvents);
account.ClearPendingEvents();