<? phpukraine PHP-ПЛАТФОРМА
⌕Пошук по платформі
КОНЦЕПЦІЯ DDD · КРОК 5 З 10

Доменні подіїDomain events

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

6 хв читання Middle → Senior
01 · ЩО ЦЕ

Факт, на який хтось відреагує

Кандидат відкликав відгук. Роботодавцю потрібен лист, лічильнику відгуків мінус один, аналітиці запис у воронку. Якщо викликати все це з методу withdraw(), агрегат почне знати про пошту й звіти. З агрегат лише фіксує факт ApplicationWithdrawn, а реагують на нього окремо.

СЛОВНИКДоменна подія. Незмінний обʼєкт про те, що сталося в домені, з назвою в минулому часі: ApplicationWithdrawn.
СЛОВНИКОбробник. Код, який реагує на подію: шле лист, оновлює лічильник. Агрегат про нього не знає.

Подію називають дієсловом у минулому часі й мовою домену: ApplicationWithdrawn, а не SendWithdrawalEmail. Вона незмінна й несе лише потрібне: ID відгуку, час, хто це зробив. Агрегат складає події у список, а сценарій після збереження їх. Якщо подію читають інші контексти чи сервіси, її формат стає контрактом, і таку подію називають .

СЛОВНИКПублікація. Передача записаних подій обробникам. Відбувається після того, як агрегат збережено.
СЛОВНИКІнтеграційна подія. Подія для інших контекстів чи сервісів. Її формат є публічним контрактом і змінюється обережно, з версіями.

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

СЛОВНИКOutbox. Таблиця, куди подію пишуть у тій самій транзакції, що й агрегат. Окремий процес читає її й доставляє події, тож жодна не губиться.
02 · МЕЖА

Агрегат записує, сценарій публікує

ДІАГРАМА · УСЯ СХЕМА
Кроки: 4
withdraw()newsave()dispatch()сценарійWithdrawApplicationагрегатApplicationwithdraw():ApplicationWithdrawn«interface»ApplicationRepositorysave()Dispatcherпісля комітуNotifyEmployerRefreshCounters
сценарійWithdrawApplicationагрегатApplicationwithdraw():ApplicationWithdrawn«interface»ApplicationRepositorysave()Dispatcherпісля комітуNotifyEmployerRefreshCounters
Уся схема. Натисніть → або виберіть крок нижче, щоб пройти по ній частинами.
Абстракція Ключовий учасник Домен Інфраструктура Клієнт
  • WithdrawApplication використовує Application (withdraw()).
  • Application створює ApplicationWithdrawn.
  • WithdrawApplication використовує ApplicationRepository (save()).
  • WithdrawApplication використовує Dispatcher (dispatch()).
  • ApplicationWithdrawn надсилає подію, її отримує Dispatcher.
  • Dispatcher надсилає подію, її отримує NotifyEmployer.
  • Dispatcher надсилає подію, її отримує RefreshCounters.
  1. 1Назва в минулому часі й мовою домену: ApplicationWithdrawn, а не SendEmail.
  2. 2Агрегат записує подію, але не викликає обробників і нічого про них не знає.
  3. 3Публікують після коміту. Якщо втрата неприпустима, подію пишуть в outbox у тій самій транзакції.
  4. 4Обробник працює у власній транзакції, і його падіння не скасовує вже збережену зміну.
03 · ЯК У PHP UKRAINE

Що є в PHP Ukraine замість подій

Доменних подій, які один контекст публікує для іншого, у PHP Ukraine поки немає. Найближче до них історія відгуку: агрегат Application складає записи ApplicationEvent, а репозиторій пише їх у тій самій транзакції, що й сам відгук. Це журнал для людей, на нього ніхто не підписаний. Лист роботодавцю про новий відгук JobApplyController шле напряму, після сценарію ApplyToJob.

