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

Багато статей, відео з конференцій, книжки (той самий Р. Мартін з його "Чистою архітектурою") всі в один голос нас переконують, що архітектура проекту повинна бути "чистою".

І в розумної людини (ми ж усі вважаємо себе розумними) постає купа питань. Яку архітектуру вважати "чистою", в чому полягає ця "чистота", який ступінь чистоти є припустимим і для чого це все потрібно?

Для прикладу розглянемо статтю "Чиста Архітектура (Clean Architecture) в .NET та Azure: від теорії до практики"

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

Про переваги нам говорити не цікаво, про це написано тони матеріалів. Поговоримо про те, з чим ми стикнемось на практиці, особливо при реалізації великих високонавантажених рішень.

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

Переваги Чистої Архітектури

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

А тепер пройдемось по практичній реалізації, яку надає автор в якості прикладу, і подивимось, що з нею не так.

Тут треба зауважити, що проект автора, звичайно ж, працювати буде. А чи буде він працювати добре на сотнях запитів в секунду? Чи достатньо надійний цей код?


// Проєкт: Core.Domain
// Немає залежностей від зовнішніх технологій
public class OrderId
{
    public Guid Value { get; }
    public OrderId(Guid value) => Value = value;
}
public class Order
{
    private readonly List<OrderLine> _lines = new();
    public OrderId Id { getprivate set; }
    public CustomerId CustomerId { getprivate set; }
    public IReadOnlyCollection<OrderLine> Lines => _lines.AsReadOnly();
    public decimal TotalAmount => _lines.Sum(l => l.TotalPrice);
    public OrderStatus Status { getprivate set; }
    // Приватний конструктор для контролю створення через фабричний метод
    private Order(CustomerId customerId)
    {
        Id = new OrderId(Guid.NewGuid());
        CustomerId = customerId;
        Status = OrderStatus.Pending;
    }
    // Фабричний метод для створення замовлення, що гарантує валідність
    public static Order Create(CustomerId customerId)
    {
        if (customerId == null)
        {
            throw new ArgumentNullException(nameof(customerId), "Customer ID cannot be null.");
        }
        return new Order(customerId);
    }
    
    // Метод, що інкапсулює бізнес-логіку додавання товару
    public void AddLine(ProductId productId, int quantity, decimal price)
    {
        if (Status != OrderStatus.Pending)
        {
            throw new InvalidOperationException("Cannot add items to an order that is not pending.");
        }
        var existingLine = _lines.FirstOrDefault(l => l.ProductId == productId);
        if (existingLine != null)
        {
            existingLine.IncreaseQuantity(quantity);
        }
        else
        {
            _lines.Add(new OrderLine(Id, productId, quantity, price));
        }
    }
    public void Confirm()
    {
        if (Status != OrderStatus.Pending) throw new InvalidOperationException("Order is not pending.");
        Status = OrderStatus.Confirmed;
        // Тут можна було б додати доменну подію OrderConfirmedEvent
    }
}
public class OrderLine 
{
    public ProductId ProductId { getprivate set; }
    public int Quantity { getprivate set; }
    public decimal UnitPrice { getprivate set; }
    public decimal TotalPrice => Quantity * UnitPrice;
    // ... конструктор та методи
}

Перше, що напружує - створення об'єкта

    // Фабричний метод для створення замовлення, що гарантує валідність
    public static Order Create(CustomerId customerId)
    {
        if (customerId == null)
        {
            throw new ArgumentNullException(nameof(customerId), "Customer ID cannot be null.");
        }
        return new Order(customerId);
    }

Що тут не так?

Передаємо параметр CustomerId і це об'єкт класу CustomerId. Дивна назва для класу, але ок, компілятор стерпить. Перевіряється лише наявність самого об'єкту, чи він був не null, тобто існував. Реалізацію цього самого CustomerId в прикладах ми не бачимо. Є реалізація DTO запиту на створення замовлення від покупця.

// DTO для запиту, що приходить ззовні
public class CreateOrderRequest
{
    public Guid CustomerId { getset; }
    public List<OrderItemDto> Items { getset; }
}

Але тут CustomerId зовсім не клас, це нормальний Guid. Чому тоді об'єкт, який заходить в конструктор замовлення теж має назву CustomerId, хоча це зовсім інший тип?
І коли ми створюємо замовлення, хібе не потрібно перевірити, чи існує реальний Customer з таким Id в довіднику бази даних? Жодної перевірки. На рівні створення замовлення начебто все ок. А що буде при збереженні такого замовлення в базі даних? Там на низовому рівні, який чиста архітектура ховає кудись під плінтус? А розвалиться цілісність бази і вилетить DbUpdateException, і чиста архітектура в білих рукавичках змушена буде чистити каналізацію.

