Перейти до вмісту

Архітектурні стилі

Як розкладати код і сервіси: шари й чиста архітектура, моноліт проти мікросервісів, DDD, CQRS і event sourcing, перенесення legacy без великого переписування. На співбесіді тут перевіряють не визначення, а вміння назвати ціну кожного рішення й умови, за яких воно окуповується.

Перед цією темою: Впровадження залежностей.

Junior+

Чим шарувата архітектура відрізняється від чистої (гексагональної, onion)?

Section titled “Чим шарувата архітектура відрізняється від чистої (гексагональної, onion)?”
Відповідь

Що це

Шарувата архітектура, згори вниз; шар викликає лише нижній:

  1. Представлення — контролери.
  2. Бізнес-логіка — сервіси.
  3. Доступ до даних — репозиторії, EF Core.

Чиста архітектура, від центру назовні; зовнішній шар залежить від внутрішнього:

  1. Domain — сутності й правила, без залежностей.
  2. Application — сценарії та інтерфейси-порти: IOrderRepository.
  3. Infrastructure — реалізації портів: EF Core, SMTP.
  4. Web API — викликає сценарії та зв’язує порти з реалізаціями.

Де живе інтерфейс репозиторію

У шаруватій — у шарі даних, поруч із реалізацією. У чистій — у ядрі, поруч із тим, хто його викликає.

Типова помилка

Розкласти код по папках Domain та Infrastructure, але залишити в домені атрибути EF Core. Залежність однаково спрямована назовні.

Як влаштовано

  • Порт — інтерфейс, який оголошує ядро. Вхідний порт — сценарій, який викликають ззовні. Вихідний — те, що потрібно ядру: сховище, пошта, зовнішній API.
  • Адаптер — реалізація порту під технологію. Контролер — вхідний адаптер, репозиторій на EF Core — вихідний.
  • Напрямок. Посилання між проєктами йдуть усередину, а виклики під час роботи — в обидва боки: ядро викликає репозиторій через інтерфейс. Зв’язує все composition root у Program.cs.

Коли що обирати

Ситуація Стиль
CRUD, мало правил шарувата або вертикальні зрізи за фічами
Складні правила, багато зовнішніх систем clean або hexagonal
Інфраструктура змінюватиметься порти й адаптери на цій межі

Пастки

  • Протікаючий порт. Репозиторій повертає IQueryable<T> — ядро знову залежить від EF Core і від того, які запити він уміє перекласти в SQL.
  • Інтерфейс на кожен клас. Порт потрібен на межі із зовнішнім світом, а не між двома класами домену.
  • Generic-репозиторій поверх EF Core. DbContext уже реалізує unit of work, а DbSet<T> — репозиторій; обгортка IRepository<T> з GetAll нічого не додає.

Правило

Інтерфейс оголошує той, хто його використовує; посилання між проєктами спрямовані до домену.

Під капотом

  • Hexagonal — Алістер Кокберн, 2005: у застосунку немає «верху» й «низу», є ведучі порти (UI, тести) та ведені (база, черги).
  • Onion — Джеффрі Палермо, 2008: концентричні шари, домен у центрі.
  • Clean — Роберт Мартін, 2012: Dependency Rule, залежності в коді вказують лише всередину.
  • Анемічна модель ортогональна стилю. Фаулер описує багату модель предметної області як частину доменного шару шаруватої архітектури.

Ціна

  • Мапінг: доменна сутність, сутність EF Core і DTO API — три моделі. Компроміс Microsoft в eShopOnWeb: доменні сутності мапляться через Fluent API в Infrastructure, без атрибутів у домені.
  • Опосередкованість: шлях від ендпоінта до SQL проходить через кілька проєктів, налагодження й навігація повільніші.

Компроміси

  • Невеликий сервіс без складних правил дешевше писати вертикальними зрізами: ендпоінт, обробник і запит до DbContext поруч.
  • Окремий проєкт Domain без посилань на пакети коштує майже нічого й дає головне — компілятор не пустить EF Core у домен.

Як перевірити в продакшені

  • У Domain.csproj немає PackageReference на EF Core та ASP.NET Core.
  • Тести архітектури (NetArchTest, ArchUnitNET) у CI падають, якщо домен посилається на інфраструктуру.