php EloquentApplicationRepository.php фрагмент, скорочено
1final class EloquentApplicationRepository implements ApplicationRepository
2{
3 public function save(Application $application): void
4 {
5 // The row and its new history lines land together, and only over the version that was read.
6 DB::transaction(function () use ($application, $row): void {← одна транзакція
7 if ($application->id === null) {
8 $model = ApplicationModel::query()->create($row + ['created_at' => $application->createdAt, 'version' => 0]);
9 $application->assignId($model->id);
10 } else {
11 $next = $application->version + 1;
12 $written = ApplicationModel::query()->whereKey($application->id)->where('version', $application->version)->update($row + ['version' => $next]);
13
14 if ($written === 0) {
15 throw new StaleApplication;← конфлікт версій
16 }
17
18 $application->assignVersion($next);
19 }
20
21 foreach ($application->newEvents() as $event) {← нові записи історії
22 $created = ApplicationEventModel::query()->create([
23 'application_id' => $application->id,
24 'kind' => $event->kind->value,
25 'actor' => $event->actor,
26 'data' => array_filter($event->data, fn ($v) => $v !== null),
27 'created_at' => $event->at,
28 ]);
29 $event->assignId($created->id);
30 }
31 });
32 }
33}
04 · ТИПОВІ ПОМИЛКИ

Як з подіями зазвичай помиляються

✕ 1

Подія як команда

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

Натомість: називайте факт у минулому часі. Що з ним робити, вирішують обробники.

✕ 2

Публікація до коміту

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

Натомість: віддавайте події після коміту. У Laravel є DB::afterCommit(), а для гарантованої доставки потрібен outbox.

✕ 3

Уся сутність у події

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

Натомість: кладіть у подію ID і дані про сам факт: хто, коли, з якого етапу.

05 · ДЕРЕВО РІШЕНЬ

Чи потрібна вам доменна подія

1
Чи має зміна наслідки поза самим агрегатом?
Лист, лічильник, запис в аналітиці.
НІ →Подія не потрібна. Агрегат змінився, і більше нікому до цього немає діла.
2
Чи можуть ці наслідки статися трохи пізніше й в окремій транзакції?
Лист через секунду після відкликання нікому не шкодить.
НІ →Це частина того самого інваріанта. Перегляньте межу агрегату замість того, щоб додавати подію.
3
Чи наслідків кілька, або їх скоро стане більше?
Сьогодні лист, завтра ще й аналітика.
НІ →Один прямий виклик зі сценарію після збереження читається простіше. Так PHP Ukraine шле лист про новий відгук.
ТАК НА ВСІ →Вам потрібна доменна подія. Записуйте її в агрегаті й публікуйте після коміту.
06 · СЛОВНИК
Доменна подія
Незмінний обʼєкт про те, що сталося в домені, з назвою в минулому часі: ApplicationWithdrawn.
Обробник
Код, який реагує на подію: шле лист, оновлює лічильник. Агрегат про нього не знає.
Публікація
Передача записаних подій обробникам. Відбувається після того, як агрегат збережено.
Інтеграційна подія
Подія для інших контекстів чи сервісів. Її формат є публічним контрактом і змінюється обережно, з версіями.
Outbox
Таблиця, куди подію пишуть у тій самій транзакції, що й агрегат. Окремий процес читає її й доставляє події, тож жодна не губиться.
07 · ПЕРЕВІР СЕБЕ

Питання зі співбесід

Усі питання з архітектури →

Подія моделі каже, що в базі зʼявився рядок, і спрацьовує на будь-який create(). Доменна подія каже про бізнес-факт мовою домену: ApplicationWithdrawn виникає лише тоді, коли кандидат справді відкликав відгук.

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

Transactional outbox: подію пишуть у таблицю outbox у тій самій транзакції, що й агрегат. Окремий процес читає таблицю й доставляє події, повторюючи до успіху. Обробники мають бути ідемпотентними, бо подія може прийти двічі.