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

Ответ

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();

Ответ 18+ 🔞

А, Event Sourcing, говоришь? Ну это такая штука, когда ты не хранишь последнюю версию данных, как все нормальные люди, а сохраняешь каждое изменение как отдельное, блядь, событие. Представь, что у тебя не просто запись "на счету 100 рублей", а целая история: "открыл счет", "положил 50", "снял 20", "положил 70". И чтобы узнать текущий баланс, надо всю эту хуйню просуммировать. Звучит как овердохуища работы, да? Но зато ты можешь в любой момент посмотреть, а что там было вчера в 15:30, и кто последний мудак снял все деньги.

В чём соль, блядь?

  • События — это святое. Раз записали — нихуя не трогаем. Никаких "исправил", "удалил". История должна быть честной, как совесть Герасима после Муму.
  • Журнал событий — царь и бог. Это главная правда, всё остальное — производное.
  • Можно отмотать назад. Как в контроллере, когда проёбываешь уровень. Хочешь посмотреть, как всё было до того, как твой коллега накосячил с миграцией? Пожалуйста, откати события и смотри. Удобно, ёпта.

Что хорошего?

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

А где подвох?

  • Запросы нихуя не удобные. Спросить у журнала "покажи все счета, где баланс больше нуля" — это пиздец. Поэтому эту тему почти всегда используют с CQRS: пишем в один стрим событий, а для чтений держим отдельную, оптимизированную под запросы, копию данных (проекцию). Две работы, зато быстро.
  • Данных становится дохуя. Событий копится, как говна в хлеву. Нужно думать про снепшоты (сохранять состояние на определённый момент, чтобы не пересчитывать всё с нуля) и архивацию.
  • Голову сломать можно. Пока перестроишь мозги с "обновляю запись" на "генерирую событие", можешь поседеть. Это не для простых CRUD-приложений, где "сохранил форму и пошёл пить чай".

Ну и примерчик, чтобы не на словах (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();
// Пишем их в постоянное хранилище (тот самый event log)
eventStore.SaveEvents(account.Id, newEvents);
// Чистим список ожидающих событий
account.ClearPendingEvents();

Короче, паттерн мощный, но это как кувалдой гвозди забивать. Для простых задач — оверкилл и головная боль, а для сложных доменных процессов, где важна каждая изменение и аудит — иногда единственный адекватный способ не сойти с ума. Выбирай с умом, а то накосячишь — и будешь потом события вручную править, как дурак.