Что такое 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. Частая ошибка — забыть про проблему миграции формата событий.