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 межі агрегату - домовленість, а не обмеження мови: моделі позицій усе одно можна змінити напряму. Допомагають код-рев'ю, методи на корені й те, що контролери працюють лише з коренем.
Велика спокуса - зробити агрегат «як в реальному світі»: клієнт з усіма замовленнями, замовлення з товарами, товари з категоріями. Вон Вернон у серії «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-підхід у легкому варіанті).
Головне питання: яку проблему вирішує шар? Якщо відповідь «так прийнято», - його, ймовірно, не варто додавати.
Обидва - «сервіси», але на різних рівнях і з різною відповідальністю.
Доменний сервіс - бізнес-логіка, що не належить природно жодній сутності чи об'єкту-значенню. Зазвичай - операція, що охоплює кілька агрегатів чи потребує зовнішніх правил:
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