Пошук уроків, статей та іншого контенту
Зберігаємо події замість поточного стану, відновлюємо агрегати та зважуємо переваги й ризики аудиту.
Event Sourcing — це підхід, у якому система зберігає не поточний стан сутності, а послідовність подій, що призвели до цього стану.
Наприклад, замість збереження такого запису:
{
"orderId": "order-42",
"status": "PAID",
"total": 1500
}система зберігає історію:
[
{
"type": "OrderCreated",
"data": {
"orderId": "order-42",
"total": 1500
}
},
{
"type": "PaymentReceived",
"data": {
"orderId": "order-42",
"amount": 1500
}
}
]Поточний стан замовлення обчислюється шляхом послідовного застосування всіх подій.
Подія в Event Sourcing — це факт, який уже відбувся. Її не слід формулювати як команду або намір:
PayOrder — команда;
OrderPaymentRequested — подія про намір або запит;
PaymentReceived — факт успішного отримання платежу.
Зазвичай подія є незмінною: після запису її не редагують і не видаляють.
У системі з Event Sourcing варто розрізняти три поняття.
Стан — це поточне представлення агрегату, отримане з його подій.
{
id: "order-42",
status: "PAID",
total: 1500,
paidAmount: 1500
}Команда описує намір виконати дію:
{
type: "PayOrder",
orderId: "order-42",
amount: 1500
}Команда може бути відхилена. Наприклад:
замовлення не існує;
замовлення вже скасоване;
сума платежу неправильна;
замовлення вже оплачене.
Подія описує факт, який система прийняла та записала:
{
type: "PaymentReceived",
orderId: "order-42",
amount: 1500
}Типовий потік виглядає так:
система отримує команду;
завантажує події агрегату;
відновлює його поточний стан;
перевіряє бізнес-правила;
створює одну або кілька нових подій;
атомарно додає їх до event store;
публікує події для інших компонентів.
Важливо, що бізнес-логіка не повинна безпосередньо змінювати стан у базі. Вона створює нові події.
Event Store — це сховище послідовності подій. Найпростіша модель події може містити:
{
"eventId": "event-1001",
"aggregateId": "order-42",
"aggregateType": "Order",
"type": "PaymentReceived",
"version": 2,
"occurredAt": "2026-09-02T10:30:00.000Z",
"data": {
"amount": 1500
},
"metadata": {
"correlationId": "request-77",
"causationId": "command-12"
}
}eventId — унікальний ідентифікатор події;
aggregateId — ідентифікатор агрегату;
aggregateType — тип агрегату;
type — тип події;
version — версія події в потоці конкретного агрегату;
occurredAt — час виникнення;
data — дані, необхідні для відтворення стану;
metadata — технічний контекст, наприклад ідентифікатори запиту.
Події одного агрегату утворюють потік подій:
Order order-42
version 1: OrderCreated
version 2: PaymentReceived
version 3: OrderShippedПорядок подій має значення. Не можна застосувати OrderShipped до агрегату, у якому ще не відбулася OrderCreated.
Щоб відновити агрегат, система:
створює початковий стан;
читає події в правильному порядку;
застосовує кожну подію до стану;
отримує актуальний результат.
Функція, яка застосовує подію до стану, часто називається apply, evolve або event handler.
function initialOrderState() {
return {
id: null,
status: "NEW",
total: 0,
paidAmount: 0,
version: 0
};
}
function applyOrderEvent(state, event) {
switch (event.type) {
case "OrderCreated":
return {
...state,
id: event.data.orderId,
total: event.data.total,
status: "CREATED",
version: event.version
};
case "PaymentReceived":
return {
...state,
paidAmount: state.paidAmount + event.data.amount,
status: "PAID",
version: event.version
};
case "OrderShipped":
return {
...state,
status: "SHIPPED",
version: event.version
};
default:
throw new Error(`Невідомий тип події: ${event.type}`);
}
}
function rebuildOrder(events) {
return events.reduce(applyOrderEvent, initialOrderState());
}applyOrderEvent не повинна виконувати побічні ефекти. Вона лише перетворює:
попередній стан + подія → новий станВиклик зовнішнього сервісу, відправлення листа або запис до іншої бази під час повторного відтворення подій може призвести до небезпечних повторних дій.
Агрегат може перевірити команду та повернути нові події:
function handleOrderCommand(state, command) {
switch (command.type) {
case "CreateOrder":
if (state.id !== null) {
throw new Error("Замовлення вже існує");
}
if (!Number.isInteger(command.total) || command.total <= 0) {
throw new Error("Сума замовлення має бути додатним цілим числом");
}
return [
{
type: "OrderCreated",
data: {
orderId: command.orderId,
total: command.total
}
}
];
case "PayOrder":
if (state.id === null) {
throw new Error("Замовлення не існує");
}
if (state.status !== "CREATED") {
throw new Error("Замовлення не можна оплатити в поточному стані");
}
if (command.amount !== state.total) {
throw new Error("Сума платежу не відповідає сумі замовлення");
}
return [
{
type: "PaymentReceived",
data: {
amount: command.amount
}
}
];
case "ShipOrder":
if (state.status !== "PAID") {
throw new Error("Відправити можна лише оплачене замовлення");
}
return [
{
type: "OrderShipped",
data: {}
}
];
default:
throw new Error(`Невідома команда: ${command.type}`);
}
}Така функція не записує події самостійно. Вона лише визначає, які факти мають бути зафіксовані.
Два процеси можуть одночасно прочитати одну й ту саму версію агрегату:
Процес A читає version 3
Процес B читає version 3Якщо обидва без перевірки додадуть події, потік може опинитися в некоректному стані.
Для цього під час запису передають очікувану версію:
append(aggregateId, newEvents, expectedVersion)Event Store додає події лише тоді, коли поточна версія збігається з expectedVersion.
Якщо поточна версія вже змінилася, сховище повертає помилку конфлікту. Процес має повторно прочитати потік, перевірити команду на новому стані та вирішити, чи можна виконати її ще раз.
class ConcurrencyError extends Error {}
class InMemoryEventStore {
constructor() {
this.streams = new Map();
}
load(aggregateId) {
return this.streams.get(aggregateId) ?? [];
}
append(aggregateId, events, expectedVersion) {
const currentEvents = this.load(aggregateId);
const actualVersion = currentEvents.length;
if (actualVersion !== expectedVersion) {
throw new ConcurrencyError(
`Конфлікт версій: очікувалась ${expectedVersion}, фактично ${actualVersion}`
);
}
const storedEvents = events.map((event, index) => ({
eventId: `${aggregateId}-${actualVersion + index + 1}`,
aggregateId,
aggregateType: "Order",
type: event.type,
data: event.data,
version: actualVersion + index + 1,
occurredAt: new Date().toISOString()
}));
this.streams.set(aggregateId, [
...currentEvents,
...storedEvents
]);
return storedEvents;
}
}
function executeOrderCommand(store, aggregateId, command) {
const history = store.load(aggregateId);
const state = rebuildOrder(history);
const newEvents = handleOrderCommand(state, command);
return store.append(aggregateId, newEvents, state.version);
}
const store = new InMemoryEventStore();
executeOrderCommand(store, "order-42", {
type: "CreateOrder",
orderId: "order-42",
total: 1500
});
executeOrderCommand(store, "order-42", {
type: "PayOrder",
amount: 1500
});
executeOrderCommand(store, "order-42", {
type: "ShipOrder"
});
const events = store.load("order-42");
const finalState = rebuildOrder(events);
console.log(events.map(({ type, version }) => ({ type, version })));
console.log(finalState);Очікувана послідовність подій:
OrderCreated version 1
PaymentReceived version 2
OrderShipped version 3Актуальний стан:
{
id: "order-42",
status: "SHIPPED",
total: 1500,
paidAmount: 1500,
version: 3
}Перевірка версії захищає один потік агрегату. Вона не вирішує автоматично конфлікти між різними агрегатами або між окремими проєкціями.
Відновлювати кожен запит, читаючи весь потік подій, зазвичай неефективно. Для цього будують проєкції — спеціалізовані read-моделі, які оновлюються на основі подій.
Наприклад, події замовлень можуть живити такі проєкції:
список активних замовлень;
загальна сума продажів за день;
історія платежів клієнта;
статистика відправлень.
Проєкція може бути побудована заново з event store. Це одна з ключових переваг підходу: read-модель є похідними даними, а не єдиним джерелом істини.
Проста проєкція може виглядати так:
class OrderListProjection {
constructor() {
this.orders = new Map();
}
handle(event) {
switch (event.type) {
case "OrderCreated":
this.orders.set(event.aggregateId, {
id: event.aggregateId,
total: event.data.total,
status: "CREATED"
});
break;
case "PaymentReceived": {
const order = this.orders.get(event.aggregateId);
if (order) {
order.status = "PAID";
}
break;
}
case "OrderShipped": {
const order = this.orders.get(event.aggregateId);
if (order) {
order.status = "SHIPPED";
}
break;
}
}
}
}
const projection = new OrderListProjection();
for (const event of store.load("order-42")) {
projection.handle(event);
}
console.log(projection.orders.get("order-42"));Проєкція може оновлюватися синхронно в межах однієї транзакції або асинхронно через брокер повідомлень. У другому випадку читання часто має eventual consistency: одразу після запису події read-модель може ще не містити нових даних.
Подія може бути доставлена проєкції більше одного разу через повторну доставку, тайм-аут або повторну спробу.
Тому обробник повинен бути ідемпотентним: повторна обробка тієї самої події не повинна псувати результат.
Один із варіантів — зберігати ідентифікатори вже оброблених подій:
class IdempotentProjection {
constructor() {
this.processedEventIds = new Set();
this.orders = new Map();
}
handle(event) {
if (this.processedEventIds.has(event.eventId)) {
return;
}
// Оновлення read-моделі має виконуватися узгоджено з фіксацією eventId.
if (event.type === "OrderCreated") {
this.orders.set(event.aggregateId, {
id: event.aggregateId,
total: event.data.total,
status: "CREATED"
});
}
this.processedEventIds.add(event.eventId);
}
}У production-системі перевірку події та зміну проєкції бажано виконувати атомарно в одному сховищі. Інакше процес може впасти між цими двома операціями.
Якщо агрегат має тисячі або мільйони подій, повне відтворення потоку під час кожного запиту стає дорогим.
Snapshot — це збережений стан агрегату на певній версії:
{
"aggregateId": "order-42",
"version": 500,
"state": {
"status": "PAID",
"total": 1500,
"paidAmount": 1500
}
}Відновлення тоді відбувається так:
завантажити останній snapshot;
прочитати події після його версії;
застосувати лише їх;
отримати актуальний стан.
Snapshot не замінює події та не є джерелом істини. Його можна видалити й побудувати знову.
Snapshot корисний лише тоді, коли вимірювання показує проблему продуктивності. Додавати його до кожного агрегату наперед може збільшити складність системи без реальної користі.
Події живуть довше за код, який їх створив. Через це структура події може змінитися.
Наприклад, стара подія:
{
"type": "OrderCreated",
"data": {
"orderId": "order-42",
"total": 1500
}
}Нова версія може містити валюту:
{
"type": "OrderCreated",
"data": {
"orderId": "order-42",
"total": 1500,
"currency": "UAH"
}
}Зазвичай застосовують один із підходів:
зберігають версію схеми події;
підтримують кілька версій обробника;
виконують upcasting старих подій під час читання;
створюють нову назву події для семантично іншого факту.
Не слід просто змінювати значення старих подій заднім числом. Це порушує відтворюваність історії.
Обробник може безпечно надати значення за замовчуванням для старих подій:
function readOrderCreated(event) {
return {
orderId: event.data.orderId,
total: event.data.total,
currency: event.data.currency ?? "UAH"
};
}Значення за замовчуванням має бути коректним для всіх старих записів. Якщо це неможливо, потрібна явна міграція або нова версія схеми.
Event Sourcing добре підходить для доменів, де важливо знати історію змін:
фінансові операції;
платежі;
замовлення;
страхові рішення;
керування доступом;
критичні бізнес-процеси.
Система може відповісти не лише на питання «який стан зараз?», а й на питання:
яка подія змінила стан;
коли вона відбулася;
якою командою була спричинена;
хто був ініціатором;
які наступні події з неї виникли.
Однак Event Sourcing сам по собі не гарантує повний аудит. Для цього потрібні:
надійна автентифікація автора дії;
коректні metadata;
контроль доступу до event store;
захист від зміни або видалення журналу;
зрозуміла політика виправлення помилок.
Технічний час створення запису та бізнес-час події можуть відрізнятися. Якщо це важливо для домену, їх варто зберігати окремо.
У незмінному журналі помилку не виправляють редагуванням старого запису. Замість цього додають нову компенсувальну подію.
Наприклад:
PaymentReceived amount=1000
PaymentCorrected previousAmount=1000 correctAmount=900Або:
ProductReserved quantity=5
ProductReservationReleased quantity=5Це не означає, що всі помилки потрібно виправляти однаково. Вибір залежить від домену:
компенсувальна подія може змінити фінансовий баланс;
коригувальна подія може виправити помилкове значення;
інколи потрібна юридична або операційна процедура поза системою.
Головний принцип — історія залишається правдивою: видно і помилкову дію, і факт її виправлення.
Система зберігає послідовність фактів, а не лише останній результат.
Можна відновити стан на конкретній версії або побудувати історичний стан на певний момент.
Якщо read-модель пошкоджена або з'явився новий тип звіту, її можна побудувати з подій заново.
Командна модель і read-моделі можуть оптимізуватися незалежно одна від одної.
Події можуть бути основою для обміну фактами між bounded contexts або сервісами. Водночас для інтеграції слід розрізняти внутрішні доменні події та стабільний зовнішній контракт.
Потрібно проєктувати:
схему подій;
версіонування;
відновлення агрегатів;
проєкції;
повторну доставку;
конфлікти версій;
відновлення після помилок.
Асинхронні проєкції можуть відставати від event store. Користувацький інтерфейс має бути готовий до цього або використовувати потрібне джерело для критичних перевірок.
Погано спроєктована подія буде читатися роками. Зміна її сенсу або структури може вимагати міграції всіх споживачів.
Події можуть містити персональні або чутливі дані, які важко повністю видалити з незмінного журналу. Не слід без потреби записувати в події секрети, токени та надлишкові персональні дані.
Повторне відтворення подій не повинно повторно списувати кошти, надсилати листи або викликати зовнішні API. Для таких дій використовують окремі обробники, ідемпотентність та контроль стану виконання.
Якщо достатньо зберігати поточний стан, а історія не має бізнес-цінності, звичайна CRUD-модель може бути простішою та надійнішою.
Якщо подія містить повний змінений стан і використовується лише як журнал оновлень, система може втратити переваги справжнього Event Sourcing. Варто чітко визначити, чи подія описує факт домену, чи просто технічний diff.
UpdateOrderStatus описує наказ, а не факт. Назви подій мають відображати те, що вже сталося: OrderShipped, OrderCancelled.
Зміна історичних записів порушує відтворюваність. Для виправлення слід додавати нові події.
Без перевірки очікуваної версії два паралельні записи можуть застосуватися до одного стану та порушити бізнес-інваріанти.
applyФункція відтворення має бути детермінованою. Виклики мережі, генерація випадкових значень або використання поточного часу всередині apply роблять результат непередбачуваним.
Для відтворення стану не слід покладатися на актуальний стан іншої таблиці. Подія має містити факти, необхідні її обробникам.
Повторна доставка події має давати той самий результат. Це потрібно забезпечити перевіркою ідентифікаторів, унікальними обмеженнями або атомарними операціями.
До запуску системи потрібно розуміти, як читатимуться старі події після зміни доменної моделі.
Для окремого агрегату можна діяти так:
Визначити бізнес-інваріанти, які мають перевірятися разом.
Визначити команди, що змінюють агрегат.
Для кожної команди описати можливі результати у вигляді фактів-подій.
Зафіксувати мінімальні дані, необхідні для відновлення стану.
Визначити версію потоку агрегату.
Додати атомарний запис із перевіркою очікуваної версії.
Окремо спроєктувати read-моделі та їх повторну побудову.
Передбачити повторну доставку, версіонування і коригування помилкових фактів.
Перевірити, чи справді аудит і відтворюваність виправдовують додаткову складність.
Event Sourcing зберігає незмінну послідовність подій замість лише поточного стану.
Стан агрегату відновлюється застосуванням подій у правильному порядку.
Команда може бути відхилена, а подія означає факт, який система прийняла.
Очікувана версія захищає потік агрегату від конкурентних записів.
Read-моделі та snapshots є похідними даними й можуть бути побудовані заново.
Події потрібно версіонувати, обробляти ідемпотентно та не редагувати заднім числом.
Основні переваги — аудит, відтворюваність і гнучке створення проєкцій.
Основні ризики — складність, eventual consistency, довготривале версіонування та проблеми з конфіденційними даними.
Event Sourcing доцільний там, де історія бізнес-фактів є важливою частиною домену.