Деякі міркування про чисту архітектуру, і чи варто слідувати догмам

Вступ: про що ця стаття

Примітка автора. Цей текст не є претензією на остаточну істину. Це радше практичні міркування розробника, який багато років працює з бізнес-системами, базами даних, високим навантаженням і реальними виробничими помилками.

Про Clean Architecture написано багато статей, книжок і доповідей. Часто її подають як майже універсальний підхід до побудови програмних систем: бізнес-логіка не залежить від фреймворків, код легше тестувати, систему простіше розвивати й масштабувати.

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

Для прикладу розглянемо статтю «Чиста Архітектура (Clean Architecture) в .NET та Azure: від теорії до практики». Її цінність у тому, що вона коротко демонструє типову структуру Clean Architecture на прикладі .NET: Domain, Application, Infrastructure та Presentation. Саме на таких компактних прикладах добре видно як переваги підходу, так і ризики його практичного застосування.

Що зазвичай називають перевагами Clean Architecture

Автор згаданої статті перелічує кілька типових переваг чистої архітектури:

  1. Незалежність від фреймворку. Бізнес-логіка не прив’язана безпосередньо до ASP.NET, EF Core або іншої зовнішньої технології.
  2. Тестованість. Ядро системи можна тестувати ізольовано, без запуску вебсервера або реальної бази даних.
  3. Масштабованість. Система розділена на шари, а ядро можна використовувати через різні зовнішні механізми: API-контролер, обробник повідомлень, Azure Function тощо.
  4. Легше додавання функцій. Нові сценарії використання додаються в Application Layer, а інтеграції з зовнішніми системами — через адаптери в Infrastructure.

Усе це справді може бути корисним. Але в реальному проєкті важливо не лише розділити код на шари. Потрібно також зберегти цілісність бізнес-логіки, прозорість коду, контроль над транзакціями, ефективність запитів до бази даних і зрозумілу відповідальність кожного класу.

Практичне питання №1: де живе бізнес-правило

У прикладі з Domain Layer сутність Order створюється через фабричний метод:

public static Order Create(CustomerId customerId)
{
    if (customerId == null)
    {
        throw new ArgumentNullException(nameof(customerId), "Customer ID cannot be null.");
    }

    return new Order(customerId);
}

На перший погляд усе коректно: об’єкт Order не можна створити без CustomerId. Але виникає практичне питання: чи достатньо цього для реальної бізнес-системи?

Наявність об’єкта CustomerId ще не означає, що в базі даних справді існує покупець із таким ідентифікатором. Особливо якщо запит приходить через зовнішнє API, де клієнт може передати будь-який Guid.

У самій сутності Order цю перевірку виконати складно, і в межах Clean Architecture це очікувано: доменна сутність не повинна напряму звертатися до бази даних або репозиторію. Тому в прикладі перевірка існування покупця переноситься в CreateOrderCommandHandler:

var customerId = new CustomerId(request.CustomerId);
var customer = await _customerRepository.GetByIdAsync(customerId);

if (customer == null)
{
    throw new ApplicationException("Customer not found.");
}

var order = Order.Create(customerId);

Це помилка чи нормальний розподіл відповідальності?

Тут важливо розділити два типи правил.

Локальні інваріанти сутності мають залишатися всередині Order. Наприклад: не можна додавати рядки до підтвердженого замовлення, кількість товару має бути додатною, ціна не повинна бути від’ємною, повторний товар у замовленні потрібно або об’єднувати, або явно забороняти.

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

Тому сама по собі перевірка покупця вCreateOrderCommandHandler не є помилкою. Але є ризик: якщо такі перевірки розкидані по різних обробниках, а сама доменна модель залишається надто «тонкою», то бізнес-логіка поступово розмивається між Application Layer, репозиторіями, сервісами й контролерами. У результаті система формально має чисту архітектуру, але її правила стає важче бачити й контролювати.

Окремо про назви типів

НазваCustomerId для value object є цілком нормальною практикою. Але варто чітко розрізняти Guid CustomerId у DTO запиту та доменний тип CustomerId. DTO описує зовнішній контракт API, а доменний тип має відображати внутрішню модель системи й може містити додаткову валідацію, наприклад заборону Guid.Empty.

Практичне питання №2: тестування не повинно підміняти реальну надійність

