0% прочитано

Налаштування нотифікацій краще оформити як policy, а не розкидати if по listener-ах

Коли кожен listener сам вирішує, кому, коли і через який канал надсилати notification, правила швидко дублюються. Краще мати policy layer, який повертає структуроване рішення, а delivery service лише виконує доставку.

3 листопада 2026 р. 9 хв читанняЛаравель

У notification-системі легко почати з простого listener: подія сталася, перевірити кілька умов і викликати send.

Проблема з’являється пізніше. Один listener перевіряє permission, інший user preferences, третій активність template, четвертий channels. Через кілька feature ті самі правила вже живуть у різних місцях.

Я віддаю перевагу окремому notification policy: один компонент вирішує, чи треба створювати notification, кому її доставляти і через які channels. Listener лише передає факт бізнес-події.

Listener, який знає забагато

Listener із розкиданими правилами
public function handle(PostPublished $event): void{    $user = $event->post->author;     if ($user === null) {        return;    }     if (! $user->can('posts_create')) {        return;    }     if (! $this->template->isActive()) {        return;    } 

Поки listener один, це здається нормальним. Коли ті самі перевірки з’являються у scheduled publication, account events і системних нагадуваннях, кожен listener починає мати власну версію policy.

Listener повинен залишатися тонким

Listener має знати, що сталося, і передати typed context. Він не повинен сам визначати recipient, preferences, template, channels і global delivery rules.

Thin listener
public function handle(PostPublished $event): void{    $decision = $this->policy->decide(        NotificationContext::forPost(            post: $event->post,            trigger: NotificationTrigger::ScheduledPostPublished,        ),    );     $this->delivery->deliver($decision);}

Policy має повертати структуроване рішення

NotificationDecision
final readonly class NotificationDecision{    public function __construct(        public bool $shouldSend,        public ?User $recipient,        public ?NotificationTemplate $template,        public string $locale,        public array $channels,        public ?string $eventKey,        public ?string $skipReason,    ) {    }}

Простого true або false замало. Delivery service потрібні recipient, template, locale, channels, event key та причина skip.

Що саме вирішує policy

Я б залишила в policy такий порядок:

  1. визначити recipient;
  2. перевірити business permission rules;
  3. знайти активний notification template;
  4. перевірити global automatic-delivery gate;
  5. отримати template channels;
  6. отримати channels з user preferences;
  7. побудувати фактичний channel set;
  8. сформувати event key для idempotency;
  9. повернути send або skip із reason code.

Channels - це перетин правил

Channel resolver
$channels = array_values(    array_intersect(        $template->enabledChannels(),        $preferences->allowedChannels(),    ),); if ($channels === []) {    return NotificationDecision::skip(        reason: 'no_allowed_channels',    );}

Template може підтримувати database, broadcast і mail, але user preferences можуть заборонити mail. Фактичний набір channels має визначатися одним resolver, а не окремими if у listener.

Глобальний delivery gate має бути один

Якщо automatic notifications можна глобально вимкнути, цю перевірку не потрібно копіювати у кожен listener. Один gate дає однакову operational поведінку для всіх automatic notification flows.

Policy вирішує, delivery service виконує

Policy відповідає на запитання "що дозволено?". Delivery service відповідає на запитання "як це безпечно доставити?".

Delivery service може володіти idempotency ledger, database notification, broadcast, mail, retries і technical outcome. Він не повинен повторно визначати recipient або preferences.

Policy -> tracked delivery
$decision = $policy->decide($context); if (! $decision->shouldSend) {    return DeliveryResult::skipped(        $decision->skipReason,    );} return $trackedDelivery->send(    recipient: $decision->recipient,    template: $decision->template,    channels: $decision->channels,    eventKey: $decision->eventKey,);

Reason codes роблять skip зрозумілим

Якщо listener просто робить return, під час QA складно зрозуміти причину. Reason codes на кшталт missing_recipient, permission_denied, template_inactive, delivery_disabled або no_allowed_channels роблять рішення спостережуваним.

Не робіть один гігантський policy

Централізація не означає один клас на тисячу рядків. Спільні правила залишаються в reusable services: template resolver, channel resolver, preference resolver і delivery gate.

Trigger-specific policy містить лише бізнесову специфіку: хто recipient, яка eligibility і як формується event key.

  1. domain event або scheduler знаходить бізнес-подію;
  2. listener створює typed context;
  3. trigger policy визначає recipient та eligibility;
  4. template resolver знаходить активний template;
  5. delivery gate перевіряє automatic delivery;
  6. channel resolver об’єднує template settings і user preferences;
  7. policy повертає NotificationDecision;
  8. tracked delivery резервує event key і виконує channels;
  9. результат фіксується як delivered, skipped, already delivered або failed.

Тести policy стають простішими

Policy можна тестувати без реальної mail або broadcast доставки:

  • recipient відсутній - skip;
  • permission відкликано - skip;
  • template inactive - skip;
  • global gate off - skip;
  • mail вимкнений у preferences - mail немає в channels;
  • database + broadcast дозволені - обидва повертаються.
Policy test без реальної доставки
public function test_mail_is_removed_when_user_opted_out(): void{    $decision = $this->policy->decide(        $this->context(            templateChannels: [                'database',                'broadcast',                'mail',            ],            userChannels: [                'database',                'broadcast',            ],        ),    ); 

Listener повинен знати, що сталося. Policy повинен вирішити, чи потрібна нотифікація. Delivery service повинен безпечно виконати вже прийняте рішення.

Підсумок

Розкидані if у listener-ах працюють тільки поки notification rules прості. Коли з’являються templates, permissions, global gates, user preferences, кілька channels та idempotency, рішення краще зібрати в policy layer.

Це дає одне місце для бізнесового рішення, однакову поведінку для різних triggers, зрозумілі reason codes і простіші tests.