0% прочитано

DDD на практиці в AVN Center: межі моделі, агрегати та узгодженість

На прикладі ProductRegistry показую кілька практичних рішень DDD: як розділяю відповідальність між шарами, чому ProductRegistration і BootstrapInvitation залишаються окремими агрегатами, коли доречні події та коли потрібна сильна узгодженість.

30 вересня 2026 р. 8 хв читанняЛаравель

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

У AVN Center це добре видно на модулі ProductRegistry. Тут є реєстрація продукту, тимчасовий дозвіл на її початок, ідентичність, збереження даних та зовнішній API.

На цьому прикладі покажу кілька рішень, де DDD допомагає не додавати зайві шари, а навпаки - чіткіше визначати відповідальність.

Це не повний посібник з DDD. Тут лише кілька практичних рішень із ProductRegistry, де межі моделі безпосередньо впливають на структуру коду.

Чотири шари - чотири різні відповідальності

Розподіл відповідальності у ProductRegistry
Delivery    зовнішній HTTP API    запит і відповідь            |            vApplication    координація сценарію    контракти збереження    правила вибору            |            vDomain    стан    ідентичність    інваріанти    допустимі переходи

У ProductRegistry назви Domain, Application, Infrastructure і Delivery - це не просто папки.

Доменний шар володіє станом і правилами моделі. Тут знаходяться ProductRegistration, BootstrapInvitation, об’єкти ідентичності та допустимі переходи стану.

Прикладний шар координує конкретний сценарій. Наприклад, EstablishOrResumeProductRegistration визначає послідовність дій, але не працює напряму з Eloquent.

Інфраструктурний шар відповідає за технічне збереження: базу даних, унікальні обмеження, транзакції та блокування.

Зовнішній шар приймає HTTP-запит, перевіряє його форму та повертає HTTP-відповідь.

Для мене тут важливе правило: бізнес-рішення не повинно залежати від того, яким способом дані сьогодні зберігаються або передаються через HTTP.

PHP - Прикладний сценарій працює через власні контракти
final readonly class EstablishOrResumeProductRegistration{    public function __construct(        private ProductRegistrationRepository $repository,        private ProductRegistrationIdGenerator $idGenerator,        private ProductRegistrationProtocolPolicy $protocolPolicy,    ) {}     public function execute(        ProductIdentity $productIdentity,        ProductInstanceIdentity $productInstanceIdentity,        BootstrapIdempotencyKey $bootstrapKey,        array $supportedProtocolVersions,    ): ProductRegistration {        $existing = $this->repository            ->findByBootstrapKey($bootstrapKey);

ProductRegistration і BootstrapInvitation - окремі корені агрегатів

ProductRegistration і BootstrapInvitation беруть участь в одному сценарії початкової реєстрації, але це не означає, що вони повинні бути одним великим об’єктом.

ProductRegistration представляє сам зв’язок реєстрації та має власний життєвий цикл.

BootstrapInvitation має інше призначення: воно дає обмежене в часі право почати або продовжити реєстрацію. У нього є власна ідентичність, строк дії, середовище, прив’язка до реєстрації та правила повторного використання.

У термінах DDD обидва є окремими коренями агрегатів (Aggregate Root).

Те, що два об’єкти беруть участь в одному сценарії, ще не означає, що вони мають один життєвий цикл і одного власника стану.

PHP - BootstrapInvitation сам захищає правила прив’язки
public function bind(    ProductRegistrationId $registrationId,    DateTimeImmutable $at,): void {    if ($this->boundRegistrationId !== null) {        if ($this->boundRegistrationId->equals($registrationId)) {            return;        }         throw BootstrapInvitationBindingConflict            ::alreadyBoundToAnotherRegistration();    }     if ($this->isRevoked()) {        throw BootstrapInvitationBindingNotAllowed::revoked();    }

Зовнішній код не повинен просто записати новий bound_registration_id.

Замість цього викликається bind(), а сам BootstrapInvitation перевіряє, чи не був він уже прив’язаний до іншої реєстрації, відкликаний або прострочений.

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

Подія потрібна не після кожної зміни

Ще одне важливе рішення - не перетворювати кожну зміну стану на подію.

Подія доречна, коли певний факт уже відбувся, а інші частини системи можуть відреагувати на нього трохи пізніше.

Наприклад, після успішного збереження окремо можуть працювати аудит, спостережуваність або проєкції для читання. Невелика затримка тут не робить основну операцію неправильною.

Інша ситуація - коли другий крок є частиною того самого правила. Тоді відкладене виконання створює проміжний стан, який система повинна вміти безпечно пережити.

Тому я перевіряю не просто, чи можна використати подію, а чи справді друга частина операції може відбутися пізніше без порушення правил.

Подія залежить від вимог до узгодженості
Подія доречна: основна зміна успішно збережена    |    +--> аудит    +--> спостережуваність    +--> проєкція Невелика затримка допустима.  Інша ситуація: ProductRegistration уже створена    |    +--> прив’язка BootstrapInvitation ще не виконана

Коли між агрегатами потрібна сильна узгодженість

У сценарії початкової реєстрації є саме такий випадок.

Перше успішне створення або відновлення ProductRegistration має відбутися разом із прив’язкою відповідного BootstrapInvitation.

Не можна допустити стан, де реєстрація вже вважається успішно створеною, а запрошення все ще виглядає вільним і може бути використане для іншої реєстрації.

Тому тут потрібна сильна узгодженість: або обидві зміни успішно зберігаються, або жодна з них не стає остаточною.

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

Бізнесова атомарність і технічна транзакція
Прикладний шар визначає одну операцію: перевірити BootstrapInvitation        |        vстворити або відновити ProductRegistration        |        vприв’язати BootstrapInvitation        |        vуспіх  Інфраструктурний шар забезпечує: 
Один агрегат на одну транзакцію залишається хорошим правилом за замовчуванням. Але це не абсолютна заборона. Якщо правило між двома агрегатами не може бути порушене навіть тимчасово, одна транзакція може бути правильнішим рішенням.

Що я перевіряю, коли визначаю межі моделі

У практичній роботі мені допомагають кілька питань:

  • Хто володіє цим станом? Зміна повинна проходити через об’єкт, який відповідає за нього.
  • Де живе правило? Якщо правило належить одному агрегату, саме він має його захищати.
  • Чи мають два об’єкти один життєвий цикл? Спільний сценарій ще не робить їх одним агрегатом.
  • Чи може друга дія відбутися пізніше? Якщо так, подія може бути доречною.
  • Чи допустимий проміжний неузгоджений стан? Якщо ні, потрібна сильніша гарантія узгодженості.
  • Чи не проникли технічні деталі в модель? Доменний і прикладний код не повинні залежати від конкретної ORM або способу роботи HTTP.

Практична цінність DDD - у чітких межах

У ProductRegistry DDD не означає, що кожен клас повинен мати окреме сховище, кожна зміна - породжувати подію, а всі пов’язані об’єкти - входити в один великий агрегат.

Навпаки, модель залишається зрозумілішою, коли кожна відповідальність має чітку межу.

ProductRegistration і BootstrapInvitation можуть бути окремими агрегатами, але для однієї конкретної операції вимагати сильної узгодженості.

Прикладний шар може координувати сценарій, не знаючи деталей Eloquent. Інфраструктурний шар може керувати транзакцією, не приймаючи бізнес-рішень.

Для мене практична сторона DDD саме в цьому: не додавати більше архітектури, а точніше визначати, хто володіє станом, правилами та межами узгодженості.

DDD допомагає мені не ускладнювати код патернами, а чіткіше визначати власника стану, правил і меж узгодженості.