Прямой ответ
Event sourcing (ES) — состояние сущности хранится не как «текущая строка», а как append-only последовательность событий («СчётОткрыт → ДеньгиВнесены(100) → ДеньгиСняты(30)» — текущее состояние = применение всех событий по порядку — event-driven — максимальная событийность); зачем: полная история (аудит «что и когда» — каждое изменение — событие — банковский/финтех-аудит — идемпотентность), временные срезы (состояние на любую дату — «баланс на 1 марта» — применить события до даты — CQRS), отладка/воспроизведение (баг — воспроизвести последовательность событий — «как мы пришли к этому состоянию» — observability), интеграция (события — готовый контракт для других систем — event-driven); механика: event store (append-only хранилище — специализированное EventStoreDB или таблица в БД — Postgres — «event_id, aggregate_id, seq, type, payload, version» — только INSERT, никогда UPDATE/DELETE — констрейнты); агрегат (группа событий одной сущности — заказ/счёт — микросервисы — граница консистентности); восстановление состояния (load aggregate → применить все события → текущее состояние — «replay» — consumer-аналогия); снапшоты (обязательны для производительности): агрегат с 10 000 событий — восстановление = 10 000 применений — медленно; снапшот — «состояние на событие N» — восстановление: снапшот + события после N (каждые 100 событий — снапшот — ёмкость — «replay 10k событий на каждый запрос» — убийство — снапшоты — не опция); проекции (read-модели): события → проекция (текущее состояние для чтения — «список заказов» — CQRS — проекция пересобирается из событий — eventually consistent — «проекция отстаёт на секунды» — норма — event-driven); версионирование событий (главная боль): схема события меняется со временем («OrderCreated v1 без поля discount, v2 с discount» — старые события в store навсегда — upcaster (преобразование v1→v2 при чтении — API-версии — «изменил схему события» — старые события не переписать — только upcast — event-driven-схемы); когда ES оправдан: домены с богатой историей (финансы/аудит/юридически значимые операции — идемпотентность), сложные доменные процессы (состояние = последовательность шагов — saga), нужна воспроизводимость (отладка/аналитика — replay — event-driven); когда ES — ошибка: CRUD-приложения (состояние простое — история не нужна — ES — оверхед — «event sourcing для блога» — антипаттерн — микросервисы), команда без опыта (ES — сложность — upcasting/проекции/снапшоты — техдолг — «модно» — не причина), запросы к состоянию «здесь и сейчас» (ES восстанавливает состояние — медленнее прямой строки — CQRS-нужен — без CQRS ES болезнен — паттерн-пара).
Практика: event store, проекции, снапшоты
Event store на Postgres (практичный старт без спец-БД): таблица events (aggregate_id UUID, seq BIGINT, type TEXT, payload JSONB, version INT, occurred_at TIMESTAMPTZ — PRIMARY KEY (aggregate_id, seq) — констрейнты — append-only (никаких UPDATE — триггер-защита или права — доступы); optimistic concurrency (запись события с expected_seq — «агрегат на версии 5, пишу событие 6» — конфликт — повтор — транзакции — «параллельное изменение агрегата» — optimistic lock — идемпотентность); проекции (read-модели — отдельная таблица/БД): обработчик читает события, обновляет проекцию (current_state — «заказы для списка» — CQRS — eventually consistent — lag проекции — метрика (метрики — «проекция отстала» — алерт — алерты); пересборка проекции (схема проекции изменилась — replay всех событий — consumer-reset — «новая проекция с нуля» — время — ёмкость); снапшоты (таблица snapshots — aggregate_id, seq, state JSONB — каждые N событий — восстановление: снапшот + события после — ёмкость); event store в Kafka (альтернатива — события в топик — Kafka — но Kafka не оптимален как primary store (нет per-aggregate optimistic concurrency из коробки — retention удаляет старые события — «история за 5 лет» — Kafka retention не хранит — специализированный store или Postgres лучше для primary — выбор); гибрид (Postgres/EventStoreDB — primary store, Kafka — для проекций/интеграций — event-driven).
Практический сценарий: аудит платежей на event sourcing
Контекст: платёжный сервис, требование регулятора — полный аудиторский след каждой операции (кто/что/когда/почему — 7 лет хранения — комплаенс), текущая схема — строка платежа + отдельная таблица логов (рассинхронизируется — «лог не совпадает с состоянием» — техдолг); решение — event sourcing для платежей: (1) агрегат Payment (события: PaymentInitiated, PaymentAuthorized, PaymentCaptured, PaymentRefunded, PaymentFailed — saga-контекст — каждый шаг — событие); (2) event store — Postgres (append-only таблица — констрейнты — optimistic concurrency по seq — транзакции); (3) outbox → Kafka (события платежа → Kafka для проекций/интеграций — saga — атомарность store+событие — event-driven); (4) проекции: payment_summary (текущее состояние для API — CQRS), payment_list (список для админки — Grafana-аналогия), audit_log (полная история — регулятор — replay — consumer); (5) снапшоты (каждые 50 событий — восстановление платежа — снапшот + хвост — ёмкость); (6) версионирование (PaymentAuthorized v1 → v2 (добавлен auth_code) — upcaster при чтении — API-версии — старые события не переписаны — upcast — event-driven); результат: аудиторский след — из коробки (каждая операция — событие — «кто/что/когда» — комплаенс — регулятор доволен — рассинхронизация невозможна (состояние = события — CQRS), воспроизведение (баг платежа — replay событий — «как пришли к ошибке» — observability — отладка ускорилась), временные срезы (состояние платежа на дату — «что было 1 марта» — replay до даты — CQRS); цена: сложность (upcasting/проекции/снапшоты — обучение команды — техдолг), eventually consistent проекции (API читает проекцию с lag — «платёж проведён, в списке через 2 с» — UX-ожидания — event-driven), пересборка проекций (схема изменилась — replay всех — часы на больших данных — ёмкость); вывод: ES решили конкретную проблему (аудит/воспроизведение), не «перевели всё на ES» — только платежи (богатая история + регулятор) — остальные домены — обычная модель — микросервисы — ES — инструмент для доменов с историей, не универсальное решение — ADR — критерии зафиксированы — техдолг.
FAQ
Event sourcing против обычного хранения состояния — в чём разница и когда что? Обычное (CRUD): текущее состояние в строке (UPDATE перезаписывает — история теряется или в отдельном логе — техдолг); ES: состояние = события (append-only — история из коробки — восстановление replay — event-driven); разница: ES даёт аудит/воспроизведение/временные срезы (регулятор/отладка/аналитика — комплаенс), но требует CQRS/проекции/снапшоты/upcasting (сложность — CQRS — «просто CRUD» — ES оверхед); когда обычное: простые домены без истории (блог/справочник — микросервисы), запросы «текущее состояние» (ES восстанавливает — медленнее — CQRS-нужен), команда без опыта (техдолг); когда ES: домены с историей (финансы/аудит — сценарий выше), сложные процессы (saga), воспроизводимость (отладка — observability); «ES для всего» — антипаттерн (оверхед без выгоды — микросервисы — ES — для доменов с историей — ADR).
Что такое снапшоты и как часто их делать? Снапшот — сохранённое состояние агрегата на событие N (для ускорения восстановления — replay 10k событий медленно — снапшот на 9900 + 100 событий — ёмкость); механика: каждые N событий (50–100 — эмпирика) — снапшот в отдельную таблицу/store (Postgres — восстановление: последний снапшот + события после — consumer-аналогия); частота: баланс (чаще — быстрее восстановление, но больше записей/хранения — реже — медленнее восстановление — ёмкость — 100 событий — типично); «снапшот на каждое событие» — бессмысленно (дублирует store — техдолг); снапшоты не заменяют события (снапшот — оптимизация, события — источник правды — снапшот пересобирается из событий — event-driven); антипаттерн: «снапшот вместо событий» (удалить старые события, оставить снапшот — история потеряна — ES смысл утрачен — комплаенс — события хранятся полностью — retention — ёмкость); снапшоты обязательны для производительности (агрегаты с 1000+ событий — без снапшотов — восстановление секунды — ёмкость — «ES медленный» — обычно снапшотов нет — техдолг).
Как версионировать события и что такое upcasting? Проблема: схема события меняется (OrderCreated v1 {id, total}, v2 {id, total, discount} — API-версии), старые события в store навсегда (append-only — не переписать — event-driven); upcasting — преобразование старой версии в новую при чтении (upcaster: v1 → v2 (discount=0 по умолчанию) — event-driven — приложение работает с v2, store хранит v1+v2 — API-версии); стратегии: upcaster-цепочки (v1→v2→v3 — каждое чтение — преобразование — event-driven), lazy migration (при чтении старого события — преобразовать и записать новую версию — store постепенно мигрирует — миграция — «ленивая миграция событий»), event versioning в схеме (version-поле — upcaster по version — структура); антипаттерн: «изменить схему без upcaster» (старые события не парсятся — приложение падает — event-driven — обратная совместимость обязательна — API-версии); практика: schema registry (Avro/Protobuf — контроль совместимости — gRPC-схемы — «несовместимая схема — отклонена» — контракты — upcasting для legacy — event-driven).
Event sourcing и CQRS — всегда ли вместе? Не всегда, но почти всегда (ES без CQRS болезнен — восстановление состояния для каждого запроса — медленно — CQRS — «ES без CQRS» — антипаттерн — event-driven); CQRS без ES — возможен (раздельные read/write модели без событий — обычное состояние — CQRS — «CQRS без ES» — проще — часто достаточно); ES почти всегда требует CQRS (write — события, read — проекции — CQRS — паттерн-пара — сценарий выше); когда ES без CQRS: очень простые домены (агрегат маленький — восстановление быстро — «аудит-лог без сложных запросов» — редкость — event-driven); вывод: ES + CQRS — стандартная пара (write-сторона — события, read-сторона — проекции — CQRS — «ES без CQRS» — боль — техдолг — но «CQRS без ES» — нормально и проще — микросервисы — не путать обязательность — ADR).
Источники: Martin Fowler (EventSourcing), microservices.io patterns, EventStore docs — ссылки в шапке.