Пошук уроків, статей та іншого контенту
Навчить будувати архітектуру навколо подій, визначати контракти та зменшувати зв’язаність між сервісами.
Подієва архітектура — це підхід, у якому компоненти системи взаємодіють через події, що описують факти, які вже відбулися.
Наприклад:
Order Service
└── публікує OrderPlaced
Payment Service
└── реагує на OrderPlaced
└── публікує PaymentAuthorized або PaymentFailed
Inventory Service
└── реагує на OrderPlaced
└── публікує InventoryReserved або InventoryReservationFailedСервіси не викликають один одного напряму для кожної дії. Замість цього один сервіс публікує подію, а інші незалежно реагують на неї.
Подія має форму твердження:
OrderPlaced — замовлення створено;
PaymentAuthorized — платіж підтверджено;
UserRegistered — користувача зареєстровано.
Назви подій зазвичай формулюють у минулому часі, тому що подія описує завершений факт, а не запит на виконання дії.
У розподіленій системі важливо розрізняти три типи повідомлень.
Подія повідомляє про факт:
{
"type": "OrderPlaced",
"data": {
"orderId": "order-123",
"customerId": "customer-42",
"total": 1500
}
}Відправник не визначає, хто саме оброблятиме подію. На неї можуть підписатися платіжний сервіс, сервіс складського обліку, аналітика або сервіс сповіщень.
Команда просить конкретний компонент виконати дію:
{
"type": "AuthorizePayment",
"data": {
"orderId": "order-123",
"amount": 1500
}
}Команда має адресата і передбачає намір:
«авторизуй платіж»;
«зарезервуй товар»;
«скасуй замовлення».
Команди зазвичай використовують, коли відправник знає, який компонент повинен виконати дію.
Запит очікує відповідь із даними:
"Який поточний статус замовлення order-123?"Події не повинні використовуватися як заміна синхронним запитам. Якщо клієнту потрібно негайно отримати поточний стан, HTTP-запит або RPC часто буде простішим рішенням.
Типова подієва система складається з таких частин:
producer — компонент, що створює повідомлення;
consumer — компонент, що обробляє повідомлення;
broker — інфраструктура для передавання та зберігання повідомлень;
topic або queue — канал, у якому доставляються повідомлення;
consumer group — група екземплярів, що спільно обробляють повідомлення;
schema registry або інший механізм контрактів — спосіб зберігати та перевіряти схеми подій.
Важливо відокремлювати бізнес-код від конкретного брокера. Сервіс має працювати з абстракціями на кшталт:
publish(event)
subscribe(eventType, handler)А адаптер уже реалізує цю взаємодію через конкретну технологію.
Сервіс-джерело не повинен знати всі сервіси, які реагують на його події.
Якщо до OrderPlaced додати сервіс аналітики, код сервісу замовлень не обов’язково змінювати.
Якщо подій платежів більше, ніж подій сповіщень, їхні споживачі можна масштабувати окремо.
Операції, які не потрібні в межах негайної відповіді користувачу, можна виконувати у фоні:
надсилання електронного листа;
побудова звіту;
оновлення пошукового індексу;
синхронізація з зовнішньою системою.
Події можуть зберігатися як журнал змін. Це дає змогу:
аналізувати історію;
відновлювати проєкції;
повторно обробляти події після виправлення помилки.
Однак зберігання подій як головного джерела стану — це окреме архітектурне рішення. Не кожна подієва система є прикладом повного event sourcing.
Подія є публічним контрактом між командами або сервісами. Її потрібно проєктувати стабільною та зрозумілою.
Зручно розділяти службові метадані та бізнес-дані:
{
"id": "event-8c1f",
"type": "OrderPlaced",
"version": 1,
"occurredAt": "2026-09-02T10:15:00.000Z",
"producer": "order-service",
"correlationId": "request-91",
"data": {
"orderId": "order-123",
"customerId": "customer-42",
"currency": "UAH",
"total": 1500
}
}Метадані допомагають:
ідентифікувати повідомлення;
реалізувати дедуплікацію;
трасувати один бізнес-процес;
визначати версію контракту;
знаходити джерело події.
Подія повинна містити всі дані, необхідні типовому споживачу для реакції, або чіткий ідентифікатор, за яким споживач може отримати додаткові дані.
Наприклад, OrderPlaced може містити:
ідентифікатор замовлення;
ідентифікатор клієнта;
суму;
валюту;
позиції замовлення, якщо вони потрібні споживачам.
Не варто включати внутрішні деталі бази даних, які не є частиною бізнес-контракту.
Зміна значення поля має бути сумісною з усіма споживачами.
Небезпечні зміни:
перейменування customerId на userId;
зміна типу total із числа на рядок;
видалення поля, яке ще використовує споживач;
зміна значення без зміни версії, якщо семантика стала іншою.
Безпечніша зміна:
додати нове необов’язкове поле;
додати новий тип події;
створити нову версію контракту для несумісної зміни.
Надійність обробки потрібно визначити явно. Найпоширеніший практичний варіант — at-least-once delivery.
Це означає, що повідомлення буде доставлено один або більше разів. Повторна доставка можлива через:
тайм-аут підтвердження;
падіння споживача після виконання дії, але до підтвердження;
повторну спробу брокера;
тимчасову недоступність залежності.
Тому обробник події повинен бути ідемпотентним.
Ідемпотентна обробка дає той самий бізнес-результат при повторному отриманні одного повідомлення.
Наприклад, платіж не повинен авторизуватися двічі через повторну доставку OrderPlaced.
Типовий підхід:
отримати event.id;
перевірити, чи оброблялася ця подія;
якщо так — завершити обробку без повторної бізнес-операції;
якщо ні — виконати операцію та зафіксувати ідентифікатор.
Перевірка та бізнес-зміна мають бути атомарними. Інакше два паралельні обробники можуть одночасно пройти перевірку.
class PaymentService {
constructor() {
this.processedEvents = new Set();
this.payments = new Map();
}
handleOrderPlaced(event) {
if (this.processedEvents.has(event.id)) {
return { status: "duplicate" };
}
const { orderId, total, currency } = event.data;
// Ідемпотентна перевірка бізнес-результату
if (this.payments.has(orderId)) {
this.processedEvents.add(event.id);
return { status: "already-authorized" };
}
this.payments.set(orderId, {
orderId,
amount: total,
currency,
status: "authorized"
});
this.processedEvents.add(event.id);
return {
status: "processed",
event: {
id: `payment-${orderId}`,
type: "PaymentAuthorized",
version: 1,
occurredAt: new Date().toISOString(),
producer: "payment-service",
correlationId: event.correlationId,
data: { orderId, total, currency }
}
};
}
}
const service = new PaymentService();
const orderPlaced = {
id: "event-1",
type: "OrderPlaced",
version: 1,
occurredAt: new Date().toISOString(),
producer: "order-service",
correlationId: "request-1",
data: {
orderId: "order-123",
total: 1500,
currency: "UAH"
}
};
console.log(service.handleOrderPlaced(orderPlaced));
console.log(service.handleOrderPlaced(orderPlaced));У реальній системі Set замінюють таблицею або іншим надійним сховищем із унікальним обмеженням на event.id.
Глобальний порядок усіх подій у розподіленій системі зазвичай недосяжний або занадто дорогий.
Натомість визначають порядок лише там, де він потрібен.
Наприклад, події одного замовлення можуть мати такий порядок:
OrderPlaced
PaymentAuthorized
OrderShippedДля цього повідомлення часто маршрутизують за ключем orderId, щоб події одного замовлення потрапляли до одного логічного потоку.
Навіть за наявності порядку доставки споживач не повинен безумовно покладатися на нього. Можливі:
повторна доставка;
затримка одного повідомлення;
повторне читання після збою;
конкурентна обробка різними споживачами.
Корисні поля для контролю порядку:
sequence;
aggregateVersion;
occurredAt.
Якщо подія має версію агрегату, споживач може відхилити або відкласти подію з неочікуваною версією.
Одна з найскладніших проблем виникає, коли сервіс змінює базу даних і публікує подію.
Наївний код:
1. Записати замовлення в базу.
2. Опублікувати OrderPlaced.Якщо процес завершиться між кроками, замовлення вже буде створене, але подія не з’явиться.
Зворотний порядок також небезпечний:
1. Опублікувати OrderPlaced.
2. Записати замовлення в базу.Тепер споживачі можуть побачити подію про замовлення, якого ще немає в базі або яке не вдалося зберегти.
Сервіс записує бізнес-зміну та подію в одну транзакцію бази даних:
Транзакція:
INSERT INTO orders (...)
INSERT INTO outbox_events (...)
Окремий publisher:
читає outbox_events
публікує події в брокер
позначає їх як опублікованіЯкщо транзакція успішна, обидва записи з’являються разом. Якщо транзакція скасована, не з’являється жоден.
Publisher може опублікувати подію повторно, якщо впав після публікації, але до позначення запису як опублікованого. Саме тому споживачам усе одно потрібна ідемпотентність.
Спрощена модель таблиці outbox:
outbox_events
-------------
id
event_type
aggregate_id
payload
created_at
published_at
attemptsВарто передбачити:
повторні спроби;
затримку між спробами;
окремий механізм для невиправних помилок;
моніторинг непублікованих записів;
очищення старих успішно опублікованих записів.
Помилки в подієвих системах поділяють на тимчасові та постійні.
Наприклад:
база даних тимчасово недоступна;
зовнішній API повернув помилку мережі;
брокер тимчасово не відповідає.
Такі помилки можна обробляти повторними спробами з exponential backoff:
1 спроба: одразу
2 спроба: через 1 секунду
3 спроба: через 5 секунд
4 спроба: через 30 секундПовторні спроби повинні мати обмеження, щоб несправний обробник не блокував чергу безкінечно.
Наприклад:
повідомлення не відповідає схемі;
обов’язкове поле відсутнє;
бізнес-операція неможлива;
подія використовує непідтримувану версію.
Такі повідомлення варто переміщати до dead-letter queue або іншого ізольованого сховища для аналізу.
Не можна мовчки видаляти повідомлення, які не вдалося обробити. Інакше система втратить дані без можливості відновлення.
Подія часто запускає процес, який охоплює кілька сервісів:
OrderPlaced
├── PaymentAuthorized
└── InventoryReserved
└── OrderConfirmedМіж цими кроками немає однієї транзакції. Якщо резервування товару не вдалося після авторизації платежу, системі потрібна компенсаційна дія:
InventoryReservationFailed
└── RefundPaymentТакий довгий розподілений процес називають сагою.
Є два поширені способи координації.
Кожен сервіс реагує на події та публікує наступні:
Order Service → OrderPlaced
Payment Service → PaymentAuthorized
Inventory Service → InventoryReserved
Order Service → OrderConfirmedПереваги:
немає центрального координатора;
сервіси слабше зв’язані;
кожен сервіс володіє своєю логікою.
Недоліки:
складніше побачити повний процес;
зростає кількість неявних залежностей;
важче змінювати довгі бізнес-процеси.
Окремий компонент координує кроки:
OrderSaga:
1. надіслати AuthorizePayment
2. після успіху надіслати ReserveInventory
3. після успіху підтвердити замовлення
4. після помилки виконати компенсаційні командиПереваги:
процес видно в одному місці;
простіше контролювати тайм-аути та компенсації;
зручніше відстежувати стан саги.
Недоліки:
оркестратор стає важливим компонентом;
він може надмірно знати про внутрішню логіку сервісів.
Вибір залежить від складності процесу. Для коротких незалежних реакцій хореографія часто достатня. Для тривалих процесів із компенсаціями зручнішою може бути оркестрація.
У подієвій системі дані між сервісами часто узгоджуються не миттєво.
Після створення замовлення:
t0: Order Service зберіг замовлення
t1: опубліковано OrderPlaced
t2: Payment Service авторизував платіж
t3: Order Service отримав PaymentAuthorized
t4: статус замовлення змінився на paidПротягом інтервалу між t0 і t4 різні компоненти можуть бачити різні стани. Це називають eventual consistency.
Система повинна явно визначати:
які дані можуть бути тимчасово неузгодженими;
який максимальний час узгодження допустимий;
що побачить користувач у проміжному стані;
як відновити стан після пропущеної або повторної події.
Окремий споживач може будувати власну проєкцію для читання:
OrderPlaced → створити запис у read model
PaymentAuthorized → змінити статус на paid
OrderShipped → змінити статус на shippedПроєкції можна перебудувати, якщо події зберігаються достатньо довго або існує інше джерело історії.
Асинхронний ланцюжок важко діагностувати лише за логами окремих сервісів. Кожна подія повинна містити або успадковувати ідентифікатори трасування:
eventId — ідентифікатор конкретної події;
correlationId — ідентифікатор бізнес-процесу або запиту;
causationId — ідентифікатор події, яка спричинила поточну подію.
Потрібно відстежувати:
кількість опублікованих і оброблених подій;
час очікування в черзі;
кількість повторних спроб;
кількість повідомлень у dead-letter queue;
вік найстарішого необробленого повідомлення;
частку невдалих обробок;
затримку між подією та оновленням проєкції.
Під час розслідування інциденту це дозволяє відновити ланцюжок:
HTTP request
→ OrderPlaced
→ PaymentAuthorized
→ OrderConfirmedКонтракт події потрібно перевіряти до того, як зміна потрапить у production.
Корисні рівні перевірки:
Валідація схеми — усі обов’язкові поля мають правильні типи.
Consumer-driven contract tests — споживач описує мінімальну форму події, яка йому потрібна.
Перевірка сумісності версій — нова схема не ламає наявних споживачів.
Тестування помилок — споживач коректно обробляє невідомі поля, дублікати й старі версії.
Споживач не повинен падати лише через появу нового необов’язкового поля. Невідомі поля зазвичай потрібно ігнорувати.
Якщо операція потребує негайної відповіді, подія може додати непотрібну складність. Подієвий підхід доречний там, де асинхронність або слабке зв’язування справді потрібні.
Назва ProcessOrder не описує факт. Вона звучить як команда, але публікується як подія. Краще розділити:
ProcessOrder — команда
OrderProcessed — подіяПрипущення, що кожна подія буде доставлена лише один раз, призводить до дубльованих платежів, листів або записів.
Зміна бази та публікація події без outbox створюють вікно, у якому дані можуть розійтися.
Подія не повинна містити всю внутрішню модель сервісу. Великі payload збільшують трафік, час обробки та кількість несумісностей.
Глобальна черга не гарантує, що події різних агрегатів потрібно обробляти в одному порядку. Порядок слід визначати лише для конкретного бізнес-ключа.
Для кожної події має бути зрозуміло:
який сервіс є її власником;
хто може змінювати схему;
який період підтримуються старі версії;
як повідомляються споживачі про несумісні зміни.
Для нового сценарію можна використати такий порядок:
Визначте бізнес-факти, які справді мають значення.
Відокремте команди від подій.
Визначте сервіс-власник кожного факту.
Опишіть схему envelope і payload.
Визначте ключ партиціювання та вимоги до порядку.
Виберіть гарантію доставки.
Спроєктуйте ідемпотентний обробник.
Додайте outbox для подій, пов’язаних зі зміною власної бази.
Опишіть retry, dead-letter і ручне відновлення.
Визначте, як відстежуватиметься повний ланцюжок.
Перевірте сумісність контракту з усіма споживачами.
Зафіксуйте, які проміжні стани побачить користувач.
Подієва архітектура будує взаємодію навколо фактів, що вже відбулися.
Подія відрізняється від команди тим, що не вказує конкретному компоненту, яку дію виконати.
Події є контрактами, тому їхні схеми, версії та власники мають бути визначені явно.
За моделі at-least-once споживачі повинні бути ідемпотентними.
Порядок потрібно гарантувати лише для тих потоків, де він необхідний.
Transactional Outbox допомагає атомарно зберегти бізнес-зміну та подію.
Retry і dead-letter queue потрібні для безпечної обробки помилок.
Саги координують розподілені процеси та використовують компенсаційні дії.
Асинхронна взаємодія зазвичай означає eventual consistency, тому проміжні стани потрібно проєктувати явно.
Спостережуваність і трасування є обов’язковими для діагностики асинхронних ланцюжків.