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

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 для адреси, яка ніколи не існує окремо від замовлення), - і навпаки.

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

Анемічна модель - об'єкти домену містять лише дані (поля, геттери, сеттери), а вся поведінка живе в окремих сервісах. Модель - це просто структура для бази.

// анемічна модель
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 і чіткі назви допомагають.

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

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

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