Підсумок: суть чистої архітектури — напрямок залежностей до домену; кількість шарів і форма моделі — окремі рішення зі своєю ціною.

Приклад
using Shop.Application;
using Shop.Infrastructure;
// Composition root: порт зв'язується з адаптером поза ядром
var handler = new PlaceOrderHandler(new ConsoleOrderRepository());
handler.Handle(42, 3); // Saved Order { Id = 42, Quantity = 3 }
namespace Shop.Domain
{
// Ядро: не посилається ні на EF Core, ні на ASP.NET Core
public record Order(int Id, int Quantity);
}
namespace Shop.Application
{
using Shop.Domain;
// Порт оголошує той, хто його використовує
public interface IOrderRepository { void Save(Order order); }
public class PlaceOrderHandler(IOrderRepository repository)
{
public void Handle(int id, int quantity) => repository.Save(new Order(id, quantity));
}
}
namespace Shop.Infrastructure
{
using Shop.Application;
using Shop.Domain;
// Адаптер реалізує порт; у реальному коді тут EF Core
public class ConsoleOrderRepository : IOrderRepository
{
public void Save(Order order) => Console.WriteLine($"Saved {order}");
}
}

У рішенні кожен простір імен — окремий проєкт: Infrastructure посилається на Application, Application — на Domain, зворотних посилань немає.

Middle+

Моноліт, модульний моноліт чи мікросервіси: що коли обрати?

Section titled “Моноліт, модульний моноліт чи мікросервіси: що коли обрати?”
Відповідь

Що це

Стиль Як влаштований
Моноліт один застосунок, одна база, один деплой
Модульний моноліт один деплой, код поділено на модулі з межами
Мікросервіси багато сервісів, у кожного своя база й свій деплой

Навіщо мікросервіси

  • Частини системи випускаються й масштабуються незалежно.
  • Команди не чекають одна на одну: кожна відповідає за свої сервіси.

Чим платять

Виклик сусіднього модуля перетворюється на мережевий запит. Він може впасти, зависнути або повернути застарілі дані, а однієї транзакції на два сервіси вже немає.

Типова помилка

Ділити невеликий застосунок однієї команди на мікросервіси «на виріст». Виходить та сама система, лише повільніша й складніша в налагодженні.

Як влаштовано

Модульний моноліт:

  • Модуль — вертикальна частина за бізнес-можливістю: замовлення, склад, оплата. Не технічний шар.
  • Назовні модуль відкриває лише контракт: інтерфейс, DTO, події. Реалізація — internal в окремій збірці.
  • Дані модуля — у своїй схемі БД; чужі таблиці не читають і не джойнять.
  • Межі перевіряють тести архітектури, а не домовленості.

Коли що обирати

Ознака Модульний моноліт Мікросервіси
Деплой один на все незалежний у сервісу
Виклик сусіда виклик методу мережа: тайм-аути, повтори
Узгодженість одна транзакція БД eventual consistency
Масштабування увесь застосунок окремий сервіс
Налагодження один процес розподілене трасування

Пастки

  • Спільна база в сервісів. Сервіси пов’язані схемою: змінити таблицю — означає випустити всіх її читачів.
  • Межі за технікою. Сервіс «валідації» або сервіс «доступу до БД» бере участь у кожному запиті.
  • Синхронні ланцюжки. A чекає B, B чекає C: затримки додаються, а відмова будь-якого валить увесь ланцюжок.

Правило

Сервіс виділяють, коли в частини системи своя причина: навантаження, цикл релізів, команда, вимоги до надійності чи безпеки.

Ціна

  • Мережа ненадійна: потрібні тайм-аути, повтори, ідемпотентність.
  • Доступність ланцюжка — добуток доступностей: три сервіси по 99,9% дають близько 99,7%.
  • Узгодженість між базами — eventual: outbox, саги й компенсації (тема «Черги й Kafka»).
  • Інфраструктура на кожен сервіс: конвеєр випуску, моніторинг, трасування, версії контрактів.