Тобто мені достатньо в метод створення замовлення відправити некоректний Guid покупця, і все розвалиться на рівні бази даних. Ви скажете, такого не буде, бо ми точно знаємо Guid покупця? Ну, ви може й знаєте, а от розробники інших компаній, які будуть використовувати ваше API цим перейматись не будуть. Ваше API будуть тестувати на міцність і витривалість так, що не вистачить вашої фантазії. Причому без жодних злих намірів. Маю багатий досвід.

А що треба зробити в цьому випадку? Як запобігти проблемі?

А "чиста архітектура" про це нічого не каже. Я по крайній мірі не знайшов. На якому рівні ми повинні зробити перевірку на існування нашого покупця (тобто Customer)?

Всередині Order.Create? З точки зору здорової логіки Order повинен гарантувати свою логічну цілісність і не створити замовлення для неіснуючого покупця. Це ж ядро бізнес логіки. Але як всередині Order зробити таку перевірку?

Відповідь - ніяк, бо наш Order є абсолютно ізольованим від взаємодії з іншими сутностями. Що йому дали, те він в собі і збереже. Чи можна називати такий клас частиною бізнес-логіки? Ні, не можна. Бо частину бізнес-логіки ми з нього видрали самим жорстоким чином і винесли на рівень CreateOrderCommandHandler.

// Проєкт: Core.Application
// Залежить лише від Core.Domain
// Команда, що містить усі необхідні дані для створення замовлення
public record CreateOrderCommand(Guid CustomerId, List<OrderItemDto> Items) : IRequest<Guid>;
public record OrderItemDto(Guid ProductId, int Quantity);
// Обробник, що використовує доменну модель та репозиторії
public class CreateOrderCommandHandler : IRequestHandler<CreateOrderCommandGuid>
{
    private readonly IOrderRepository _orderRepository;
    private readonly ICustomerRepository _customerRepository;
    private readonly IProductRepository _productRepository;
    public CreateOrderCommandHandler(
        IOrderRepository orderRepository, 
        ICustomerRepository customerRepository, 
        IProductRepository productRepository)
    {
        _orderRepository = orderRepository;
        _customerRepository = customerRepository;
        _productRepository = productRepository;
    }
    public async Task<Guid> Handle(CreateOrderCommand request, CancellationToken cancellationToken)
    {
        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);
        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);
        }
        
        await _orderRepository.AddAsync(order);
        
        return order.Id.Value;
    }
}

Ось цей рядок

var customer = await _customerRepository.GetByIdAsync(customerId);
        if (customer == null)
        {
            throw new ApplicationException("Customer not found.");
        }

робить перевірку на наявність покупця в довіднику. Тобто ми шматок коду, який фактично є частиною сутності Order, винесли в інший клас CreateOrderCommandHandler, і фактично позбавили Order будь якого імунітету.

Мені скажуть, що Order буде створюватись лише всередині CreateOrderCommandHandler. Питання - для чого тоді виносити чистину логіки Order (ту саму перевірку на наявність покупця) в обгортку CreateOrderCommandHandler?

І мені скажуть - ти не розмієш, виокремлення Order в такому вигляді нам потрібно для тестування. А тестування чого саме?

Коли в шостому класі на лабораторці з фізики ви встромляєте термометр в склянку з теплою водою, що ви міряєте? Температуру води? Грамотний викладач обов'язково на це зверне увагу. Насправді ви міряєте температуру термометра.

Відчуваєте? Тестування метода перевіряє не коректність роботи метода, воно перевіряє коректність роботи теста. На тих даних, які ви в цьому тесті вказали.

І часто бачимо тони абсолютно безглуздих тестів, де перевіряється, що 2+3 дійсно дорівнює 5. Знову літакопоклонництво. Ну, я розумію, що так спокійніше. Особливо керівникам. Всі методи покрили тестами? Всі. Ок, можна бути впевненим, в нас код надійний.

А тепер поясніть мені, в якому тесті буде перевірена наявність покупця в базі даних? Тобто треба тестувати CreateOrderCommandHandler. Але він також не має доступу до бази, там замість OrderRepository сидить затичка (mock), який теж втілює IOrderRepository. І що ми тоді тестуємо? Пустишку. Тестування заради тестування.