Один із головних аргументів на користь Clean Architecture — простота unit-тестування. Це справді перевага: локальні бізнес-правила сутностіOrder можна перевірити швидко й ізольовано.

Наприклад, unit-тест добре перевіряє, що не можна додати товар до замовлення, яке вже підтверджене. Або що повторний товар у замовленні збільшує кількість, якщо саме така поведінка передбачена бізнес-логікою.

Але unit-тести не замінюють інтеграційні тести. Значна частина складних помилок у бізнес-системах виникає не в окремому методі, а на стику кількох компонентів: база даних, транзакції, блокування, паралельний доступ, конкурентне оновлення залишків, помилки збереження, реальні обмеження SQL-сервера.

Наприклад, дві видаткові накладні можуть одночасно списувати одні й ті самі товарні залишки. Якщо вони блокують товари в різному порядку, може виникнути deadlock. Такий сценарій майже неможливо адекватно перевірити простим unit-тестом із mock-репозиторієм. Для цього потрібні інтеграційні, навантажувальні або спеціально змодельовані конкурентні тести.

Отже, ізольовані тести корисні, але вони перевіряють лише частину системи. Якщо найризикованіші ділянки пов’язані з базою даних і транзакціями, то саме ці ділянки мають бути покриті тестами відповідного рівня.

Практичне питання №3: незалежність від фреймворку має свою ціну

Clean Architecture прагне відділити бізнес-логіку від конкретних фреймворків. Це логічна мета, особливо для довготривалих проєктів. Проте повна ізоляція від інструментів доступу до даних не завжди безкоштовна.

У реальних бізнес-системах база даних — це не другорядна деталь. Вона зберігає стан системи, забезпечує цілісність, виконує складні запити, контролює транзакції й обмеження. EF Core, якщо ним користуватися обережно, дає багато можливостей для оптимізації: проєкції,Include, фільтрацію на сервері, batch-запити, транзакції, контроль tracking/no-tracking, перевірку SQL через ToQueryString().

Якщо заради «чистоти» повністю сховати EF Core за надто загальними репозиторіями, можна втратити частину цих можливостей. У такому випадку код може стати формально правильним з погляду шарів, але менш зрозумілим і менш ефективним на практиці.

Практичне питання №4: продуктивність і проблема N+1 запитів

Найбільш показовий фрагмент у прикладі — обробка товарів у циклі:

foreach (var item in request.Items)
{
    var productId = new ProductId(item.ProductId);
    var product = await _productRepository.GetByIdAsync(productId);

    if (product == null)
    {
        throw new ApplicationException($"Product with ID {item.ProductId} not found.");
    }

    order.AddLine(productId, item.Quantity, product.Price);
}

Тут кожен товар завантажується окремим запитом. Для невеликого навчального прикладу це прийнятно. Але в реальній системі такий підхід швидко створює проблему N+1 запитів.

Якщо в документі 10 позицій, різниця може бути непомітною. Якщо в документі тисячі або десятки тисяч позицій, окремий запит на кожен товар перетворюється на серйозне навантаження на SQL-сервер і суттєво збільшує час виконання операції.

Важливо уточнити: це не обов’язкова проблема Clean Architecture як концепції. Це проблема конкретної реалізації сценарію використання. Clean Architecture не забороняє завантажити всі потрібні товари одним запитом. Але якщо архітектурні обмеження або надто загальні репозиторії підштовхують розробника до поштучного завантаження, це вже практичний сигнал, що абстракції почали заважати.

Більш практичний варіант виглядав би приблизно так:

var productIds = request.Items
    .Select(item => new ProductId(item.ProductId))
    .Distinct()
    .ToList();

var products = await _productRepository.GetByIdsAsync(productIds, cancellationToken);

Після цього можна перевірити, чи всі товари знайдені, побудувати словник за ідентифікатором і вже тоді формувати замовлення. Це дозволяє зменшити кількість звернень до бази даних і краще контролювати продуктивність.

Що варто покращити в такому прикладі

Щоб приклад був ближчим до реальної практики, я б звернув увагу на такі моменти:

1. Додати явну валідацію value object

CustomerId,ProductId іOrderId можуть перевіряти, що значення не дорівнюєGuid.Empty. Це проста, але корисна валідація на рівні доменної моделі.