Компроміси

  • Закон Конвея. Мелвін Конвей, 1968: структура системи копіює структуру комунікацій. Сервіси без своєї команди-власника розпадаються на розподілений моноліт; тому інколи спершу змінюють команди, а потім архітектуру.
  • Monolith first. Фаулер радить починати з моноліту: правильні межі видно лише після того, як домен зрозумілий. Перенести межу між модулями — рефакторинг, між сервісами — міграція даних.
  • Незалежний деплой працює, лише якщо контракти зворотно сумісні й версіонуються.

Як перевірити в продакшені

  • Сервіси завжди випускаються разом — це розподілений моноліт.
  • У трасуванні видно глибину синхронних ланцюжків на запит.
  • Тести архітектури в CI ловлять звернення модуля до внутрішніх частин іншого.

Підсумок: мікросервіси — обмін простоти на незалежність команд; без реальної потреби в цій незалежності модульний моноліт дешевший і простіший у змінах.

Приклад
using Orders;
var builder = WebApplication.CreateBuilder(args);
// Модуль реєструє себе сам; іншим модулям видно лише контракт
builder.Services.AddOrdersModule();
var app = builder.Build();
app.MapGet("/orders/{id:int}", (int id, IOrdersApi orders) => orders.GetStatus(id));
app.Run(); // GET /orders/42 → "Order 42: shipped"
namespace Orders
{
// Публічний контракт модуля
public interface IOrdersApi { string GetStatus(int id); }
// У реальному рішенні модуль — окремий проєкт,
// і internal закриває реалізацію від інших модулів
internal sealed class OrdersService : IOrdersApi
{
public string GetStatus(int id) => $"Order {id}: shipped";
}
public static class OrdersModule
{
public static IServiceCollection AddOrdersModule(this IServiceCollection services) =>
services.AddScoped<IOrdersApi, OrdersService>();
}
}
Middle+

Що таке DDD і які його ідеї потрібні на практиці?

Section titled “Що таке DDD і які його ідеї потрібні на практиці?”
Відповідь

Що це

DDD — підхід із книжки Еріка Еванса (2003): код описує бізнес тими самими словами й правилами, що й експерти.

Головні ідеї

  • Єдина мова. Якщо склад каже «резерв» і «відвантаження», у коді ті самі Reserve і Shipment, а не UpdateStatus(3).
  • Обмежений контекст. Межа, де слово має одне значення. «Товар» на складі — місце й вага, у каталозі — опис і ціна.
  • Value object. Значення без ідентичності: Money, Address. Два об’єкти рівні, якщо рівні поля.
  • Агрегат. Група об’єктів, яку змінюють цілком через корінь: Order і його рядки.

Типова помилка

Вважати DDD набором папок Entities, ValueObjects, Repositories. Без єдиної мови й меж це просто структура проєкту.

Як влаштовано

Рівень Що вирішує Інструменти
Стратегічний як розрізати систему піддомени, контексти, карта контекстів
Тактичний як моделювати всередині сутності, value objects, агрегати, події

Агрегат:

  1. Корінь — єдина точка входу: order.AddLine(...), а не order.Lines.Add(...).
  2. Інваріанти («сума не більша за кредитний ліміт») перевіряються в методах кореня.
  3. Репозиторій — на агрегат, а не на таблицю: агрегат завантажують і зберігають цілком.
  4. Доменна подія (OrderSubmitted) повідомляє про факт, і інші агрегати реагують на неї у своїх транзакціях.

Пастки

  • Величезний агрегат. «Клієнт з усіма замовленнями» довго завантажується й постійно ловить конфлікти конкурентності.
  • Публічні сетери. Інваріанти розповзаються по сервісах, і модель стає анемічною.
  • Одна модель на всю компанію. Єдиний Customer для продажів, доставки й бухгалтерії обростає полями, потрібними лише одному контексту.

Правило

Одна транзакція змінює один агрегат; інші агрегати — за ідентифікатором і через події.

Карта контекстів

Карта описує стосунки між контекстами й командами:

Стосунок Зміст
Customer/Supplier постачальник враховує потреби споживача
Conformist споживач приймає чужу модель як є
Anti-corruption layer споживач перекладає чужу модель у свою
Open host service, published language постачальник дає стабільний публічний протокол
Shared kernel дві команди ділять частину моделі й змінюють її разом