За моїм (великим, багаторічним, з 1990 року) досвідом, 99 відсотків проблем виникає на рівні колізій доступу до ресурсів. Коли декілька потоків одночасно хочуть отримати один і той самий ресурс і на нього вплинути.

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

Наприклад, перша накладна проводила товари в послідовності A, B і С. А друга накладна в той же час проводила товари С, E і A. І на цих товарах A і С виникає класичний deadlock. Якби перетнулись лише на товарі A, другий потік дочекався проведення першої накладної і завершення транзакції, і провів би свою накладну, і в товарі A послідовно зменшився би запас після проведення двох накладних. Але у випадку колізії по двох товарах, та ще й у зворотній послідовності, коли перша накладна заблокувала товар A, і друга накладна не може зменшити його залишок, і друга накладна вже заблокувала товар C, і перша накладна не може зменшити залишок товару С - що робити SQL серверу? Він якомусь з потоків каже "до побачення, приходь наступного разу", коректне повідомлення звучить так "you are deadlock victim".

Мені цікаво, хтось такі тести пише? Щоб змоделювати таку ситуацію? Це вилазить лише в реальній роботі під високим навантаженням. Досвідчений розробник такі речи передбачає одразу (бо вже обпікався на цьому), а новачок не передбачає. І всі ці функціональні тести мають дуже обмежену цінність.

Чи потрібно відмовлятись від тестування?

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

Другий аргумент - зміна фреймворку. Щоб все пройшло гладко.

Я бачив багато фреймпорків і технологій. Стосовно баз даних - починаючі з Dbase III, Dbase IV, Foxpro, Paradox, MS Access, різні SQL сервери. Microsoft в свій час запропонувала DAO, з якого розвилось ADO, потім ADO .Net з типізованими Dataset? з якого вже виріз Entity Framework, з якого виріс EF Core.

За весь цей час були реалізовані десятки великих проектів, і міняти Framework доступу до даних мені довелось один раз - з Entity Framework на EF Core. І зміни ці були мінімальними. Питання - чи треба гратись з чистою архітектурою, і ускладнювати код вдвічі або втричі? Код який важко читати і розуміти? Бо код пишеться для людини, комп'ютеру все це байдуже, компілятор все це заверне як йому треба через оптимізації та inlining.

А тепер поговоримо про ще одну цікаву річ - про швидкодію.

Коли ви реалізуєте бізнес-логіку, вам потрібні дані, які ви обробляєте. Дані ці ви берете з бази даних. А потім, після обробки ще й зберігаєте оброблені дані в базі.

Це дуже дорогі операції. Всі ці обгортки в чистій архітектурі за рахунок інлайнінгу досить добре оптимізуються. А от запити до бази даних (мають термін "Roundtrip") - це дуже дорогі операції. Самий простий запит до бази даних SQL-сервера - це мінімум 15 мілісекунд. Такі звернення до бази даних (не важливо, чи це файлова Sqlite чи це серверна MS SQL) треба мінімізувати. Вигідно одним запитом забрати якмога більше даних (в розумних межах). Наприклад, якщо ми забираємо з бази даних замовлення покупця, вигідно одразу забрати навігаційні властивості торгової точки і покупця, весь перелік товарів. Але бізнес-сутності в чистій архітектурі відокремлені від EF Core і не можуть мати навігаційні властивості. Завантаження таких даних робиться через репозиторій. І це все значно ускладнює логіку і просто провалює швидкодію. Зверніть увагу на цей асинхронний виклик покупця.

var customer = await _customerRepository.GetByIdAsync(customerId);

А ще цікавіше цей фрагмент

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);
        }

Цей фрагмент не стільки цікавий, скільки страшний.

var product = await _productRepository.GetByIdAsync(productId);

Ми в циклі викликаємо товари з бази даних. Кожен товар окремим запитом. Наприклад, вам потрібно завантажити акт переобліку в магазині. Там 20 тисяч позицій. Тобто це 20 тисяч запитів до SQL сервера, 20 тисяч раундтріпів. 15 мілісекунд помножимо на 20 тисяч. 300 секунд. Ваш акт переобліку буде відкриватись 5 хвилин. Не кажучі вже про навантаження на SQL сервер. Це точно "чиста" архітектура?

Andriy Kravchenko

Andriy Kravchenko

Admin, Writer, File Uploader

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

5/2/2026 4:51:29 PM

1