Gymterview
senior

Что такое Event Sourcing?

Event Sourcing — это паттерн хранения данных, при котором состояние объекта определяется не текущим снимком (snapshot), а последовательностью доменных событий, которые произошли с этим объектом.

Аналогия из жизни: банковская выписка. Вместо хранения только текущего баланса хранится вся история операций. Баланс в любой момент можно вычислить, «проиграв» все транзакции с начала.

Вместо хранения «баланс счёта = 1000 руб.» хранится:

Пример
1. AccountOpened(accountId=123, initialBalance=0)
2. MoneyDeposited(accountId=123, amount=5000)
3. MoneyWithdrawn(accountId=123, amount=2000)
4. MoneyDeposited(accountId=123, amount=500)
5. MoneyWithdrawn(accountId=123, amount=2500)
→ Текущий баланс = 0 + 5000 - 2000 + 500 - 2500 = 1000

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

  • Полный аудит — каждое изменение зафиксировано. Критически важно для банков.
  • Возможность воспроизвести состояние на любой момент времени.
  • Отладка — можно воспроизвести проблему, повторив события.
  • Event-driven архитектура — события естественно интегрируются с другими сервисами.
  • Нет конфликтов при записи — запись только в append-only лог.

Недостатки

  • Сложность — непривычная модель, кривая обучения.
  • Восстановление состояния — при большом количестве событий нужны снапшоты.
  • Eventual consistency — проекции могут отставать.
  • Миграция событий — изменение формата события требует стратегии миграции.
Пример реализации: агрегат и хранилище событий
// Доменное событие
public sealed interface AccountEvent {
    UUID accountId();
    Instant occurredAt();

    record AccountOpened(UUID accountId, BigDecimal initialBalance,
                         Instant occurredAt) implements AccountEvent {}
    record MoneyDeposited(UUID accountId, BigDecimal amount,
                          Instant occurredAt) implements AccountEvent {}
    record MoneyWithdrawn(UUID accountId, BigDecimal amount,
                          Instant occurredAt) implements AccountEvent {}
}

// Агрегат, восстанавливаемый из событий
public class Account {
    private UUID id;
    private BigDecimal balance;
    private List<AccountEvent> uncommittedEvents = new ArrayList<>();

    // Восстановление состояния из событий
    public static Account reconstitute(List<AccountEvent> events) {
        Account account = new Account();
        events.forEach(account::apply);
        return account;
    }

    public void deposit(BigDecimal amount) {
        if (amount.compareTo(BigDecimal.ZERO) <= 0) {
            throw new IllegalArgumentException("Сумма должна быть положительной");
        }
        var event = new MoneyDeposited(id, amount, Instant.now());
        apply(event);
        uncommittedEvents.add(event);
    }

    public void withdraw(BigDecimal amount) {
        if (balance.compareTo(amount) < 0) {
            throw new InsufficientFundsException("Недостаточно средств");
        }
        var event = new MoneyWithdrawn(id, amount, Instant.now());
        apply(event);
        uncommittedEvents.add(event);
    }

    private void apply(AccountEvent event) {
        switch (event) {
            case AccountOpened e -> {
                this.id = e.accountId();
                this.balance = e.initialBalance();
            }
            case MoneyDeposited e -> this.balance = this.balance.add(e.amount());
            case MoneyWithdrawn e -> this.balance = this.balance.subtract(e.amount());
        }
    }
}

// Хранилище событий
@Repository
public class EventStore {
    private final JdbcTemplate jdbc;

    public void save(UUID aggregateId, List<AccountEvent> events, long expectedVersion) {
        for (AccountEvent event : events) {
            jdbc.update("""
                INSERT INTO events (aggregate_id, event_type, event_data, version, occurred_at)
                VALUES (?, ?, ?::jsonb, ?, ?)
                """,
                aggregateId, event.getClass().getSimpleName(),
                objectMapper.writeValueAsString(event),
                ++expectedVersion, event.occurredAt()
            );
        }
    }
}

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

На собеседовании: подчеркните, что Event Sourcing — это не только «хранить события», а ещё и восстановление состояния из событий, снапшоты для производительности и natural fit с CQRS. Частая ошибка — забыть про проблему миграции формата событий.