Глава 9. Патерни Взаємодії
Глава 9. Патерни Взаємодії
У попередніх розділах (глави 5–8) ми фокусувалися на тактичних патернах — як будувати окремі компоненти системи, моделювати бізнес-логіку всередині одного Обмеженого Контексту (Bounded Context). Ми навчилися створювати Агрегати, Об'єкти-Значення та організовувати їх у Шарувату або Гексагональну архітектуру.
Але жоден сервіс не існує у вакуумі.
У цій главі ми вийдемо за межі одного компонента і розглянемо, як організувати "спілкування" між різними частинами системи. Ми поговоримо про те, як дані перетікають між контекстами, як забезпечити узгодженість (consistency) у розподілених системах і як керувати складними бізнес-процесами, що охоплюють кілька сервісів.
Трансляція Моделей
Інтеграція Агрегатів
Сага та Менеджер Процесів
1. Трансляція Моделей (Model Translation)
Обмежений Контекст — це кордон, всередині якого існує певна Єдина Мова (Ubiquitous Language). Термін "Користувач" у контексті Продажів може означати "Покупець", а у контексті Доставки — "Отримувач". У них різні атрибути, різна поведінка і різний зміст.
Коли два контексти взаємодіють, їм потрібно "перекладати" терміни з однієї мови на іншу. Без явного перекладу ми ризикуємо "забруднити" нашу чисту модель чужими концепціями.
Патерни Інтеграції (нагадування)
Згадаємо стратегічні патерни з Глави 3:
- Partnership (Партнерство): Команди синхронізують зміни. Переклад може бути мінімальним.
- Shared Kernel (Спільне Ядро): Частина моделі є спільною (наприклад, бібліотека з DTO). Переклад не потрібен для спільної частини.
- Customer-Supplier (Клієнт-Постачальник): Постачальник може диктувати формат, або Клієнт змушений адаптуватися.
- Conformist (Конформіст): Клієнт повністю приймає модель Постачальника "як є", без перекладу. (Часто це погана ідея).
- Anticorruption Layer (ACL): Клієнт створює захисний шар, який транслює зовнішню модель у свою внутрішню.
- Open-Host Service (OHS): Постачальник створює публічний API (Published Language), який відрізняється від його внутрішньої моделі, щоб захистити клієнтів від змін.
У цій главі ми зосередимося на технічній реалізації ACL та OHS.
Stateless Translation (Переклад без стану)
Це найпростіший вид трансляції. Він відбувається "на льоту" (on-the-fly) під час проходження запиту або повідомлення.
Синхронна взаємодія (Proxy)
Уявіть, що ваш сервіс замовлень (OrderContext) повинен отримати інформацію про клієнта з CRM (CustomerContext).
Замість того, щоб розкидувати виклики до CRM по всьому коду, ми створюємо Proxy (або Адаптер).
Де це живе в коді? В архітектурі Портів та Адаптерів:
- Порт:
ICustomerGateway(визначений в Domain Layer). - Адаптер:
CrmCustomerAdapter(реалізований в Infrastructure Layer).
// Domain Layer
public record Customer(CustomerId Id, string FullName, Address ShippingAddress);
public interface ICustomerGateway
{
Task<Customer?> GetCustomerAsync(CustomerId id);
}
// Infrastructure Layer (ACL)
public class CrmResponse
{
public string PartnerId { get; set; }
public string PartnerName { get; set; } // В CRM це називається PartnerName
public string Street { get; set; }
public string City { get; set; }
}
public class HttpCrmAdapter : ICustomerGateway
{
private readonly HttpClient _client;
public async Task<Customer?> GetCustomerAsync(CustomerId id)
{
var response = await _client.GetFromJsonAsync<CrmResponse>($"/partners/{id}");
// Ось тут відбувається магія трансляції (Mapping)
return new Customer(
new CustomerId(response.PartnerId),
response.PartnerName,
new Address(response.Street, response.City)
);
}
}
Асинхронна взаємодія (Message Translator)
Коли системи спілкуються через повідомлення (RabbitMQ, Kafka), трансляція також необхідна.
Якщо BillingContext підписаний на події OrderContext, він не повинен залежати від внутрішньої структури подій OrderContext.
- Public Events:
OrderContextпублікує спеціальні "інтенціональні" події (Integration Events), які є стабільним контрактом (Published Language). - Consumer Translator: Якщо
OrderContextпублікує "брудні" події,BillingContextможе мати вхідний адаптер, який конвертує їх у внутрішні команди перед обробкою.
Stateful Translation (Переклад зі станом)
Іноді простого мапінгу недостатньо. Вам може знадобитися:
- Агрегація: Зібрати дані з трьох різних сервісів, щоб сформувати одну внутрішню сутність.
- Де-дуплікація: Відфільтрувати повторювані повідомлення.
- Збагачення (Enrichment): Отримати ID, а потім зробити запит, щоб отримати повні дані.
Для цього потрібне збереження стану в БД.
Aggregator Pattern
Уявіть, що ви будуєте Backend for Frontend (BFF). Мобільний додаток хоче отримати "Деталі Замовлення одним запитом". А у вас мікросервіси:
- Сервіс Замовлень (основна інфа).
- Сервіс Доставки (статус трекінгу).
- Сервіс Оплати (статус транзакції).
- Сервіс Каталогу (картинки товарів).
Ваш BFF виступає як Stateful Translator (або просто Aggregator).
Якщо це відбувається асинхронно (через події), вам знадобиться локальна база даних у сервісі-агрегаторі, щоб зберігати проміжні результати ("прийшла інфа від доставки, чекаємо на інфу від оплати"). Це вже нагадує патерн Process Manager, про який ми поговоримо пізніше.
Інтеграція з Legacy Системами (Bubble Context)
Одна з найскладніших задач — це інтеграція нового чистого DDD-рішення з великою "кулею бруду" (Big Ball of Mud) або застарілою системою (Legacy).
Тут виникає дилема:
- Якщо ми будемо використовувати модель даних Legacy-системи, ми заразимо наш новий контекст поганими абстракціями.
- Якщо ми будемо будувати ідеальну модель, нам доведеться писати дуже складний шар трансляції.
Bubble Context (Контекст-Бульбашка)
Це стратегія захисту нового коду. Ми створюємо "бульбашку", всередині якої діє наша чиста модель. На кордонах цієї бульбашки стоїть дуже товстий ACL.
Стратегії ACL для Legacy
- Synchronizing ACL: Legacy база даних є "майстром". Наш ACL періодично вичитує дані з Legacy БД і оновлює нашу чисту базу даних.
- Event-Intercepting ACL: Ми ставимо тригери на Legacy БД або перехоплюємо зміни (CDC - Change Data Capture), щоб генерувати події для нашої системи.
- Strangler Fig (Фіга-Душитель): Ми поступово замінюємо частини Legacy, перенаправляючи трафік на новий контекст.
Change Data Capture (CDC)
Іноді Legacy система не вміє публікувати події. Вона просто пише в базу. Щоб інтегруватися, ми можемо використати патерн CDC (наприклад, Debezium + Kafka).
- Legacy пише в MySQL.
- Debezium читає binlog MySQL.
- Debezium публікує "сирі" події (
db.table.row_changed) в Kafka. - Наш ACL слухає ці події, транслює їх у Domain Events (
OrderConfirmed) і відправляє в наш Context.
Це дозволяє перетворити "німий" моноліт на джерело подій.
2. Інтеграція Агрегатів (Transactional Integrity)
Головне правило Агрегатів: Одна транзакція — Один Агрегат.
Ми не змінюємо Order і Customer в одній транзакції бази даних. Це порушує межі контекстів і створює проблеми з блокуваннями.
Але бізнес-процеси часто зачіпають кілька агрегатів. Як бути? Відповідь: Eventual Consistency (Узгодженість у кінцевому рахунку).
- Агрегат А змінюється і публікує подію
DomainEvent. - Агрегат Б слухає цю подію і змінюється у відповідь.
Головна проблема тут — надійність публікації подій.
Проблема "Dual Write" (Подвійний запис)
Розглянемо наївний (і неправильний) код:
public async Task DeactivateCampaign(Guid id)
{
// 1. Зміна стану в БД
var campaign = await _repo.GetAsync(id);
campaign.Deactivate();
await _db.SaveChangesAsync(); // Transaction 1
// 2. Публікація в чергу (RabbitMQ/Kafka)
var evt = new CampaignDeactivated(id);
await _bus.PublishAsync(evt); // Transaction 2 (?)
}
Що може піти не так?
SaveChangesAsyncпройшов, а_bus.PublishAsyncвпав (мережа, таймаут, брокер лежить).- Результат: Кампанія деактивована, але ніхто про це не знає. Система неузгоджена.
- (Якщо поміняти місцями)
_bus.PublishAsyncпройшов, аSaveChangesAsyncвпав.- Результат: Подія пішла ("Кампанія деактивована"), а в базі вона досі активна. Система бреше.
Ви не можете просто так обгорнути базу і RabbitMQ в одну розподілену транзакцію (2PC - Two Phase Commit). Це повільно, складно і часто не підтримується хмарними брокерами.
Рішення: Outbox Pattern.
Outbox Pattern (Патерн Вихідної Скриньки)
Ідея геніальна у своїй простоті: використовуйте локальну транзакцію БД для обох дій.
Замість того, щоб слати подію в брокер одразу, ми записуємо її в спеціальну таблицю OutboxMessages в тій самій базі даних, де лежить наш Агрегат.
Чому це важливо?
Уявіть фінансову систему. Якщо подія MoneyTransferred загубиться, гроші просто зникнуть. Outbox гарантує, що подія буде збережена в тій же транзакції, що і зміна балансу.
Деталі реалізації Outbox
Таблиця OutboxMessages повинна мати такі колонки:
Id(UUID): Унікальний ідентифікатор повідомлення.Type(string): Тип події (повне ім'я класу).Payload(json/blob): Самі дані події.OccurredOn(timestamp): Коли це сталося.TraceId(string): Для розподіленого трейсингу (OpenTelemetry).CorrelationId(string): ID бізнес-транзакції або Causing Event ID.
Relay (Poller vs Transaction Log Tailing) Є два способи дістати дані з Outbox таблиці:
- Polling Publisher: Простий JOB, який робить
SELECT * FROM Outbox WHERE Processed = 0кожні X секунд.- Плюси: Легко реалізувати. Працює з будь-якою SQL БД.
- Мінуси: Затримка (Latency). Навантаження на БД. Можливі конфлікти (Race conditions) при багатьох інстансах.
- Transaction Log Tailing (CDC): Використання Debezium для читання змін саме в таблиці Outbox.
- Плюси: Майже нульова затримка. Не вантажить БД запитами.
- Мінуси: Складніша інфраструктура (Kafka, Debezium Connectors).
Алгоритм
- Почати транзакцію БД.
- Зберегти зміни Агрегата (напр. таблиця
Campaigns). - Зберегти подію в таблицю
OutboxMessages(insert JSON). - Закомітити транзакцію.
- Атомарність гарантує ACID вашої БД (Postgres/MySQL/MSSQL). Або збережеться все, або нічого.
- Окремий фоновий процес (Relay) читає з таблиці
Outboxі пересилає в RabbitMQ. - Після успішної відправки — видаляє запис з
Outboxабо позначає якProcessed.
Реалізація на C# (EF Core)
// 1. Сутність Outbox Message
public class OutboxMessage
{
public Guid Id { get; set; }
public string Type { get; set; }
public string Data { get; set; } // JSON
public DateTime OccurredOn { get; set; }
public DateTime? ProcessedDate { get; set; }
}
// 2. Збереження в одній транзакції
public async Task DeactivateCampaign(Guid id)
{
using var transaction = _context.Database.BeginTransaction();
var campaign = await _context.Campaigns.FindAsync(id);
campaign.Deactivate(); // Генерує DomainEvents в пам'яті
// Перетворюємо Domain Events на Outbox Messages
var domainEvents = campaign.DomainEvents;
foreach (var evt in domainEvents)
{
var outboxMsg = new OutboxMessage
{
Id = Guid.NewGuid(),
Type = evt.GetType().Name,
Data = JsonSerializer.Serialize(evt, evt.GetType()),
OccurredOn = DateTime.UtcNow
};
_context.OutboxMessages.Add(outboxMsg);
}
await _context.SaveChangesAsync();
await transaction.CommitAsync();
}
UPDATE Outbox), він відправить його знову після перезапуску.
Тому ваші консюмери (послухачі) повинні бути Ідемпотентними (Idempotent). Тобто, повторна обробка тієї ж події не повинна ламати систему.3. Сага (Saga)
Якщо Outbox вирішує проблему атомарності всередині одного сервісу при публікації, то Сага керує бізнес-процесом, що охоплює кілька сервісів.
Сага — це послідовність локальних транзакцій. Кожна транзакція оновлює дані в одному сервісі і публікує подію, що тригерить наступний крок. Якщо щось йде не так, Сага запускає Компенсуючі транзакції (Compensating Transactions), щоб відкотити зміни.
У реляційних БД ми звикли до ACID. У мікросервісах ми маємо BASE (Basically Available, Soft state, Eventual consistency).
Приклад: Бронювання Подорожі
Процес:
- Забронювати готель (HotelService).
- Забронювати авто (CarService).
- Забронювати літак (FlightService).
Якщо літак не вдалося забронювати (місць немає), ми повинні скасувати бронь авто і готелю.
Ми не можемо просто ROLLBACK в розподіленій системі. Ми повинні виконати команду CancelCarBooking та CancelHotelBooking.
Типи Саг
1. Хореографія (Choreography)
Сервіси спілкуються подіями децентралізовано. Немає єдиного "диригента".
- Order Service:
OrderCreated-> публікує подію. - Hotel Service: слухає
OrderCreated-> бронює готель -> публікуєHotelBooked. - Car Service: слухає
HotelBooked-> бронює авто -> публікуєCarBooked. - Flight Service: слухає
CarBooked-> провал -> публікуєFlightBookingFailed. - Car Service: слухає
FlightBookingFailed-> скасовує авто. - Hotel Service: слухає
FlightBookingFailed-> скасовує готель. - Order Service: слухає
FlightBookingFailed-> позначає замовлення як "Failed".
- Переваги: Просто почати, мала зв'язність (loose coupling), немає єдиної точки відмови.
- Недоліки: Важко зрозуміти весь процес (він розмазаний по всіх сервісах). "Пекло подій" (Event Hell). Циклічні залежності.
2. Оркестрація (Orchestration)
Є центральний координатор (Оркестратор), який каже всім, що робити.
- Orchestrator: Посилає команду
BookHotel(RPC або Command Message). - Hotel Service:
HotelBooked. - Orchestrator: Ок, далі. Посилає команду
BookCar. - Car Service:
CarBooked. - Orchestrator: Ок, далі. Посилає команду
BookFlight. - Flight Service:
FlightFailed. - Orchestrator: Ой! Викликає
CancelCarіCancelHotel.
- Переваги: Процес явний і записаний в одному місці. Легко керувати помилками і таймаутами.
- Недоліки: Оркестратор стає "розумним", а сервіси "тупими" (CRUD). Центральна точка відмови (хоча це лікується реплікацією).
4. Process Manager (Менеджер Процесів)
Менеджер Процесів — це еволюція Оркестратора. Якщо Сага — це просто набір кроків "вперед-назад" для досягнення узгодженості, то Менеджер Процесів — це State Machine (машина станів), яка керує довготривалим бізнес-процесом.
Він має свій власний стан (State), який зберігається в БД.
Анатомія Process Manager
Це Агрегат. Так, звичайний DDD Агрегат, але його призначення — не зберігати бізнес-дані (як профіль користувача), а зберігати стан процесу.
public class TripBookingSaga : AggregateRoot
{
private TripId _id;
private BookingState _state; // New, HotelBooked, CarBooked, Confirmed, Failed
private Dictionary<string, string> _bookingIds; // Зберігаємо ID зовнішніх броней
public void Handle(HotelBooked evt)
{
if (_state != BookingState.New) return; // Ідемпотентність
_bookingIds["Hotel"] = evt.HotelBookingId;
_state = BookingState.HotelBooked;
// Вирішуємо, що робити далі
AddDomainEvent(new BookCarCommand(...));
}
public void Handle(CarBooked evt)
{
if (_state != BookingState.HotelBooked) return;
_bookingIds["Car"] = evt.CarBookingId;
_state = BookingState.CarBooked;
AddDomainEvent(new BookFlightCommand(...));
}
public void Handle(FlightBookingFailed evt)
{
_state = BookingState.Cancelling;
// Запускаємо компенсацію
AddDomainEvent(new CancelCarCommand(_bookingIds["Car"]));
AddDomainEvent(new CancelHotelCommand(_bookingIds["Hotel"]));
}
}
Відмінність Саги від PM
Часто ці терміни вживають як синоніми. Але технічно:
- Сага: Патерн управління транзакціями (як відкотити, якщо впало).
- Process Manager: Більш загальний патерн. Він може чекати на подію "Користувач підтвердив email" 3 дні. Він може мати складну логіку if-else, таймери ("якщо водій не знайшовся за 5 хв, скасувати замовлення").
5. Еволюція Контрактів Подій (Schema Evolution)
Одна з найбільших проблем розподілених систем — це зміни. Ви змінили структуру події OrderCreated (перейменували поле), і раптом 5 інших сервісів впали з помилкою десеріалізації.
Подія — це публічний API. Ви не можете змінювати його як заманеться.
Стратегії Версійності
1. Адитивні зміни (Additive Changes)
Найбезпечніша стратегія. Ви тільки додаєте нові поля, але ніколи не видаляєте і не перейменовуєте старі. JsonSerializer зазвичай ігнорує незнайомі поля, тому старі консюмери просто не побачать нових даних, але продовжать працювати.
2. Явна Версійність (Explicit Versioning)
Включайте версію в структуру події або в заголовок повідомлення.
{
"eventId": "...",
"eventType": "OrderCreated.v2",
"data": { ... }
}
Або в класі C#:
public class OrderCreatedV1 { ... }
public class OrderCreatedV2 { ... }
Ваш ACL (Message Translator) може приймати обидві версії і мапити їх на одну внутрішню команду.
3. Weak Schema (Слабка Схема)
Консюмер не повинен десеріалізувати всю подію. Він повинен витягувати тільки ті поля, які йому потрібні.
Замість типізованого DTO (OrderCreatedDto), використовуйте JsonDocument або динамічний доступ, якщо це дозволяє мова.
Це патерн Tolerant Reader (Толерантний Читач).
Schema Registry
Для серйозних систем (особливо з Kafka) використовуйте Schema Registry (наприклад, Confluent Schema Registry). Це сервіс, який зберігає всі версії ваших контрактів (Avro, Protobuf, JSON Schema). Продюсер перед відправкою валідує повідомлення зі схемою. Якщо ви спробуєте відправити несумісну зміну, Schema Registry відхилить її на етапі CI/CD або Runtime.
6. Тестування Взаємодії
Як переконатися, що Сага працює, не піднімаючи 10 докер-контейнерів?
Unit Tests
Тестуйте Агрегати і Саги ізольовано.
Сага (Process Manager) — це просто клас, який приймає DomainEvent і повертає Command (або список команд). Це чиста функція (майже).
[Fact]
public void Should_book_car_after_hotel_booked()
{
// Arrange
var saga = new TripBookingSaga();
saga.TransitionTo(BookingState.New);
// Act
saga.Handle(new HotelBooked { HotelBookingId = "h-123" });
// Assert
saga.State.Should().Be(BookingState.HotelBooked);
saga.DomainEvents.Should().Contain(e => e is BookCarCommand);
}
Integration Tests (Outbox)
Перевірте, що збереження в БД і запис в Outbox відбуваються атомарно.
Використовуйте TestContainers з реальною БД.
Contract Tests (Consumer-Driven Contracts)
Це "золотий стандарт" для мікросервісів.
Замість того, щоб Продюсер гадав, що потрібно Консюмеру, Консюмер пише контракт-тест ("Я очікую, що в JSON прийде поле userId типу String").
Ці контракти ("Pacts") публікуються на білдервер (Pact Broker).
Продюсер на своєму CI проганяє ці контракти. Якщо він видалив поле userId, білд впаде, і він не зможе задеплоїтися.
Інструмент: Pact.NET.
7. Часті Питання (FAQ)
SagaState з колонками Id, CurrentState, Payload (JSON) — чудово підходить.Це біда асинхронності. Подія OrderShipped може прийти раніше за OrderPaid через затримки в чергах.
Рішення:
- Кинути ексепшн і дозволити брокеру зробити Redelivery пізніше.
- Зберегти подію в таблицю
UnorderedEventsі обробити її, коли прийде попередня.
Outbox, то ви вже використовуєте Outbox. Якщо ви налаштували його прямо на таблицю Orders — це теж варіант, але тоді ви розкриваєте структуру своєї БД (Internal Model) назовні, що порушує інкапсуляцію. Краще мати явну таблицю Outbox.5. Підсумки та Рекомендації
Інтеграція — це місце, де система стає крихкою. Використовуйте ці патерни, щоб повернути контроль.
- Завжди захищайте свої Агрегати. Ніколи не публікуйте події без Outbox або іншого механізму гарантованої доставки, якщо вам важлива узгодженість.
- Не бійтеся дублювання даних. Краще мати копію імені клієнта в сервісі замовлень (очевидно через ACL), ніж робити синхронний HTTP запит до CRM кожного разу, коли треба показати список замовлень.
- Use ID references. Між контекстами передавайте тільки ID.
CustomerId,ProductId. Не передавайте цілі об'єкти. - Асинхронність — друг. Синхронні виклики (HTTP/gRPC) створюють часовий зв'язок (temporal coupling). Якщо сервіс Б лежить, сервіс А теж лежить. Черги повідомлень розривають цей зв'язок.
| Патерн | Коли використовувати | Складність |
|---|---|---|
| ACL (Proxy) | Синхронний виклик або простий мапінг подій. Захист від легасі. | Низька |
| Outbox | Публікація подій з гарантією доставки. Must-have для DDD. | Середня |
| Choreography Saga | Прості процеси (2-3 кроки). | Середня |
| Orchestration Saga | Складні процеси, багато кроків, компенсації. | Висока |
| Process Manager | Довготривалі процеси, таймери, очікування людської реакції. | Висока |
У наступній главі ми поговоримо про Евристики Проектування — як вибирати правильні інструменти та патерни, дивлячись на код і вимоги бізнесу, а не сліпо слідуючи книжкам.
Глава 8. Архітектурні Патерни
Глибоке занурення в архітектурні стилі: від Шаруватої Архітектури до Портів та Адаптерів і CQRS. Вибір правильної структури для вашої бізнес-логіки.
Глава 10. Проектні Евристики
"It depends" (Це залежить) — найпопулярніша відповідь будь-якого консультанта чи архітектора. Але від чого саме це залежить?