DDD

Глава 10. Проектні Евристики

"It depends" (Це залежить) — найпопулярніша відповідь будь-якого консультанта чи архітектора. Але від чого саме це залежить?

Глава 10. Проектні Евристики

"It depends" (Це залежить) — найпопулярніша відповідь будь-якого консультанта чи архітектора. Але від чого саме це залежить?

Коли використовувати CQRS, а коли вистачить звичайного CRUD? Коли будувати складну Гексагональну Архітектуру, а коли просто писати код в контролерах? Коли мікросервіси — це благо, а коли — смерть проекту?

Ця глава — це набір Евристик (емпіричних правил), які допоможуть вам приймати зважені рішення. Ми перетворимо абстрактне "It depends" на конкретні алгоритми прийняття рішень.

Межі Контекстів

Як правильно нарізати систему на модулі та сервіси. Великі чи малі?

Бізнес-Логіка

Transaction Script vs Domain Model. Вибір складності реалізації.

Архітектура

Layered vs Hexagonal vs Clean. Організація коду.

1. Евристики Обмежених Контекстів

Одна з найчастіших помилок в DDD — нарізання системи на надто дрібні шматки (Entity Services) або створення одного гігантського моноліту.

Функція vs Дані

Не діліть контексти по сутностях (CustomerService, OrderService, ProductService). Діліть по Бізнес-Можливостях (Business Capabilities) або Поведінці.

  • ProductService: зберігає id, name, price, stock.
  • CatalogContext: пошук, категорії, SEO-атрибути.
  • InventoryContext: залишки на складах, резервування, інвентаризація.
  • PricingContext: знижки, промо-акції, динамічне ціноутворення.

Розмір Команди (Число Данбара)

Контекст повинен бути таким, щоб його могла підтримувати одна команда (Two-Pizza Team, 5-9 людей). Якщо над контекстом працює 20 людей — це забагато когнітивного навантаження. Розбивайте його. Якщо над 5-ма контекстами працює одна людина — це оверхед на перемикання контексту та інфраструктуру. Об'єднуйте їх.

Життєвий Цикл (Rate of Change)

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

Когнітивне Навантаження (Team Topologies)

Згідно з книгою Team Topologies, межі сервісів повинні збігатися з межами когнітивного навантаження команди. Якщо команда "тоне" в підтримці, постійно перемикається між контекстами і боїться чіпати старий код — це сигнал, що межі контекстів прокладені неправильно.

Евристика Розміру

Контекст не є "завеликим", якщо:

  1. Команда розуміє його цілком.
  2. Новий розробник може розібратися в ньому за 1-2 тижні.
  3. Зміни в одній частині контексту не ламають іншу (High Cohesion).
Loading diagram...

graph TD Start{Чи варто розбивати Контекст?} Start --> Q1{Різна мова (Ubiquitous Language)?} Q1 -- Tak --> SplitРозбивайте! Q1 -- Ni --> Q2{Різний життєвий цикл?} Q2 -- Tak --> Split Q2 -- Ni --> Q3{Різні нефункціональні вимоги (Scale/Security)?} Q3 -- Tak --> Split Q3 -- Ni --> KeepЗалиште разом (Modulith)

style Split fill:#ffccbc
style Keep fill:#c8e6c9

2. Евристики Бізнес-Логіки

Не вся логіка однакова. Для "Core Domain" ми хочемо максимум гнучкості, для "Generic Subdomain" — купити готове або зробити швидко.

Матриця Cкладності

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

ПатернСкладність ДоменуСкладність Структур ДанихПриклад
Transaction ScriptНизькаНизькаАдмінка, прості звіти, ETL джоби
Active RecordНизька/СередняСередняПрості CRUD додатки, блоги
Domain ModelВисокаВисокаCore Domain, складна бізнес-логіка, інваріанти
Event SourcedДуже ВисокаЧасова (Temporal)Бухгалтерія, Медичні картки, Git-подібні системи

Transaction Script vs Domain Model

Давайте порівняємо код.

Transaction Script (Процедурний стиль)

Логіка розмазана по сервісу. Сутності тупі (DTO).

public class OrderService
{
    public void SubmitOrder(int orderId)
    {
        var order = _db.Orders.Find(orderId);
        var customer = _db.Customers.Find(order.CustomerId);

        // Validation Logic
        if (order.Total > customer.CreditLimit)
            throw new Exception("Limit exceeded");

        if (order.Items.Count == 0)
             throw new Exception("Empty order");

        // Logic
        order.Status = "Submitted";
        order.SubmittedDate = DateTime.Now;

        _db.SaveChanges();
    }
}