Ціна

  • Моделювання потребує часу експертів; без їхньої участі виходить модель розробника, а не бізнесу.
  • Value objects і закриті колекції потребують налаштування EF Core: owned types, complex types (EF Core 8+), мапінг полів.
  • Узгодження агрегатів через події — eventual consistency. Інтеграційні події в інші сервіси надійно надсилають через outbox (тема «Черги й Kafka»).

Компроміси

  • Еванс ділить піддомени на core, supporting і generic. Повний тактичний DDD — лише для core; generic, наприклад авторизацію, купують.
  • Вернон (Effective Aggregate Design): маленькі агрегати, посилання за Id, eventual consistency за межею агрегату. Якщо бізнес вимагає негайної узгодженості двох агрегатів, це привід переглянути межі.

Як перевірити в продакшені

  • Часті DbUpdateConcurrencyException на одному агрегаті — він завеликий або гарячий.
  • На рев’ю видно транзакцію, яка зберігає кілька агрегатів одразу.

Підсумок: головне в DDD — межі контекстів і маленькі агрегати з явними інваріантами; тактичні патерни застосовують лише там, де складність домену їх окуповує.

Приклад
var order = new Order(creditLimit: new Money(100m, "UAH"));
order.AddLine("SKU-1", new Money(60m, "UAH"));
try { order.AddLine("SKU-2", new Money(50m, "UAH")); }
catch (InvalidOperationException ex) { Console.WriteLine(ex.Message); } // Credit limit exceeded
order.Submit();
Console.WriteLine(order.Events[0]); // OrderSubmitted { OrderId = ... }
Console.WriteLine(new Money(5m, "UAH") == new Money(5m, "UAH")); // True: рівність за значенням
// Value object: незмінний record без ідентичності
public record Money(decimal Amount, string Currency)
{
public Money Add(Money other) => other.Currency == Currency
? this with { Amount = Amount + other.Amount }
: throw new InvalidOperationException("Currency mismatch");
}
public record OrderSubmitted(Guid OrderId);
// Корінь агрегату: стан змінюється лише через методи, інваріанти всередині
public class Order(Money creditLimit)
{
private readonly List<Money> _lines = [];
private readonly List<object> _events = [];
public Guid Id { get; } = Guid.NewGuid();
public IReadOnlyList<object> Events => _events;
public void AddLine(string sku, Money price)
{
var total = _lines.Aggregate(price, (sum, line) => sum.Add(line));
if (total.Amount > creditLimit.Amount) throw new InvalidOperationException("Credit limit exceeded");
_lines.Add(price);
}
public void Submit() => _events.Add(new OrderSubmitted(Id)); // доменна подія
}
Senior+

Що таке CQRS і event sourcing і коли вони виправдані?

Section titled “Що таке CQRS і event sourcing і коли вони виправдані?”
Відповідь

Що це

  • Команда змінює стан і майже нічого не повертає: PlaceOrder.
  • Запит повертає дані й нічого не змінює: GetOrderSummary.
  • CQRS — у команд і запитів різні моделі: для запису — доменна модель із правилами, для читання — пласкі DTO під екран.
  • Event sourcing — замість рядка «баланс = 70» зберігаються події Deposited 100 і Withdrawn 30; баланс отримують, програючи їх.

Навіщо

  • Читання не тягне складну модель запису й пишеться простими запитами.
  • Event sourcing зберігає історію кожної зміни.

Типова помилка

Думати, що CQRS вимагає двох баз і черги. Найпростіший CQRS — різні класи для команд і запитів над однією базою.

Як влаштовано

Варіант Читання Узгодженість
Одна база, різні моделі проєкції в DTO, AsNoTracking, Dapper одразу
Окрема модель читання свої таблиці чи база, оновлюються за подіями eventual
Event sourcing проєкції з потоку подій eventual

Event sourcing покроково:

  1. Команда завантажує потік подій агрегату й відновлює його стан.
  2. Агрегат перевіряє інваріанти й створює нові події.
  3. Події дописуються у сховище подій з перевіркою очікуваної версії потоку — це оптимістична конкурентність.
  4. Проєкції читають нові події й оновлюють моделі читання.

