Увійти Реєстрація
Блог Серії
Кар'єра
Вакансії Компанії
Навчання
Документація Співбесіди Тестування Відео
Екосистема
Пакети Ресурси Проєкти Інструменти Події
Інше
Про нас Реклама

Middle: питання на співбесіді з теми «DDD і доменне моделювання»

Питання з реальних співбесід з відповідями: Laravel і PHP, бази даних, JavaScript і фронтенд, Git, Docker, API, безпека й архітектура. Тими самими темами, що й тести.

5 питань

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

Приклад: замовлення (Order - корінь) з позиціями (OrderLine). Позицію не змінюють напряму - лише через замовлення:

$order->addLine($product, quantity: 2);
$order->changeQuantity($lineId, 5);
$order->removeLine($lineId);

Навіщо - інваріанти. Інваріант - бізнес-правило, яке має виконуватися завжди:

  • сума замовлення дорівнює сумі позицій;
  • у замовленні не більше 50 позицій;
  • оплачене замовлення не можна змінювати;
  • загальна знижка не перевищує 30%.

Якщо позиції можна змінювати напряму (OrderLine::find($id)->update(...)), правила перевіряються в кількох місцях - або не перевіряються. Корінь агрегату - єдине місце, де їх контролюють:

public function addLine(Product $product, int $quantity): void
{
    if ($this->isPaid()) {
        throw new OrderIsLocked($this);
    }
    if ($this->lines->count() >= 50) {
        throw new TooManyLines($this);
    }

    $this->lines->push(new OrderLine(...));
    $this->recalculateTotal();
}

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

Правила проєктування (Вон Вернон):

  • маленькі агрегати: лише те, що потрібно для інваріантів. «Клієнт з усіма замовленнями» - поганий агрегат: зміна одного замовлення блокує всі;
  • посилання на інші агрегати - за ідентифікатором, а не об'єктом: замовлення зберігає customer_id, а не тримає Customer;
  • узгодженість між агрегатами - кінцева: через доменні події й окремі транзакції.

Конкурентність: два користувачі змінюють одне замовлення - потрібне оптимістичне блокування (версія агрегату) чи блокування рядка кореня.

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

Докладніше в документації: Martin Fowler: DDD Aggregate

Велика спокуса - зробити агрегат «як в реальному світі»: клієнт з усіма замовленнями, замовлення з товарами, товари з категоріями. Вон Вернон у серії «Effective Aggregate Design» сформулював правила, чому так робити не варто.

Правило 1. Агрегат - для інваріантів, а не для зручності навігації. До агрегату входить лише те, що потрібно для перевірки правил, які мають виконуватися миттєво й разом. Якщо правило «замовлення має позиції з сумою, що дорівнює total» - позиції входять. Якщо «клієнт має не більше 3 неоплачених замовлень» - це не обов'язково причина робити всі замовлення частиною агрегату клієнта.

Правило 2. Маленькі агрегати. Великий агрегат:

  • завантажується цілком при кожній зміні - повільно;
  • блокує конкурентні зміни: два менеджери редагують різні замовлення клієнта, а оптимістичне блокування агрегату «клієнт» відхиляє одну зі змін;
  • зростає з часом - клієнт з 10 000 замовлень.

Правило 3. Посилання на інші агрегати - за ідентифікатором:

// погано: агрегат тримає інший агрегат
class Order { private Customer $customer; }

// добре
class Order { private int $customerId; }

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

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

OrderPlaced → слухач (окрема транзакція) → оновити лічильник у Customer

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

Як знайти правильну межу - питання до бізнесу: «що станеться, якщо ці дві речі будуть неузгоджені кілька секунд?». Якщо «нічого страшного» - різні агрегати. Якщо «гроші списано двічі» - один агрегат чи явне блокування.

Практика в Laravel: зв'язки Eloquent ($order->customer) зручні для читання, але змінювати інші агрегати через них у методах кореня не варто. Читання - через зв'язки й запити; зміни - через корінь відповідного агрегату.

Докладніше в документації: Vaughn Vernon: Effective Aggregate Design

Репозиторій у DDD - колекція агрегатів в пам'яті з погляду домену: «дай замовлення за id», «збережи замовлення». Домен не знає, як саме дані зберігаються - SQL, документна база, зовнішній API.

interface OrderRepository
{
    public function find(OrderId $id): ?Order;
    public function save(Order $order): void;
    public function nextIdentity(): OrderId;
}

Конфлікт з Eloquent. Eloquent - патерн Active Record: модель сама знає, як себе зберегти ($order->save()), і є водночас доменним об'єктом і рядком таблиці. Класичний DDD передбачає Data Mapper: доменні об'єкти без знань про базу, а окремий шар (як Doctrine ORM) переносить їх у таблиці.

Варіанти на практиці:

1. Eloquent-моделі як доменні об'єкти, без репозиторіїв. Найпоширеніший і чесний для більшості Laravel-проєктів: поведінка в моделях, запити - в query scopes і окремих класах-запитах. Репозиторій-обгортка над Eloquent, що лише проксіює find/save, додає шар без користі.

2. Репозиторій як межа для складних запитів і тестування: інтерфейс у домені, реалізація на Eloquent. Має сенс, коли треба підміняти сховище в тестах доменної логіки чи коли запити справді складні. Але «чистоти» не дає: повертаються Eloquent-моделі з усією їх магією.