2. Не змішувати локальні інваріанти й зовнішні перевірки

Order має відповідати за власну внутрішню коректність. А перевірки, які потребують бази даних або інших агрегатів, мають бути явно оформлені на рівні application service, command handler або domain service.

3. Уникати поштучного завантаження даних

Якщо сценарій працює з колекцією товарів, репозиторій або query service має підтримувати batch-завантаження. Інакше архітектура буде виглядати правильно лише на маленьких прикладах.

4. Не ховати складні запити за надто загальними інтерфейсами

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

5. Тестувати не лише класи, а й ризикові сценарії

Unit-тести корисні для локальних правил. Але для систем із базою даних потрібно також тестувати транзакції, конкурентний доступ, інтеграцію з реальною СУБД і поведінку під навантаженням.

Практики автора: приклад архітектури робочої бізнес-системи

Щоб не залишатися лише в площині критики навчального прикладу, варто коротко описати інший підхід — не як універсальний рецепт, а як приклад практичної реалізації довгоживучого високонавантаженого проєкту.

Йдеться про Trade Control Utility — систему обліку товарів, документів, залишків, закупівельних партій, переміщень, продажів і взаємодії з іншими сервісами. Це не лабораторний приклад і не демонстраційний проєкт. Система багато років працює у виробничому середовищі, обробляє за добу сотні тисяч документів і має справу з великими базами даних, реальними користувачами, зовнішніми інтеграціями, помилками обладнання, паралельними операціями та постійною потребою зберігати логічну цілісність даних.

Шари системи

Архітектура такого проєкту також розділена на шари, але ці шари мають не догматичну, а практичну природу.

  • Клієнтський UI-рівень відповідає за взаємодію з користувачем: форми, редактори, списки, відображення прогресу операцій, реакцію на події.
  • DataSources-рівень на стороні клієнта інкапсулює сценарії завантаження, збереження, оновлення й додаткового завантаження даних. Він працює з каналами зв’язку, викликає API і перетворює серверну поведінку на зручну модель для UI.
  • Proxy-рівень описує зовнішні контракти між клієнтом і сервером, але не зводиться лише до пасивних транспортних DTO. Класи мають суфікс  Proxy, бо вони одночасно виконують роль контрактів обміну й робочих моделей редактора на клієнті. Наприклад, вони можуть реалізовувати  INotifyPropertyChanged, перераховувати суму накладної, оновлювати залежні поля, підтримувати стан рядків, помилки введення та іншу поведінку, потрібну UI.
  • Серверний API-рівень приймає запити, створює користувацький контекст бази, відкриває транзакції, викликає доменні операції, публікує події та повертає результат клієнту.
  • Доменний/Data-рівень містить основні сутності системи: документи, товарні рядки, складські записи, журнали руху товарів, довідники, правила проведення й відкату документів.
  • Оптимізовані транспортні DTO використовуються там, де звичайний JSON або надто глибока об’єктна модель створюють зайве навантаження. Для великих обсягів даних можуть застосовуватися спеціальні плоскі DTO та MessagePack.

Такий підхід дозволяє скоротити кількість проміжних класів, які представляють одну й ту саму сутність на різних рівнях системи. Замість ланцюжка на кшталт DocumentProxy → DocumentDto → Document використовується простіша й пряміша схема DocumentProxy → Document.DocumentProxy залишається зовнішньою моделлю для клієнта та API, а Document — внутрішньою доменною сутністю з повною бізнес-поведінкою. Це компроміс між чистотою розділення й практичною простотою супроводу.

Тобто система не є безшаровою. Але головна мета шарів — не відповідати певній архітектурній схемі з книжки, а розділити відповідальність там, де це реально допомагає підтримці, розвитку, продуктивності та контролю помилок.

Свідоме використання EF Core всередині доменної моделі

У цьому підході автор не претендує на повну ізоляцію доменних сутностей від фреймворку доступу до даних. Навпаки, доменні сутності щільно інтегровані з EF Core і максимально використовують його можливості.

Наприклад, сутності отримують доступ до TcuContext, можуть завантажувати потрібні навігаційні властивості, будувати спеціалізовані IQueryable-запити, використовувати IncludeThenIncludeEF.Functions.Like, індексні підказки, транзакції, RowVersion,ToQueryString() для аналізу повільних запитів і точкове завантаження пов’язаних даних.

