Junior: питання на співбесіді з теми «DDD і доменне моделювання»
Питання з реальних співбесід з відповідями: Laravel і PHP, бази даних, JavaScript і фронтенд, Git, Docker, API, безпека й архітектура. Тими самими темами, що й тести.
5 питань
Єдина мова - спільний словник, яким користуються і бізнес, і розробники: в розмовах, документації, тікетах і в коді. Одне поняття - одна назва, і ця назва означає те саме для всіх.
Проблема, яку вона розв'язує. Менеджер каже «замовлення оформлене», бухгалтер - «рахунок виставлено», а в коді є 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) як запах коду: якщо значення має правила (формат, діапазон, операції), воно заслуговує на власний тип.