Питання на співбесіді: DDD і доменне моделювання
Питання з реальних співбесід з відповідями: Laravel і PHP, бази даних, JavaScript і фронтенд, Git, Docker, API, безпека й архітектура. Тими самими темами, що й тести.
14 питань
Єдина мова - спільний словник, яким користуються і бізнес, і розробники: в розмовах, документації, тікетах і в коді. Одне поняття - одна назва, і ця назва означає те саме для всіх.
Проблема, яку вона розв'язує. Менеджер каже «замовлення оформлене», бухгалтер - «рахунок виставлено», а в коді є Order зі статусом processing і таблиця invoices_tmp. Кожна розмова потребує «перекладу», і в перекладі губляться деталі: розробник реалізує не те, що мав на увазі бізнес.
Як це виглядає в коді:
// без єдиної мови
$order->status = 3;
$order->save();
$this->mailer->sendType2($order);
// з єдиною мовою
$order->confirm();
// всередині: перевірка правил підтвердження, подія OrderConfirmed
Назви класів, методів і подій (confirm, OrderConfirmed, cancel, refund) - ті самі слова, що їх вимовляють експерти предметної області. Код можна обговорювати з бізнесом майже дослівно.
Як її будувати:
- слухати експертів і записувати терміни, а не вигадувати технічні замінники;
- глосарій у документації проєкту - з визначеннями й прикладами;
- уточнювати неоднозначності: якщо «клієнт» означає і того, хто купує, і того, хто платить, - це два різні поняття, і їм потрібні різні назви;
- оновлювати код, коли змінюється розуміння: перейменування класу - нормальна частина роботи, а не «косметика».
Важливе обмеження: єдина мова діє в межах одного обмеженого контексту. «Товар» у каталозі (опис, фото, характеристики) і «товар» на складі (кількість, полиця, вага) - різні моделі, і силоміць зводити їх до одного класу Product - типова помилка великих систем.
Ознака проблеми: у команді є «перекладачі» - люди, без яких розробники й бізнес не розуміють одне одного.
Докладніше в документації: Martin Fowler: Ubiquitous Language
Сутність має ідентичність, що зберігається протягом усього життя: користувач лишається тим самим користувачем, навіть якщо змінив ім'я, email і адресу. Дві сутності з однаковими полями, але різними id - різні об'єкти.
Об'єкт-значення не має ідентичності - він визначається лише своїми значеннями. Дві суми «100 гривень» - одна й та сама сума; немає сенсу питати «яка саме з них».
| Сутність | Об'єкт-значення | |
|---|---|---|
| рівність | за ідентифікатором | за всіма значеннями |
| зміна | змінюється з часом | незмінний: «зміна» = новий об'єкт |
| приклади | User, Order, Invoice |
Money, Email, Address, DateRange |
Об'єкт-значення в PHP:
final readonly class Money
{
public function __construct(
public int $amount, // у копійках
public string $currency,
) {
if ($amount < 0) {
throw new InvalidArgumentException('Сума не може бути від\'ємною');
}
}
public function add(Money $other): self
{
if ($other->currency !== $this->currency) {
throw new InvalidArgumentException('Різні валюти');
}
return new self($this->amount + $other->amount, $this->currency);
}
public function equals(Money $other): bool
{
return $this->amount === $other->amount && $this->currency === $other->currency;
}
}
Що дають об'єкти-значення:
- валідація в одному місці: некоректний
Emailчи від'ємну суму неможливо створити - не треба перевіряти скрізь, де вони використовуються; - поведінка поруч з даними: додавання грошей, перевірка перетину періодів;
- незмінність (
readonly) - об'єкт можна безпечно передавати й кешувати; - виразність:
function charge(Money $amount)замістьfunction charge(int $amount, string $currency).
У Laravel об'єкти-значення зберігають у моделях через власні касти (CastsAttributes): Money з двох колонок price_amount і price_currency.
Пастка: робити сутністю те, що насправді значення (окрема таблиця addresses з id для адреси, яка ніколи не існує окремо від замовлення), - і навпаки.
Анемічна модель - об'єкти домену містять лише дані (поля, геттери, сеттери), а вся поведінка живе в окремих сервісах. Модель - це просто структура для бази.
// анемічна модель
class Order extends Model {}
class OrderService
{
public function cancel(Order $order): void
{
if ($order->status === 'shipped') {
throw new DomainException('Відправлене замовлення не скасувати');
}
$order->status = 'cancelled';
$order->cancelled_at = now();
$order->save();
}
}
Мартін Фаулер назвав це антипатерном: об'єкти виглядають як об'єктна модель, але насправді це процедурний код - дані окремо, функції окремо.
Проблеми:
- правила розпорошені: перевірку «чи можна скасувати» хтось повторить в іншому сервісі, контролері, команді - і забуде одну з умов;
- будь-хто може зламати інваріанти:
$order->status = 'cancelled'можна написати будь-де, оминувши правила; - сервіси розростаються до «божественних» класів на тисячі рядків.
Багата модель - поведінка поруч з даними:
class Order extends Model
{
public function cancel(string $reason): void
{
if ($this->status === OrderStatus::Shipped) {
throw new OrderAlreadyShipped($this);
}
$this->status = OrderStatus::Cancelled;
$this->cancelled_at = now();
$this->cancellation_reason = $reason;
}
}
Правило скасування - в одному місці, і викликати його можна лише через метод з назвою з єдиної мови.
Чи завжди анемічна модель погана? Ні:
- CRUD-застосунки без складної логіки - анемічна модель простіша й чесніша;
- «тонкі» моделі + actions (окремий клас на дію:
CancelOrder) - популярний у Laravel підхід. Він не багата модель у класичному сенсі, але логіка кожної операції зібрана в одному місці, і це вже краще за розмазаний код.
Критерій: якщо одні й ті самі правила повторюються в кількох місцях або бізнес-інваріанти порушуються «випадково» - логіці час переїхати ближче до даних.
Докладніше в документації: Martin Fowler: Anemic Domain Model
Доменна подія - факт, що вже стався в предметній області і важливий для бізнесу: OrderPlaced, PaymentReceived, SubscriptionCancelled. Назва - дієслово в минулому часі, бо подію не можна «скасувати» - лише відреагувати на неї.
final class OrderPlaced
{
public function __construct(
public readonly int $orderId,
public readonly int $customerId,
public readonly int $totalAmount,
public readonly CarbonImmutable $placedAt,
) {}
}
Навіщо вони:
1. Розв'язати зв'язки між частинами системи. Оформлення замовлення не повинно знати про лист клієнту, нарахування бонусів, оновлення аналітики й сповіщення складу:
// без подій: оформлення знає про все
$order->save();
$mailer->sendConfirmation($order);
$loyalty->addPoints($order);
$warehouse->reserve($order);
// з подією: оформлення лише повідомляє факт
OrderPlaced::dispatch($order->id, ...);
// окремі слухачі: лист, бонуси, склад - кожен незалежно
Новий обробник (наприклад, вебхук партнеру) додається без зміни коду оформлення.
2. Явно назвати важливі моменти бізнес-процесу - події стають частиною єдиної мови.
3. Інтеграція між контекстами чи сервісами - подія передається в чергу чи брокер повідомлень.
4. Журнал і аудит - історія того, що відбувалося.
У Laravel - події й слухачі:
class SendOrderConfirmation implements ShouldQueue
{
public function handle(OrderPlaced $event): void { /* ... */ }
}
Що варто знати:
- подія - про факт, а не команда:
OrderPlaced, а неSendOrderEmail; - подія після коміту транзакції: якщо слухач у черзі отримає подію до коміту, він не знайде замовлення в базі. У Laravel - інтерфейс
ShouldDispatchAfterCommitдля подій чиafterCommitдля слухачів; - дані в події - ідентифікатори й значущі значення, а не ціла модель, що може змінитися до обробки;
- приховані залежності: коли подій і слухачів багато, важко зрозуміти, що відбувається після дії.
php artisan event:listі чіткі назви допомагають.
Гроші, час і статуси - три типи даних, які найчастіше моделюють «примітивами» (float, string, int) і які дають найбільше помилок.
Гроші:
- не
float:0.1 + 0.2≠0.3. Сума в мінімальних одиницях (копійках) цілим числом абоdecimalу базі; - валюта завжди поруч з сумою: 100 - це гривні чи долари? Додавання сум у різних валютах має бути помилкою, а не мовчазним результатом;
- об'єкт-значення
Money(власний чи бібліотекаmoneyphp/money,brick/money) збирає правила в одному місці: додавання, порівняння, округлення, розподіл без втрат (100 грн на 3 частини - 33,34 + 33,33 + 33,33, а не три по 33,33 з загубленою копійкою).
Час:
- мить у часі -
CarbonImmutableв UTC; у місцевий час перетворювати лише для показу; - дата без часу (день народження, дата події) - окремий тип, а не
DateTimeз опівнічним часом, який зміщується в іншому поясі; - період - об'єкт
DateRangeз початком і кінцем і методамиcontains(),overlaps()замість пари змінних і перевірок, розкиданих по коду; - незмінні дати (
CarbonImmutable,Date::use(CarbonImmutable::class)) - щоб$start->addDay()не змінював оригінал непомітно; - «зараз» як залежність: код, що порівнює з поточним часом, легше тестувати, якщо час можна підмінити (
Carbon::setTestNow,$this->travelTo()).
Статуси:
- енум замість рядків і чисел:
OrderStatus::Paidзамість'paid'чи3; - дозволені переходи - у моделі чи енумі (
canTransitionTo()), а не вifпо всьому коду; - без суперечливих прапорців:
is_paid,is_shipped,is_cancelledдозволяють неможливі комбінації - один статус робить їх невиразними.
У Laravel все це підтримується з коробки: касти енумів ('status' => OrderStatus::class), власні касти для Money, immutable_datetime.
Загальна ідея - «примітивна одержимість» (primitive obsession) як запах коду: якщо значення має правила (формат, діапазон, операції), воно заслуговує на власний тип.
Агрегат - група пов'язаних об'єктів, яку розглядають як одне ціле для змін. Один з об'єктів - корінь агрегату: лише через нього зовнішній код змінює агрегат.
Приклад: замовлення (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
CQRS (Command Query Responsibility Segregation) - розділення моделі на дві: одна для змін (команди), інша для читання (запити).
Чому одна модель часто незручна для обох задач:
- запис потребує агрегатів з поведінкою й інваріантами, нормалізованих даних, транзакцій;
- читання потребує денормалізованих, плоских даних під конкретний екран: «список замовлень з ім'ям клієнта, кількістю позицій, статусом доставки й останнім коментарем».
Одна модель на обидві задачі або обростає зв'язками й N+1 для читання, або втрачає виразність для запису.
Рівні CQRS - від легкого до радикального:
1. Розділення в коді - найпоширеніше й найкорисніше:
// команди: змінюють стан через агрегати
final class PlaceOrder { public function handle(PlaceOrderData $data): OrderId { /* ... */ } }
// запити: окремі класи, що читають напряму, без моделей домену
final class OrderListQuery
{
public function get(int $customerId): Collection
{
return DB::table('orders')
->join('customers', ...)
->select(['orders.id', 'customers.name', 'orders.status', ...])
->where('orders.customer_id', $customerId)
->get();
}
}
Читання не обмежене формою агрегатів - оптимізований SQL під екран.
2. Окремі моделі читання (read models) - денормалізовані таблиці чи проєкції, що оновлюються подіями: таблиця order_summaries з усім потрібним для списку.
3. Окремі сховища - запис у реляційну базу, читання з Elasticsearch/Meilisearch чи окремої репліки.
Коли CQRS виправданий:
- складний домен з багатою логікою запису й різноманітними поданнями для читання;
- велика різниця навантажень: читань у сотні разів більше, і їх треба масштабувати окремо;
- різні вимоги до даних: пошук, звіти, аналітика;
- разом з event sourcing - там CQRS практично обов'язковий (стан будується з подій).
Коли шкодить: у простих CRUD-застосунках - подвоєння коду без вигоди. Фаулер прямо попереджає: для більшості систем CQRS додає ризику й складності.
Ціна окремих моделей читання:
- кінцева узгодженість: користувач зберіг зміну, а список ще показує старе. Інтерфейс має це враховувати;
- синхронізація: проєкції, що не оновилися через помилку, треба вміти перебудувати;
- більше рухомих частин.
Практична порада: почати з рівня 1 (окремі класи-запити) - він дає більшість користі майже без ціни.
Event sourcing - замість поточного стану зберігається послідовність подій, що до нього призвели. Стан обчислюється «програванням» подій.
Звичайно: accounts: id=7, balance=150
Event sourcing: AccountOpened(id=7)
MoneyDeposited(7, 200)
MoneyWithdrawn(7, 80)
MoneyDeposited(7, 30)
→ balance = 150
Події лише додаються - ніколи не змінюються й не видаляються. Виправлення - нова подія (DepositCorrected), а не редагування старої.
Переваги:
- повна історія й аудит «з коробки»: не лише що зараз, а як і чому так сталося. Природно для фінансів, бухгалтерії, медицини, юридичних процесів;
- стан на будь-який момент у минулому - програти події до потрібної дати;
- нові подання даних заднім числом: придумали новий звіт - будуєте проєкцію з усієї історії подій, ніби він існував завжди;
- налагодження: відтворити точну послідовність, що призвела до помилки;
- природна інтеграція через події з іншими частинами системи.
Ціна - і вона значна:
- складність: проєкції для читання (CQRS практично обов'язковий), знімки (snapshots) для агрегатів з тисячами подій, обробка кінцевої узгодженості;
- еволюція подій: подія, записана три роки тому, має читатися й сьогодні. Зміна структури потребує версіонування й «апкастингу» старих подій;
- видалення даних: незмінний журнал конфліктує з правом на видалення персональних даних (GDPR). Рішення - шифрування персональних даних у подіях окремим ключем і знищення ключа (crypto-shredding);
- запити до поточного стану - лише через проєкції; «просто зробити SQL-запит» уже не вийде;
- команда має розуміти підхід - інакше помилки в проєкціях і подіях дорогі.
Коли варто: домен, де історія змін - сама цінність (рахунки, облік, бронювання, системи з аудитом), або де потрібні складні часові запити.
Коли не варто: CRUD, контентні сайти, більшість типових вебзастосунків. Журнал аудиту (spatie/laravel-activitylog) чи таблиця історії змін дають частину переваг без перебудови архітектури.
У Laravel-екосистемі є готові пакети (наприклад, spatie/laravel-event-sourcing), але вони не знімають концептуальної складності.
Стратегічне проєктування в DDD починається не з коду, а з питання: які частини бізнесу справді важливі і куди вкладати найкращі сили.
Предметну область ділять на піддомени трьох типів:
1. Основний (core) піддомен - те, що відрізняє бізнес від конкурентів і заробляє гроші. Для сервісу пошуку роботи - алгоритм підбору вакансій і якість даних про них; для маркетплейсу - ціноутворення й рекомендації.
- сюди - найкращі розробники і найретельніше моделювання;
- писати самим: готове рішення означає мати те саме, що й конкуренти;
- тут DDD приносить найбільше користі.
2. Допоміжний (supporting) піддомен - потрібен бізнесу, специфічний для нього, але не дає конкурентної переваги: внутрішня адмінка модерації, звіти для менеджерів.
- писати самим, але простіше - без складного моделювання, CRUD цілком підходить;
- можна доручити менш досвідченій команді чи підряднику.
3. Загальний (generic) піддомен - однаковий для більшості бізнесів: автентифікація, платежі, розсилки, бухгалтерія, пошук, зберігання файлів.
- купувати чи брати готове: Stripe/LiqPay, Mailgun, Meilisearch, Laravel Fortify, готові CRM;
- писати самим - марнування ресурсів і ризик зробити гірше, ніж готовий продукт.
Як це впливає на архітектуру:
- межі контекстів часто проходять по межах піддоменів;
- антикорупційні шари - навколо загальних піддоменів, щоб модель постачальника не просочувалася в основний;
- інвестиції в якість (тести, рефакторинг, ретельний дизайн) - насамперед в основний піддомен.
Типові помилки:
- писати загальні речі самостійно («зробимо свою систему розсилок») - роки роботи над тим, що не дає переваги;
- купувати основний піддомен - віддати ключову перевагу в чужі руки й обмеження чужого продукту;
- однаковий рівень ретельності для всього - складна DDD-модель у CRUD-адмінці й поспіх в основній логіці.
Класифікація змінюється з часом: унікальна функція стає загальною, коли ринок наздоганяє; загальна може стати основною, якщо бізнес вирішує конкурувати саме в ній.
Для співбесіди: уміння пояснити, що в конкретному проєкті є основним піддоменом, - показник розуміння бізнесу, а не лише технологій.
DDD часто сприймають як набір тактичних патернів: репозиторії, фабрики, агрегати, value objects, шари. Перенесені механічно в Laravel-проєкт, вони дають багато коду й мало користі. Прагматичний підхід - брати ідеї, а не церемонії.
Що варто брати майже завжди:
- єдина мова: назви моделей, методів, подій і енумів - зі словника бізнесу (
$order->cancel(),OrderCancelled,OrderStatus::Refunded); - поведінка поруч з даними для важливих правил: методи на моделях замість
$order->status = ...по всьому коду; - енуми й об'єкти-значення для грошей, статусів, періодів - через касти Eloquent;
- доменні події для побічних ефектів;
- actions/use cases - один клас на бізнес-операцію (
PlaceOrder,RefundPayment): зрозуміла точка входу, легко тестувати; - модулі за предметними областями, а не за технічними шарами, коли проєкт росте:
app/Domain/Billing/{Models,Actions,Events,Enums}
app/Domain/Catalog/...
app/Domain/Shipping/...
Що варто брати лише для складного ядра:
- агрегати з явними межами і заборона змінювати дочірні сутності напряму;
- окремі моделі читання (CQRS) для важких звітів і списків;
- антикорупційні шари навколо зовнішніх інтеграцій.
Що зазвичай зайве в Laravel-проєкті:
- репозиторії-обгортки над Eloquent «для чистоти» - дублюють Eloquent і не дають ізоляції;
- окремі доменні класи + мапінг у Eloquent для простих сутностей - десятки класів перетворень;
- повна гексагональна архітектура для CRUD-адмінки;
- event sourcing без бізнес-потреби в історії.
Як вирішувати, де межа: спершу визначити основний піддомен (те, що приносить гроші й має складні правила). Там - більше моделювання й захисту інваріантів. Допоміжні й загальні частини - звичайний Laravel: моделі, Form Request, ресурси, Filament.
Ознаки, що складність виправдана:
- бізнес-правила часто змінюються і їх багато;
- помилки дорогі (гроші, юридичні наслідки);
- над кодом працює кілька команд.
Ознаки «DDD заради DDD»: більше коду інфраструктури, ніж бізнес-логіки; кожна проста зміна торкається п'яти шарів; нові розробники тижнями не розуміють, куди писати код.
Підсумок для співбесіди: DDD - насамперед про розуміння предметної області й спільну мову з бізнесом. Тактичні патерни - інструменти, які вмикають там, де складність домену цього вимагає.
Докладніше в документації: Martin Fowler: Domain Driven Design