Проблема: Цей код неможливо перевикористати. Якщо інший сервіс захоче перевірити ліміт, він продублює if (order.Total > customer.CreditLimit).

Domain Model (ООП стиль)

Логіка інкапсульована в Агрегаті. Сервіс тільки координує.

public class Order : AggregateRoot
{
    public void Submit(Customer customer)
    {
        // Invariant Guard
        if (this.Items.IsEmpty)
            throw new DomainException("Order cannot be empty");

        // Rule checks utilizing other aggregates info
        if (this.CalculateTotal() > customer.GetAvailableCredit())
             throw new DomainException("Credit limit exceeded");

        this.Status = OrderStatus.Submitted;
        this.SubmittedDate = DateTime.UtcNow;

        AddDomainEvent(new OrderSubmitted(this.Id));
    }
}

// Application Service
public class SubmitOrderHandler
{
    public void Handle(SubmitOrder cmd)
    {
        var order = _repo.Get(cmd.Id);
        var customer = _customerRepo.Get(order.CustomerId);

        order.Submit(customer); // All logic is here

        _repo.Save(order);
    }
}

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

Анемічна Модель (Anemic Model) — Анти-патерн?

Мартін Фаулер називає Анемічну Модель (сутності тільки з геттерами/сеттерами) анти-патерном. Але в DDD це залежить від контексту.

  • Для Generic Subdomain (адмінка) — це нормально.
  • Для Core Domain — це зло, бо вся логіка перетікає в сервіси ("Transaction Scripts on steroids").
YAGNIПочинайте з простого. Не робіть Domain Model для довідника "Країни/Міста". Active Record (або просто анемічна модель) там працюватиме ідеально.

3. Евристики Архітектури

Архітектура — це про структурування коду.

Layered Architecture (Шарувата)

Підходить для більшості проектів на старті.

  • Плюси: Зрозуміла всім, проста структура папок.
  • Мінуси: Схильна до перетворення на "Database Driven Design" (все залежить від БД).

Hexagonal (Ports & Adapters)

Для систем, які живуть довго і мають складну бізнес-логіку.

  • Плюси: Повна ізоляція домену. Легко тестувати. Легко замінити БД або API.
  • Мінуси: Багато бойлерплейту (DTO, Mappers, Interfaces). Вищий поріг входу.

CQRS (Command Query Responsibility Segregation)

Використовувати тільки точково!

  • Коли ТАК:
    • Навантаження на читання в 100 разів вище за запис.
    • Потрібні складні Materialized Views (звіти, екран "Dashboard").
    • Команди і Запити мають кардинально різні вимоги до масштабування.
  • Коли НІ:
    • Простий CRUD.
    • "Бо це модно".
Loading diagram...

graph TD Start{Вибір Архітектури} Start --> Q1{Це Core Subdomain?} Q1 -- Ni (Generic/Supporting) --> LayeredTraditional Layered / CRUD Q1 -- Tak --> Q2{Чи важливе тестування бізнес-правил без БД?} Q2 -- Tak --> HexHexagonal / Clean Arch Q2 -- Ni --> Layered

Hex --> Q3{Чи є асиметрія Read/Write?}
Q3 -- Tak (High Load Reads) --> CQRS[CQRS (Optional)]
Q3 -- Ni --> Standard[Standard Hexagonal]

Структура Проекту

Layered Architecture

src/
├── Controllers/       # Presentation
├── Services/          # Application Logic (Mix of Domain & App)
├── Entities/          # Data Models (Anemic)
├── Data/              # DbContext, Repositories
└── DTOs/              # Data Transfer Objects

Hexagonal Architecture (Approximation)

src/
├── Domain/            # Core Logic (No dependencies!)
│   ├── Model/         # Aggregates, Value Objects
│   └── Ports/         # Interfaces (IRepository, INotificationService)
├── Application/       # Use Cases (Orchestration)
│   ├── Commands/      # Handlers
│   └── Queries/
├── Infrastructure/    # Adapters
│   ├── Persistence/   # EF Core impl of Ports
│   └── Messaging/     # RabbitMQ adapters
└── WebApi/            # Entry Point

4. Стратегії Тестування

Як тестувати DDD-системи? Забудьте про "Test Pyramid" у її класичному розумінні (багато юнітів, мало інтеграційних), якщо ви робите мікросервіси.

The Testing Diamond (Ромб Тестування)