Пастки

  • Read-your-writes. Користувач зберіг і не бачить змін у списку. Допомагають відповідь із самої команди, очікування потрібної версії проєкції або оптимістичний UI.
  • CRUD-події. OrderUpdated з усіма полями втрачає зміст; події називають бізнес-фактами в минулому часі: OrderShipped.
  • ES на всю систему. Довідники й налаштування в подіях лише ускладнюють код.

Правило

Починайте з CQRS в одній базі. Окрема модель читання — за реальної різниці навантаження або форми даних, ES — коли історія потрібна бізнесу.

Під капотом

  • Сховище подій — журнал потоків лише на дозапис: KurrentDB (колишня EventStoreDB), Marten поверх PostgreSQL або своя таблиця з унікальним ключем (потік, версія).
  • Снапшот — стан на версії N; агрегат завантажується з нього плюс хвіст подій. Потреба в снапшотах часто означає, що потік задовгий і його варто закривати за періодами.
  • Нова модель читання будується програванням усіх подій від початку; на великих обсягах це години.

Ціна

  • Версіонування. Події не змінюють заднім числом. Нові поля додають необов’язковими; несумісна зміна — новий тип події або upcasting: стару подію під час читання перетворюють на нову.
  • Персональні дані. Видалення на вимогу закону конфліктує з незмінним журналом; такі дані зберігають поза подіями або шифрують ключем на людину й видаляють ключ.

Компроміси

  • MediatR не дорівнює CQRS. CQRS — про окремі моделі, MediatR — патерн Mediator: він доставляє повідомлення обробнику всередині процесу. CQRS роблять і звичайними класами-обробниками.
  • Ліцензія MediatR. 12.5.0 — остання версія під Apache 2.0. З 13.0 (2 липня 2025) — подвійна ліцензія RPL 1.5 і комерційна; Community-редакція безкоштовна за виручки до 5 млн доларів на рік і поза продакшеном. Для нових проєктів це привід узяти свій тонкий диспетчер.

Як перевірити в продакшені

  • Відставання проєкцій: позиція останньої обробленої події проти кінця журналу — метрика з алертом.
  • Довжина потоків і час завантаження агрегату показують, де потрібні снапшоти.
  • Часті конфлікти очікуваної версії — гарячий потік.

Підсумок: CQRS і ES — інструменти для конкретного контексту з явною вигодою; їхня ціна — eventual consistency, версіонування подій і експлуатація проєкцій.

Приклад
// Потік подій рахунку — єдине джерело правди
List<object> stream = [new Deposited(100m), new Withdrawn(30m)];
var account = new Account(stream); // стан = програні події
account.Withdraw(50m);
stream.AddRange(account.Changes); // у реальності — дозапис з очікуваною версією
Console.WriteLine(account.Balance); // 20
// Проєкція: модель читання з тих самих подій
var history = stream.Select(e => e switch
{
Deposited d => $"+{d.Amount}",
Withdrawn w => $"-{w.Amount}",
_ => "?"
});
Console.WriteLine(string.Join(" ", history)); // +100 -30 -50
public record Deposited(decimal Amount);
public record Withdrawn(decimal Amount);
public class Account
{
public decimal Balance { get; private set; }
public List<object> Changes { get; } = [];
public Account(IEnumerable<object> history)
{
foreach (var e in history) Apply(e);
}
public void Withdraw(decimal amount)
{
if (amount > Balance) throw new InvalidOperationException("Insufficient funds");
var e = new Withdrawn(amount);
Apply(e);
Changes.Add(e);
}
private void Apply(object e) => Balance += e switch
{
Deposited d => d.Amount,
Withdrawn w => -w.Amount,
_ => 0m
};
}
Senior+

Як поступово мігрувати legacy-систему без великого переписування?

Section titled “Як поступово мігрувати legacy-систему без великого переписування?”
Відповідь

Що це

Велике переписування — зробити нову систему з нуля й перемкнутися одразу. Ризик: роки без користі для бізнесу, а стара система тим часом продовжує змінюватися.

Поступова міграція (strangler fig, Мартін Фаулер, 2004) — нове росте навколо старого, доки старе не стане непотрібним.