З погляду «ідеальної» Clean Architecture це може виглядати як залежність домену від інфраструктури. Але в реальній обліковій системі база даних — це не зовнішня випадкова деталь. Це середовище існування сутностей. Саме там зберігаються залишки, закупівельні партії, документи, зв’язки між документами, історія руху товарів, транзакційність і обмеження цілісності.

Тому тут обрано інший компроміс: не ховати EF Core за універсальним репозиторієм, який збіднює модель, а дати сутностям достатньо інструментів, щоб вони могли захищати власну цілісність і виконувати складні бізнес-операції ефективно.

Як виглядає обробка документа

Типовий сценарій роботи з документом проходить кілька рівнів.

Клієнт працює з DocumentProxy або його нащадками. Це не лише контейнер для передавання даних на сервер, а й модель, з якою працює редактор документа: вона може реагувати на зміну властивостей, перераховувати підсумки, підтримувати стан рядків і давати користувачу швидкий зворотний зв’язок ще до збереження. Під час збереження proxy-об’єкт передається на сервер. Контролер створює UserTcuContext, відкриває транзакцію й передає роботу доменній моделі. Далі Document.Update знаходить або створює потрібний документ, викликає FromProxy, а для товарних документів оновлює рядки через внутрішню логіку GoodsDocument та DocumentDetail.

Проведення документа — ще показовіший приклад. Контролер не проводить документ самостійно. Він лише створює контекст, відкриває транзакцію, завантажує документ і викликає document.Approve(). Далі сама сутність документа виконує свою життєву операцію: завантажує потрібні навігаційні властивості, перевіряє права користувача, перевіряє послідовність документів, закриті періоди, статус, валюту, підрозділ, тип операції, після чого передає проведення спеціалізованій реалізації.

Для товарного документа це означає завантаження товарних рядків, завантаження складських записів, створення пов’язаних переоцінок, проходження по рядках документа, перевірку товару, кількості, одиниці виміру, доступності продажу або приходу, підбір закупівельних партій, створення записів журналу руху товару, зміну залишків у InventoryRecord і перерахунок підсумків.

Це не набір розрізнених handler-ів, які випадково змінюють стан різних таблиць. Це послідовність взаємодії сутностей, де DocumentGoodsDocumentDocumentDetailInventoryRecord і InventoryJournalRecord разом утворюють робочу доменну систему.

Сутність не ізольована, але вона захищає себе

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

Наприклад, товарний рядок документа під час проведення не просто зменшує число в полі Quantity. Він знаходить або створює потрібний складський запис, перевіряє товар, кількість, одиницю виміру, статус продажу або приходу, підбирає закупівельні партії, створює записи руху товару й передає зміну залишку об’єкту InventoryRecord. А InventoryRecord, у свою чергу, не є пасивним рядком таблиці: він змінює кількість, середньозважену закупівельну ціну, контролює від’ємні залишки, оновлює службові показники й бере участь у відкаті операцій.

Тобто сутності тут не «мертві». Вони мають поведінку, пам’ятають свій стан, знають свій контекст і мають механізми самозахисту. При цьому вони не є повністю ізольованими: як і живі організми, вони існують у середовищі та взаємодіють з іншими сутностями.

Чому не варто ізолюватися від EF Core за будь-яку ціну

Один із типових аргументів Clean Architecture — можливість замінити фреймворк доступу до даних. Теоретично це звучить привабливо. Практично ж для великої облікової системи, яка багато років розвивається на EF Core, імовірність повної заміни цього фреймворку майже нульова.

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

Тому в такому проєкті надмірна підготовка до гіпотетичної заміни EF Core може стати не перевагою, а джерелом зайвої складності. Це близько до відомої інженерної думки про шкоду передчасної оптимізації. У цьому випадку йдеться навіть не стільки про оптимізацію швидкодії, скільки про передчасну універсалізацію архітектури: ми ускладнюємо сьогоднішній код заради майбутнього сценарію, який майже напевно ніколи не настане.

Якщо система багато років працює на конкретному технологічному стеку, а основні ризики пов’язані не із заміною ORM, а з продуктивністю, транзакціями, цілісністю залишків, конкурентним доступом і зрозумілістю бізнес-правил, то саме ці ризики й мають бути в центрі архітектурного рішення.