3. Чисті доменні об'єкти + Eloquent як шар зберігання. Домен - звичайні PHP-класи, репозиторій перетворює їх у Eloquent-моделі й назад. Максимальна ізоляція, але багато коду перетворень і втрата зручностей Laravel (зв'язки, касти, події моделей).

4. Doctrine ORM замість Eloquent - справжній Data Mapper, якщо DDD - ключова вимога проєкту.

Типові помилки:

  • «репозиторій для кожної моделі» з методами all(), find(), create(), update() - дублювання Eloquent без жодної переваги;
  • репозиторій повертає query builder - абстракція протікає, і логіка запитів розповзається;
  • репозиторій для читання й для запису однаковий - звіти й списки (читання) зручніше робити окремими класами-запитами, а репозиторій лишити для агрегатів (CQRS-підхід у легкому варіанті).

Головне питання: яку проблему вирішує шар? Якщо відповідь «так прийнято», - його, ймовірно, не варто додавати.

Докладніше в документації: Martin Fowler: Repository

Обидва - «сервіси», але на різних рівнях і з різною відповідальністю.

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

final class TransferPolicy
{
    public function transfer(Account $from, Account $to, Money $amount): void
    {
        if ($from->isFrozen() || $to->isFrozen()) {
            throw new AccountFrozen();
        }

        $from->withdraw($amount);
        $to->deposit($amount);
    }
}

Ознаки доменного сервісу:

  • назва з єдиної мови (PricingPolicy, TransferPolicy, ShippingCostCalculator);
  • без інфраструктури: не знає про HTTP, базу, черги, пошту;
  • без стану - лише операції над доменними об'єктами.

Сервіс застосунку (application service, use case) - координує виконання сценарію: отримує вхідні дані, завантажує агрегати, викликає доменну логіку, зберігає результат, запускає побічні ефекти.

final class TransferMoney
{
    public function __construct(
        private AccountRepository $accounts,
        private TransferPolicy $policy,
    ) {}

    public function handle(int $fromId, int $toId, Money $amount): void
    {
        DB::transaction(function () use ($fromId, $toId, $amount) {
            $from = $this->accounts->lockForUpdate($fromId);
            $to = $this->accounts->lockForUpdate($toId);

            $this->policy->transfer($from, $to, $amount);

            $this->accounts->save($from);
            $this->accounts->save($to);
        });

        MoneyTransferred::dispatch($fromId, $toId, $amount);
    }
}

Ознаки сервісу застосунку:

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

Чому розділення корисне: бізнес-правила (доменний сервіс, агрегати) тестуються без бази й фреймворку; сценарій (сервіс застосунку) - інтеграційними тестами. Правила не змішуються з технічними деталями.

У Laravel роль сервісу застосунку часто виконують actions (app/Actions/TransferMoney.php), job-класи чи тонкі контролери з Form Request. Типова помилка - «сервіс застосунку», в якому зосереджено всю бізнес-логіку, а моделі лишаються анемічними: тоді це вже не DDD, а транзакційний сценарій.

Докладніше в документації: Microsoft: проєктування DDD-орієнтованого мікросервісу

Антикорупційний шар (anti-corruption layer, ACL) - прошарок між вашою моделлю й чужою моделлю (зовнішній API, легасі-система, інший контекст), що перекладає між ними й не дає чужим поняттям «проникнути» у ваш домен.

Проблема без нього. Платіжний провайдер повертає {"txn_state": "SETTLED_PARTIAL", "amt_minor": 12550, "cur": 980}. Якщо ці назви й коди розходяться по коду застосунку, ваш домен починає «говорити мовою провайдера»:

  • перевірки if ($payment->txn_state === 'SETTLED_PARTIAL') у десятках місць;
  • зміна API провайдера чи перехід на іншого ламає весь застосунок;
  • модель провайдера (і її дивацтва) стає вашою моделлю.

З антикорупційним шаром:

final class LiqPayGateway implements PaymentGateway   // інтерфейс вашого домену
{
    public function status(PaymentId $id): PaymentStatus
    {
        $raw = $this->client->get("/transactions/{$id}");

        return match ($raw['txn_state']) {
            'SETTLED' => PaymentStatus::Paid,
            'SETTLED_PARTIAL' => PaymentStatus::PartiallyPaid,
            'REVERSED', 'CHARGEBACK' => PaymentStatus::Refunded,
            default => PaymentStatus::Pending,
        };
    }
}

Домен знає лише PaymentGateway і PaymentStatus. Усі перетворення - в одному класі.

Що робить шар:

  • переклад понять (статуси, назви полів, коди валют);
  • перетворення форматів (одиниці, дати, ідентифікатори);
  • захист від дивацтв чужої системи (нестабільні поля, помилки, обмеження);
  • фасад - простий інтерфейс над складним чи незручним API.

Коли потрібен:

  • інтеграція з легасі-системою, модель якої не хочеться переносити в нову;
  • зовнішні API, що можуть змінитися чи бути замінені (платежі, доставка, CRM);
  • поступова міграція з моноліту: новий модуль спілкується зі старим кодом через ACL (патерн Strangler Fig);
  • взаємодія обмежених контекстів з різними моделями.

Ціна: додатковий код і ще один шар, який треба підтримувати. Для простої, стабільної інтеграції (одне поле з API) повноцінний ACL - зайвий; досить невеликого класу-адаптера.

Зв'язок з патернами: технічно ACL часто складається з адаптерів, фасадів і перетворювачів - але суть не в патернах, а в захисті моделі домену від чужих понять.

Докладніше в документації: Azure: патерн Anti-corruption Layer