У попередніх розділах (глави 5–8) ми фокусувалися на тактичних патернах — як будувати окремі компоненти системи, моделювати бізнес-логіку всередині одного Обмеженого Контексту (Bounded Context). Ми навчилися створювати Агрегати, Об'єкти-Значення та організовувати їх у Шарувату або Гексагональну архітектуру.
Але жоден сервіс не існує у вакуумі.
У цій главі ми вийдемо за межі одного компонента і розглянемо, як організувати "спілкування" між різними частинами системи. Ми поговоримо про те, як дані перетікають між контекстами, як забезпечити узгодженість (consistency) у розподілених системах і як керувати складними бізнес-процесами, що охоплюють кілька сервісів.
Трансляція Моделей
Інтеграція Агрегатів
Сага та Менеджер Процесів
Обмежений Контекст — це кордон, всередині якого існує певна Єдина Мова (Ubiquitous Language). Термін "Користувач" у контексті Продажів може означати "Покупець", а у контексті Доставки — "Отримувач". У них різні атрибути, різна поведінка і різний зміст.
Коли два контексти взаємодіють, їм потрібно "перекладати" терміни з однієї мови на іншу. Без явного перекладу ми ризикуємо "забруднити" нашу чисту модель чужими концепціями.
Згадаємо стратегічні патерни з Глави 3:
У цій главі ми зосередимося на технічній реалізації ACL та OHS.
Це найпростіший вид трансляції. Він відбувається "на льоту" (on-the-fly) під час проходження запиту або повідомлення.
Уявіть, що ваш сервіс замовлень (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)
);
}
}
Коли системи спілкуються через повідомлення (RabbitMQ, Kafka), трансляція також необхідна.
Якщо BillingContext підписаний на події OrderContext, він не повинен залежати від внутрішньої структури подій OrderContext.
OrderContext публікує спеціальні "інтенціональні" події (Integration Events), які є стабільним контрактом (Published Language).OrderContext публікує "брудні" події, BillingContext може мати вхідний адаптер, який конвертує їх у внутрішні команди перед обробкою.Іноді простого мапінгу недостатньо. Вам може знадобитися:
Для цього потрібне збереження стану в БД.
Уявіть, що ви будуєте Backend for Frontend (BFF). Мобільний додаток хоче отримати "Деталі Замовлення одним запитом". А у вас мікросервіси:
Ваш BFF виступає як Stateful Translator (або просто Aggregator).
Якщо це відбувається асинхронно (через події), вам знадобиться локальна база даних у сервісі-агрегаторі, щоб зберігати проміжні результати ("прийшла інфа від доставки, чекаємо на інфу від оплати"). Це вже нагадує патерн Process Manager, про який ми поговоримо пізніше.
Одна з найскладніших задач — це інтеграція нового чистого DDD-рішення з великою "кулею бруду" (Big Ball of Mud) або застарілою системою (Legacy).
Тут виникає дилема:
Це стратегія захисту нового коду. Ми створюємо "бульбашку", всередині якої діє наша чиста модель. На кордонах цієї бульбашки стоїть дуже товстий ACL.
Стратегії ACL для Legacy
Іноді Legacy система не вміє публікувати події. Вона просто пише в базу. Щоб інтегруватися, ми можемо використати патерн CDC (наприклад, Debezium + Kafka).
db.table.row_changed) в Kafka.OrderConfirmed) і відправляє в наш Context.Це дозволяє перетворити "німий" моноліт на джерело подій.
Головне правило Агрегатів: Одна транзакція — Один Агрегат.
Ми не змінюємо Order і Customer в одній транзакції бази даних. Це порушує межі контекстів і створює проблеми з блокуваннями.
Але бізнес-процеси часто зачіпають кілька агрегатів. Як бути? Відповідь: Eventual Consistency (Узгодженість у кінцевому рахунку).
DomainEvent.Головна проблема тут — надійність публікації подій.
Розглянемо наївний (і неправильний) код:
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.
Ідея геніальна у своїй простоті: використовуйте локальну транзакцію БД для обох дій.
Замість того, щоб слати подію в брокер одразу, ми записуємо її в спеціальну таблицю OutboxMessages в тій самій базі даних, де лежить наш Агрегат.
Уявіть фінансову систему. Якщо подія MoneyTransferred загубиться, гроші просто зникнуть. 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 таблиці:
SELECT * FROM Outbox WHERE Processed = 0 кожні X секунд.
Campaigns).OutboxMessages (insert JSON).Outbox і пересилає в RabbitMQ.Outbox або позначає як Processed.// 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). Тобто, повторна обробка тієї ж події не повинна ламати систему.Якщо Outbox вирішує проблему атомарності всередині одного сервісу при публікації, то Сага керує бізнес-процесом, що охоплює кілька сервісів.
Сага — це послідовність локальних транзакцій. Кожна транзакція оновлює дані в одному сервісі і публікує подію, що тригерить наступний крок. Якщо щось йде не так, Сага запускає Компенсуючі транзакції (Compensating Transactions), щоб відкотити зміни.
У реляційних БД ми звикли до ACID. У мікросервісах ми маємо BASE (Basically Available, Soft state, Eventual consistency).
Процес:
Якщо літак не вдалося забронювати (місць немає), ми повинні скасувати бронь авто і готелю.
Ми не можемо просто ROLLBACK в розподіленій системі. Ми повинні виконати команду CancelCarBooking та CancelHotelBooking.
Сервіси спілкуються подіями децентралізовано. Немає єдиного "диригента".
OrderCreated -> публікує подію.OrderCreated -> бронює готель -> публікує HotelBooked.HotelBooked -> бронює авто -> публікує CarBooked.CarBooked -> провал -> публікує FlightBookingFailed.FlightBookingFailed -> скасовує авто.FlightBookingFailed -> скасовує готель.FlightBookingFailed -> позначає замовлення як "Failed".Є центральний координатор (Оркестратор), який каже всім, що робити.
BookHotel (RPC або Command Message).HotelBooked.BookCar.CarBooked.BookFlight.FlightFailed.CancelCar і CancelHotel.Менеджер Процесів — це еволюція Оркестратора. Якщо Сага — це просто набір кроків "вперед-назад" для досягнення узгодженості, то Менеджер Процесів — це State Machine (машина станів), яка керує довготривалим бізнес-процесом.
Він має свій власний стан (State), який зберігається в БД.
Це Агрегат. Так, звичайний 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"]));
}
}
Часто ці терміни вживають як синоніми. Але технічно:
Одна з найбільших проблем розподілених систем — це зміни. Ви змінили структуру події OrderCreated (перейменували поле), і раптом 5 інших сервісів впали з помилкою десеріалізації.
Подія — це публічний API. Ви не можете змінювати його як заманеться.
Найбезпечніша стратегія. Ви тільки додаєте нові поля, але ніколи не видаляєте і не перейменовуєте старі. JsonSerializer зазвичай ігнорує незнайомі поля, тому старі консюмери просто не побачать нових даних, але продовжать працювати.
Включайте версію в структуру події або в заголовок повідомлення.
{
"eventId": "...",
"eventType": "OrderCreated.v2",
"data": { ... }
}
Або в класі C#:
public class OrderCreatedV1 { ... }
public class OrderCreatedV2 { ... }
Ваш ACL (Message Translator) може приймати обидві версії і мапити їх на одну внутрішню команду.
Консюмер не повинен десеріалізувати всю подію. Він повинен витягувати тільки ті поля, які йому потрібні.
Замість типізованого DTO (OrderCreatedDto), використовуйте JsonDocument або динамічний доступ, якщо це дозволяє мова.
Це патерн Tolerant Reader (Толерантний Читач).
Для серйозних систем (особливо з Kafka) використовуйте Schema Registry (наприклад, Confluent Schema Registry). Це сервіс, який зберігає всі версії ваших контрактів (Avro, Protobuf, JSON Schema). Продюсер перед відправкою валідує повідомлення зі схемою. Якщо ви спробуєте відправити несумісну зміну, Schema Registry відхилить її на етапі CI/CD або Runtime.
Як переконатися, що Сага працює, не піднімаючи 10 докер-контейнерів?
Тестуйте Агрегати і Саги ізольовано.
Сага (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);
}
Перевірте, що збереження в БД і запис в Outbox відбуваються атомарно.
Використовуйте TestContainers з реальною БД.
Це "золотий стандарт" для мікросервісів.
Замість того, щоб Продюсер гадав, що потрібно Консюмеру, Консюмер пише контракт-тест ("Я очікую, що в JSON прийде поле userId типу String").
Ці контракти ("Pacts") публікуються на білдервер (Pact Broker).
Продюсер на своєму CI проганяє ці контракти. Якщо він видалив поле userId, білд впаде, і він не зможе задеплоїтися.
Інструмент: Pact.NET.
SagaState з колонками Id, CurrentState, Payload (JSON) — чудово підходить.Це біда асинхронності. Подія OrderShipped може прийти раніше за OrderPaid через затримки в чергах.
Рішення:
UnorderedEvents і обробити її, коли прийде попередня.Outbox, то ви вже використовуєте Outbox. Якщо ви налаштували його прямо на таблицю Orders — це теж варіант, але тоді ви розкриваєте структуру своєї БД (Internal Model) назовні, що порушує інкапсуляцію. Краще мати явну таблицю Outbox.Інтеграція — це місце, де система стає крихкою. Використовуйте ці патерни, щоб повернути контроль.
CustomerId, ProductId. Не передавайте цілі об'єкти.| Патерн | Коли використовувати | Складність |
|---|---|---|
| ACL (Proxy) | Синхронний виклик або простий мапінг подій. Захист від легасі. | Низька |
| Outbox | Публікація подій з гарантією доставки. Must-have для DDD. | Середня |
| Choreography Saga | Прості процеси (2-3 кроки). | Середня |
| Orchestration Saga | Складні процеси, багато кроків, компенсації. | Висока |
| Process Manager | Довготривалі процеси, таймери, очікування людської реакції. | Висока |
У наступній главі ми поговоримо про Евристики Проектування — як вибирати правильні інструменти та патерни, дивлячись на код і вимоги бізнесу, а не сліпо слідуючи книжкам.
Глава 8. Архітектурні Патерни
Глибоке занурення в архітектурні стилі: від Шаруватої Архітектури до Портів та Адаптерів і CQRS. Вибір правильної структури для вашої бізнес-логіки.
Глава 10. Проектні Евристики
"It depends" (Це залежить) — найпопулярніша відповідь будь-якого консультанта чи архітектора. Але від чого саме це залежить?