Що дає такий підхід

Перевага такого підходу не в «чистоті» залежностей, а в практичній цілісності системи.

  • Бізнес-операція виконується там, де є достатньо контексту для її коректного виконання.
  • Сутності можуть захищати власний стан, а не лише приймати вже підготовлені значення ззовні.
  • Складні запити можна оптимізувати засобами EF Core та SQL Server, не пробиваючись через надто загальні інтерфейси.
  • Транзакційні сценарії залишаються явними: контролер відкриває транзакцію, але доменна модель виконує змістовну роботу.
  • Код ближчий до реального бізнес-процесу: документ проводиться, рядок списує товар, складський запис змінює залишок, журнал фіксує рух.

Звичайно, такий підхід має свою ціну. Доменна модель стає залежною від EF Core, а отже менш придатною для повної заміни persistence-механізму. Але для конкретного класу систем це може бути прийнятним і навіть бажаним компромісом. Якщо головна цінність — надійна робота облікової системи під реальним навантаженням, то архітектура має служити цій меті, а не абстрактній можливості переписати persistence-рівень у майбутньому.

Тут важливо не протиставляти цей підхід Clean Architecture як «правильний» і «неправильний». Правильніше говорити про різні компроміси. Clean Architecture прагне захистити домен від зовнішніх деталей. Практика автора в цьому прикладі прагне захистити життєздатність самої бізнес-сутності, навіть якщо для цього сутність має тісніше взаємодіяти з EF Core і базою даних.

Висновки

Clean Architecture не є поганою архітектурою. Навпаки, вона формулює важливі ідеї: контроль залежностей, розділення відповідальності, незалежність ядра від зовнішніх деталей, тестованість бізнес-правил.

Але жодна архітектура не повинна застосовуватися механічно. У високонавантажених бізнес-системах важливі не тільки шари й абстракції, а й ефективність роботи з даними, цілісність транзакцій, зрозумілість коду, контроль конкурентного доступу та реальна поведінка системи у виробничих умовах.

Доменна сутність як життєздатна система

Окремо варто підкреслити ще одну важливу думку. Доменна сутність не повинна бути просто контейнером властивостей або набором алгоритмів із приватними сетерами. У реальній бізнес-системі вона більше схожа на живу одиницю: має власні межі, внутрішній стан, правила самозахисту й водночас взаємодіє з іншими сутностями.

Така сутність не обов’язково має напряму звертатися до бази даних або знати про EF Core, SQL Server чи конкретний репозиторій. Але вона не повинна створюватися в завідомо нежиттєздатному стані. Якщо Order може бути створений для неіснуючого клієнта, то проблема не лише в технічній валідації. Проблема в тому, що базова умова існування замовлення не була гарантована на момент його народження.

У цьому сенсі доменна модель має бути не анемічною, а стійкою. Вона повинна отримувати достатньо перевіреного контексту, щоб зберігати власну цілісність. Інакше ми отримуємо не повноцінну бізнес-сутність, а лише фрагмент бізнес-логіки, який сподівається, що всі критичні умови вже перевірені десь зовні.

Це можна порівняти з виходом у море на яхті. Формально на борту можуть бути коробки з написом «продукти», але перед подорожжю важливо переконатися, що всередині справді продукти, а не цегла. Так само й замовлення: воно має бути забезпечене всім необхідним для коректного існування ще до того, як система почне працювати з ним як із валідною доменною сутністю.

Інженерне рішення майже завжди є компромісом. Якщо ми зменшуємо залежність від фреймворку, можемо заплатити складністю. Якщо максимально ізолюємо код для unit-тестів, можемо винести за межі тестування найважливіші інтеграційні ризики. Якщо створюємо абстракції над EF Core, можемо втратити частину його оптимізаційних можливостей.

Тому варто вивчати Clean Architecture, Onion Architecture, DDD, CQRS, мікросервісну архітектуру, gRPC, шини подій, різні типи тестування й сучасні AI-інструменти. Але застосовувати їх потрібно не заради самої назви, а для конкретної задачі, конкретної команди, конкретного навантаження й конкретних обмежень проєкту.

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

Andriy Kravchenko

Andriy Kravchenko

Admin, Writer, File Uploader

Останнє оновлення:

5/2/2026 8:34:35 PM

18