Як користуватися

  1. Обрати невелику цінну функцію: звіт, один екран.
  2. Описати її поточну поведінку тестами.
  3. Реалізувати її в новому сервісі.
  4. Перемкнути виклики: у вебі — через проксі, у товстому клієнті — викликом API замість прямого SQL.
  5. Переконатися, що старим шляхом ніхто не користується, і видалити його.

Типова помилка

Змінювати поведінку одночасно з перенесенням. Спершу перенести як є, потім покращувати, інакше незрозуміло, що зламала міграція.

Як влаштовано

  • Фасад. YARP перед ASP.NET: перенесені маршрути обслуговує ASP.NET Core, решту MapForwarder пересилає в старий застосунок. Спільні сесію й автентифікацію дають System.Web adapters.
  • Anti-corruption layer. Шар перекладу між моделлю legacy і новою: новий код не знає про таблицю T_ORD_HDR і прапорці 'Y'/'N'.
  • Характеризаційні тести (Майкл Фезерс). Фіксують, що код робить зараз, а не що має робити: реальні вхідні дані проганяють через старий код і зберігають результат як еталон.
  • Feature toggles. Перемикання на нову реалізацію за користувачем, філією або відсотком, з миттєвим відкотом. У .NET — Microsoft.FeatureManagement.

Товстий клієнт

Функції настільного клієнта переносять у сервіси: клієнт викликає HTTP API замість SQL у базу. Зазвичай спершу переносять читання, потім запис, а стару реалізацію в клієнті вимикають прапорцем.

Пастки

  • Синхронізація в обидва боки між старою й новою базою: конфлікти й нескінченні цикли оновлень.
  • Новий код читає legacy-таблиці напряму. Стара модель протікає в нову.
  • Вічні прапорці. Кожен прапорець подвоює кількість шляхів у коді; у прапорця має бути строк видалення.

Правило

Кожен крок оборотний, у кожної таблиці — один власник, що пише.

Дані в перехідний період

  1. Спільна база: новий сервіс читає й пише старі таблиці через ACL. Зміни схеми — лише сумісні (зміна схеми без простою).
  2. Володіння переходить по таблицях: записувач один, інша сторона читає через представлення, API або потік змін.
  3. Окрема база нового сервісу — коли старих записувачів не залишилося.

Ціна

  • Дві системи працюють і підтримуються одночасно, виправлення інколи роблять в обох.
  • Синхронізація даних і спільна автентифікація — окрема інфраструктура на весь час міграції.

Компроміси

  • Паралельний запуск. Нова реалізація рахує поруч зі старою, результати порівнюються, а відповідь віддає стара. Дорого, але знімає ризик для розрахунків: цін, залишків, нарахувань.
  • Branch by abstraction. Коли фасад у мережі поставити нікуди, абстракцію вводять усередині коду, перемикають реалізацію за нею й видаляють стару.

Як перевірити в продакшені: готово до вимкнення

  • За логами проксі на старі маршрути немає запитів довше за повний бізнес-цикл, включно з місячними й річними звітами.
  • Старих версій товстого клієнта немає в телеметрії.
  • Дані перенесено й звірено, старі записувачі вимкнені.
  • Прапорці прибрано, старий код і маршрути видалено.

Підсумок: міграція — керована послідовність оборотних кроків з одним власником даних і заздалегідь заданими критеріями вимкнення.

Приклад
Yarp.ReverseProxy
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddHttpForwarder();
builder.Services.AddScoped<IOrderReport, NewOrderReport>();
var app = builder.Build();
// Перенесена функція: її обслуговує новий код на ASP.NET Core
app.MapGet("/reports/orders", (IOrderReport report) => report.Build());
// Усе, що ще не перенесено, іде в старий застосунок на ASP.NET
app.MapForwarder("/{**catch-all}", "https://legacy.internal")
.WithOrder(int.MaxValue) // найнижчий пріоритет: спершу свої маршрути
.ShortCircuit(); // пересланий запит не йде через решту конвеєра
app.Run();
public interface IOrderReport { string Build(); }
public class NewOrderReport : IOrderReport
{
public string Build() => "Orders report from ASP.NET Core";
}

Перенесення наступної функції — новий ендпоінт в ASP.NET Core: проксі сам перестане надсилати цей маршрут у старий застосунок.