0% прочитано

Подія правильна, а listener вже застарів: як старе lifecycle-rule продовжує жити після зміни вимог

Коли бізнес-вимога змінює момент запуску процесу, недостатньо підключити нову подію. Старі listeners, guards, services і тести можуть і далі непомітно реалізовувати попереднє правило життєвого циклу.

20 жовтня 2026 р. 10 хв читанняЛаравель

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

У моєму випадку потрібний процес уже мав запускатися після registration. Нова подія була правильною. Але старий listener усе ще перевіряв умову, яка належала попередньому lifecycle: очікував email verification.

У результаті код виглядав оновленим, event спрацьовував у правильний момент, але фактична бізнес-дія все одно не виконувалася для частини користувачів.

Зміна event не гарантує зміну поведінки. Після зміни lifecycle треба пройти весь execution path і знайти всі місця, які все ще захищають старе правило.

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

Бізнесова зміна виглядає простою
До registration    |    vemail verification    |    vbusiness action  Після зміни вимог registration    |    v

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

Тому природна перша реакція - знайти потрібний event і підключити listener до нього.

Але event визначає тільки момент входу в flow. Він не гарантує, що всі наступні перевірки вже відповідають новій вимозі.

Старий guard може непомітно повернути попередню семантику

Event уже новий, але guard залишив старий lifecycle
final class ClaimPendingData{    public function handle(        UserRegistered $event,    ): void {        $user = $event->user;         if (! $user->hasVerifiedEmail()) {            return;        }         $this->claimService->handle(            $user,        );    }}

На перший погляд listener виглядає безпечним: він просто перевіряє додаткову умову.

Але після зміни вимоги ця умова вже не є safety guard. Вона фактично повертає старий lifecycle.

Новий event каже "запусти після registration", а guard нижче каже "ні, все одно дочекайся verification".

Я розділяю дві речі: момент запуску і право виконати дію

Trigger і eligibility не повинні випадково змішуватися
Запитання 1:КОЛИ має розпочатися процес? Відповідь:після реєстрації  Запитання 2:ХТО має право на участь? Відповідь:залежить від поточних бізнес-правил,а не від припущень, що ґрунтуються на історії життєвого циклу

Event відповідає на питання "коли перевірити або запустити процес?".

Guard відповідає на інше питання: "чи має цей конкретний об’єкт право пройти далі?".

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

Configuration робить застарілий guard ще небезпечнішим

Особливо підступна ситуація виникає, коли email verification може бути увімкнена або вимкнена конфігурацією.

Якщо verification вимкнена, користувач може бути повністю валідним одразу після registration. Старий unconditional guard hasVerifiedEmail() тоді блокує правильний сценарій назавжди.

Тобто guard не просто затримує виконання. Він робить частину flow недосяжною.

Guard повинен виражати поточне правило, а не історичне припущення
if (    $verificationRequired    && ! $user->hasVerifiedEmail()) {    return;} $this->claimService->handle(    $user,);

Після зміни lifecycle я проходжу весь execution path

Стара семантика може залишитися на будь-якому рівні
Event  |  vListener  |  vGuard  |  vApplication service  |  vDomain rule  |  vRepository / persistence

Я більше не зупиняю перевірку після того, як знайшла правильний event.

Потрібно подивитися listener, early returns, policies, helper methods, application services, domain checks, query scopes і тести.

Будь-яке з цих місць може містити старе правило, яке раніше було правильним.

Найскладніше те, що старий код не виглядає неправильним

Застарілий lifecycle guard часто не схожий на bug.

Він має хорошу назву, покритий тестом і колись захищав коректну поведінку.

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

Старий тест теж може захищати вже неправильну поведінку

Колись правильний тест може закріпити старий lifecycle
public function test_unverified_user_is_skipped(): void{    $user = User::factory()        ->unverified()        ->create();     $listener->handle(        new UserRegistered($user),    );     $this->assertNothingClaimed();}

Після зміни вимоги такий тест не просто потребує оновлення. Спочатку треба перевірити, чи його очікування взагалі ще є бізнес-правилом.

Тест не є джерелом вимоги. Він лише фіксує те, що ми вважали правильним у момент написання.

Я тестую lifecycle як матрицю сценаріїв

Одна конфігурація не повинна приховувати іншу
Потрібна верифікація? YES  registered + unverified -> wait  registered + verified   -> run NO  registered + unverified -> run  registered + verified   -> run

Такий test matrix добре показує, де насправді знаходиться правило.

Якщо verification обов’язкова, вона може залишатися умовою. Якщо verification вимкнена, той самий guard уже не має права блокувати flow.

Окремі тести для обох конфігурацій значно краще захищають зміну вимог, ніж один happy path.

Service нижче listener я роблю безпечним для повторного запуску

Коли lifecycle змінюється, певний час можуть існувати кілька trigger paths: старий event, новий event, retry queue або ручний backfill.

Тому service, який виконує реальну бізнес-дію, краще робити idempotent, якщо це можливо.

Тоді виправлення event wiring не створює новий ризик подвійної обробки.

Я шукаю старе правило не тільки за назвами класів

Пошук по старій умові часто корисніший за пошук по новому event
rg "hasVerifiedEmail|verified_at|verification" app tests rg "return;" app/Listeners app/Services

Якщо вимога змінилася з "після verification" на "після registration", я шукаю не тільки registration event.

Я шукаю всі згадки verification, старі helper names, guards, query scopes і tests. Саме там найчастіше залишається попередня семантика.

Не кожен старий guard треба видаляти

Після такого bugfix легко піти в іншу крайність і просто прибрати всі перевірки.

Я цього не роблю. Потрібно розрізнити lifecycle guard і справжній invariant або security condition.

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

Моя перевірка після зміни lifecycle-rule

  • Я зафіксувала новий trigger moment окремо від eligibility rule.
  • Перевірила всі listeners нового і старого event.
  • Переглянула early returns і guards нижче по flow.
  • Перевірила configuration branches.
  • Переглянула services, policies, scopes і helper methods зі старою семантикою.
  • Знайшла tests, які могли закріпити попереднє правило.
  • Додала scenario matrix для старого й нового lifecycle.
  • Перевірила idempotency бізнес-дії на випадок повторного trigger.

Lifecycle-rule живе не в event, а в усьому execution path

Найважливіший висновок із цього кейсу для мене такий: змінити event недостатньо.

Event може бути правильним, але listener, guard, service або test нижче все одно здатні повернути стару поведінку.

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

Правильний event визначає, коли flow починається. Але фактичний lifecycle визначається всіма умовами, які стоять між event і бізнес-дією.