У мікросервісах одиницею ізоляції є Сервіс, а не клас. Тому Integration Tests стають найважливішими.

  1. Unit Tests: Тестуйте лише Domain Model (Агрегати, Value Objects) і складні алгоритми. Не тестуйте геттери/сеттери та прості мапери.
  2. Integration Tests (Component Tests): Підніміть контекст з реальною БД (TestContainers) і перевірте сценарії через публічний API (або Application Layer). Це дає найбільше впевненості.
  3. E2E Tests: Мінімум. Тільки критичні шляхи (Happy Path), які проходять через всю систему. Вони повільні і крихкі.
Loading diagram...

block-beta columns 1 E2E"End-to-End (Few)" Int"Integration / Component (Many)" Unit"Unit (Domain Core Only)"

style E2E fill:#ef9a9a style Int fill:#a5d6a7 style Unit fill:#90caf9

Component Tests (In-Process Integration)

Найкращий ROI (Return on Investment) дають тести, які піднімають весь Application Context в пам'яті, підміняють тільки зовнішні сервіси (Payment Gateway), але використовують реальну БД.

public class OrderComponentTests : IClassFixture<WebApplicationFactory<Program>>
{
    private readonly HttpClient _client;

    // TestContainers for Postgres
    // ... setup code ...

    [Fact]
    public async Task Should_create_order_when_valid_input()
    {
        // Arrange
        var command = new CreateOrderCommand(...);

        // Act
        var response = await _client.PostAsJsonAsync("/orders", command);

        // Assert
        response.EnsureSuccessStatusCode();
        var order = await _db.Orders.FirstAsync();
        order.Status.Should().Be("Created");
    }
}

Testing Anti-Patterns

  1. Mocking Hell: Коли ви мокаєте репозиторій, щоб протестувати сервіс, який просто викликає репозиторій. Ви тестуєте мок, а не код.
  2. Testing Implementation Details: Тести падають при кожному рефакторингу приватних методів. Тестуйте поведінку (Public API), а не реалізацію.

5. Decision Tree: Вибір Сховища Даних

Ще одне часте питання: SQL чи NoSQL?

  • Relational (Postgres, MySQL): Дефолтний вибір. ACID, стабільність, зв'язки. Підходить для 90% задач.
  • Document (Mongo, DynamoDB):
    • Агрегати добре лягають на документи (один JSON).
    • Гнучка схема (Generic Subdomain, CMS).
    • Ризик: Втрата цілісності даних при слабких транзакціях (хоча сучасні NoSQL вже мають ACID).
  • Graph (Neo4j): Соціальні мережі, рекомендаційні системи, Fraud Detection (пошук зв'язків).
  • Time-Series (TimescaleDB, Influx): IoT, метрики, фінансові тікери.

Евристика Поліглота

Не тягніть зоопарк технологій без потреби. PostgreSQL з JSONB може замінити Mongo і Redis у 80% випадків на старті проекту. Operability (зручність експлуатації) важливіше за теоретичну продуктивність.

6. Евристики Рефакторингу

Коли і як змінювати дизайн?

Strangler Fig (Фіга-Душитель)

Якщо у вас є Моноліт, не переписуйте його "з нуля" (Big Bang Rewrite). Це майже завжди закінчується провалом. Використовуйте патерн Strangler Fig:

  1. Поставте проксі (nginx/YARP) перед монолітом.
  2. Напишіть нову функціональність у новому мікросервісі.
  3. Перенаправте трафік (/api/v2/orders) на новий сервіс.
  4. Поступово переносьте стару функціональність ("душіть" моноліт).

Rule of Three (Правило Трьох)

Не виділяйте спільний код (Shared Kernel/Library), поки він не повторився у трьох різних місцях. Виділення абстракцій надто рано (Premature Abstraction) призводить до створенная універсальних монстрів, які важко підтримувати. Краще мати трохи дублювання (Duplication is cheaper than wrong abstraction).


6. Висновки

Евристики — це не догми. Це стартові точки для вашого мислення.

  1. Always start simple. Почніть з Модульного Моноліту, Шаруватої Архітектури і Postgres.
  2. Refactor slightly later. Вводьте DDD, CQRS, Hexagonal тільки тоді, коли біль від поточної архітектури стає нестерпним.
  3. Align with Business. Технічні рішення повинні відображати бізнес-цілі (Subdomains).

У наступній, фінальній главі, ми розглянемо, як ці рішення еволюціонують у часі. Що робити, коли Core Domain стає Legacy, і як проводити рефакторинг великих систем.

Copyright © 2026