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

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

Докладніше в документації: 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

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

Приклад: замовлення (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

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 (окремі класи-запити) - він дає більшість користі майже без ціни.

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

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), але вони не знімають концептуальної складності.

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

Стратегічне проєктування в DDD починається не з коду, а з питання: які частини бізнесу справді важливі і куди вкладати найкращі сили.

Предметну область ділять на піддомени трьох типів:

1. Основний (core) піддомен - те, що відрізняє бізнес від конкурентів і заробляє гроші. Для сервісу пошуку роботи - алгоритм підбору вакансій і якість даних про них; для маркетплейсу - ціноутворення й рекомендації.

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

2. Допоміжний (supporting) піддомен - потрібен бізнесу, специфічний для нього, але не дає конкурентної переваги: внутрішня адмінка модерації, звіти для менеджерів.

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

3. Загальний (generic) піддомен - однаковий для більшості бізнесів: автентифікація, платежі, розсилки, бухгалтерія, пошук, зберігання файлів.

  • купувати чи брати готове: Stripe/LiqPay, Mailgun, Meilisearch, Laravel Fortify, готові CRM;
  • писати самим - марнування ресурсів і ризик зробити гірше, ніж готовий продукт.

Як це впливає на архітектуру:

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

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

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

Класифікація змінюється з часом: унікальна функція стає загальною, коли ринок наздоганяє; загальна може стати основною, якщо бізнес вирішує конкурувати саме в ній.

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

Докладніше в документації: Azure: аналіз предметної області

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