Obserwatory Laravel a zdarzenia domenowe: Wyznacz granicę

8 min. czytania

Zamówienie zostało zapisane. Czy to oznacza fakt biznesowy? Niekoniecznie: mogła to być korekta adresu, notatka administratora albo automatyczna zmiana znacznika czasu. Dlatego „zareaguj na Order::updated” jest inną decyzją niż „zareaguj, gdy klient złożył zamówienie”. Laravel pozwala łatwo połączyć oba mechanizmy; trudność polega na wybraniu kontraktu prawdziwego również dla importów, retry, transakcji i wielu wywołujących.

Obserwator obsługuje lokalny skutek zapisu. Zdarzenie domenowe opisuje intencjonalny fakt, który może skonsumować inna część biznesu. Akcja koordynuje przejście stanu, a listener reaguje na fakt. Ta granica sprawia, że endpoint API, komenda konsolowa, import CSV i ekran administracyjny nie potraktują każdego save() jak sprzedaży.

Słownictwo ujawnia granicę

Obserwator odpowiada na pytanie: „co powinno zdarzyć się, gdy ten model został utworzony, zapisany lub usunięty?”. Jego język wynika z cyklu życia Eloquent. Jest właściwy dla UUID, unieważnienia klucza cache czy małej lokalnej projekcji. Praca powinna być szybka, deterministyczna i bezpieczna także wtedy, gdy uruchomi ją seeder albo import.

Zdarzenie domenowe odpowiada: „jaki fakt biznesowy właśnie stał się prawdą?”. OrderPlaced, QuoteAccepted i SubscriptionCancelled to nazwy rozpoznawalne przez właściciela produktu. Należą na granicy use case'u, po spełnieniu niezmienników, a nie do wniosku wyciąganego z zdarzenia ORM podobnego tylko z nazwy.

Zamówienie robocze, ponowienie płatności i import historii mogą każdy utworzyć wiersz orders. Tylko jedna ścieżka może naprawdę znaczyć „klient złożył dziś zamówienie”. Ta decyzja musi mieszkać w kodzie, który posiada przejście stanu.

Obserwator nie jest ukrytym workflowem

Unieważnienie cache katalogu jest dobrym obserwatorem: nie wywołuje systemu zewnętrznego ani nie ocenia znaczenia biznesowego zmiany. Produkt może zostać zmieniony w transakcji, dlatego obserwator wykona się po commicie.

php
<?php

declare(strict_types=1);

namespace App\Observers;

use App\Models\Product;
use Illuminate\Contracts\Events\ShouldHandleEventsAfterCommit;
use Illuminate\Support\Facades\Cache;

final class ProductObserver implements ShouldHandleEventsAfterCommit
{
    public function saved(Product $product): void
    {
        Cache::forget("catalog.product.{$product->getKey()}");
        Cache::forget("catalog.product.slug.{$product->slug}");
    }

    public function deleted(Product $product): void
    {
        Cache::forget("catalog.product.{$product->getKey()}");
        Cache::forget("catalog.product.slug.{$product->slug}");
    }
}

Zarejestruj klasę w providerze, o ile projekt nie używa discovery zdarzeń.

php
<?php

declare(strict_types=1);

namespace App\Providers;

use App\Models\Product;
use App\Observers\ProductObserver;
use Illuminate\Support\ServiceProvider;

final class AppServiceProvider extends ServiceProvider
{
    public function boot(): void
    {
        Product::observe(ProductObserver::class);
    }
}

Nie rozbudowuj tej klasy o mail, płatność, analitykę czy API magazynu. Takie konsekwencje są niewidoczne z kodu checkoutu, uruchamiają się też dla factory oraz importów i mogą dotyczyć rekordu, który później rollback usunie. ShouldHandleEventsAfterCommit rozwiązuje czas, lecz nie wybiera właściciela procesu.

Akcja nazywa fakt

Tylko poniższa akcja może stwierdzić, że złożono zamówienie. Zapisuje agregat, liczy snapshot w integerowych centach i dispatchuje niemutowalne zdarzenie. Payload zawiera stabilne skalary, nie żywy model z grafem relacji.

php
<?php

declare(strict_types=1);

namespace App\Orders\Actions;

use App\Models\Order;
use App\Orders\Events\OrderPlaced;
use Illuminate\Support\Facades\DB;

final class PlaceOrder
{
    /** @param array<int, array{product_id: int, quantity: int, unit_price_cents: int}> $lines */
    public function handle(int $customerId, array $lines): Order
    {
        return DB::transaction(function () use ($customerId, $lines): Order {
            $totalCents = collect($lines)->sum(
                fn (array $line): int => $line['quantity'] * $line['unit_price_cents'],
            );

            $order = Order::query()->create([
                'customer_id' => $customerId,
                'status' => 'placed',
                'total_cents' => $totalCents,
            ]);

            $order->lines()->createMany($lines);

            OrderPlaced::dispatch($order->getKey(), $customerId, $totalCents);

            return $order;
        });
    }
}

ShouldDispatchAfterCommit nie pozwala listenerowi ani workerowi zobaczyć zamówienia przed widocznością transakcji. Gdy transakcja się nie powiedzie, Laravel odrzuca zdarzenie zamiast wysłać potwierdzenie lub analitykę dla nieistniejącego zamówienia.

php
<?php

declare(strict_types=1);

namespace App\Orders\Events;

use Illuminate\Contracts\Events\ShouldDispatchAfterCommit;
use Illuminate\Foundation\Events\Dispatchable;

final readonly class OrderPlaced implements ShouldDispatchAfterCommit
{
    use Dispatchable;

    public function __construct(
        public int $orderId,
        public int $customerId,
        public int $totalCents,
    ) {}
}

Traktuj payload jak kontrakt. Dodawaj pola świadomie, wersjonuj zmianę łamiącą semantykę dla zewnętrznych konsumentów i nie serializuj wszystkich relacji „na wszelki wypadek”. Listener po commicie może pobrać aktualny model odczytowy po ID.

Kolejka dostarcza co najmniej raz

Retry po timeoutach, awarii workera i chwilowym błędzie SMTP jest normalne. Listener kolejkowy musi uczynić efekt idempotentnym. Tu order_receipts.order_id ma unikalny indeks. To on zabezpiecza współbieżność; exists() przed insertem nie zabezpiecza wyścigu dwóch workerów.

php
<?php

declare(strict_types=1);

namespace App\Orders\Listeners;

use App\Mail\OrderReceiptMail;
use App\Models\Order;
use App\Models\OrderReceipt;
use App\Orders\Events\OrderPlaced;
use Illuminate\Contracts\Queue\ShouldQueueAfterCommit;
use Illuminate\Support\Facades\Mail;

final class SendOrderReceipt implements ShouldQueueAfterCommit
{
    public int $tries = 3;

    /** @var array<int, int> */
    public array $backoff = [10, 60, 300];

    public function handle(OrderPlaced $event): void
    {
        $receipt = OrderReceipt::query()->firstOrCreate(['order_id' => $event->orderId]);

        if (! $receipt->wasRecentlyCreated) {
            return;
        }

        $order = Order::query()->with('customer')->findOrFail($event->orderId);

        Mail::to($order->customer->email)->send(new OrderReceiptMail($order));
    }

    public function failed(OrderPlaced $event, \Throwable $exception): void
    {
        report($exception, ['order_id' => $event->orderId]);
    }
}

Zapis do bazy i SMTP nie są jedną atomową transakcją. Proces może paść po wysłaniu maila, lecz przed commitem statusu. Dla ważnej integracji zapisz rekord outbox w transakcji zamówienia, publikuj retryowalnym workerem i użyj klucza idempotencji dostawcy. Duplikat potwierdzenia bywa do przyjęcia; podwójne obciążenie karty nie.

Testuj szew biznesowy

Testuj akcję z fake zdarzenia po utworzeniu factory, które mogą wymagać zdarzeń modelu. Sprawdzaj payload, nie tylko „czy wydarzyło się zdarzenie”. Fake celowo nie uruchamia listenerów, więc test pozostaje testem akcji.

php
<?php

use App\Models\Customer;
use App\Orders\Actions\PlaceOrder;
use App\Orders\Events\OrderPlaced;
use Illuminate\Support\Facades\Event;

test('placing an order dispatches its business fact', function () {
    $customer = Customer::factory()->create();
    Event::fake([OrderPlaced::class]);

    $order = app(PlaceOrder::class)->handle($customer->getKey(), [
        ['product_id' => 10, 'quantity' => 2, 'unit_price_cents' => 1_500],
    ]);

    expect($order->total_cents)->toBe(3_000);

    Event::assertDispatched(OrderPlaced::class, function (OrderPlaced $event) use ($order): bool {
        return $event->orderId === $order->getKey() && $event->totalCents === 3_000;
    });
});

Wywołaj listener dwa razy w osobnym teście. Właściwość jest biznesowa, nie implementacyjna: podwójne dostarczenie ma dać jeden rekord potwierdzenia i jeden mail.

php
<?php

use App\Mail\OrderReceiptMail;
use App\Models\Customer;
use App\Models\Order;
use App\Models\OrderReceipt;
use App\Orders\Events\OrderPlaced;
use App\Orders\Listeners\SendOrderReceipt;
use Illuminate\Support\Facades\Mail;

test('the receipt listener is idempotent across a retry', function () {
    Mail::fake();
    $customer = Customer::factory()->create(['email' => '[email protected]']);
    $order = Order::factory()->for($customer)->create(['total_cents' => 3_000]);
    $event = new OrderPlaced($order->getKey(), $customer->getKey(), 3_000);

    app(SendOrderReceipt::class)->handle($event);
    app(SendOrderReceipt::class)->handle($event);

    expect(OrderReceipt::query()->where('order_id', $order->getKey())->count())->toBe(1);
    Mail::assertSent(OrderReceiptMail::class, 1);
});

Obserwator testuj oddzielnie: zmień produkt i aseruj unieważnienie cache. Nie fake'uj wszystkich zdarzeń przed factory zależną od hooka creating, ponieważ Laravel wyciszy też zdarzenia modelu.

Kiedy nie używać żadnego z nich

Nie emituj OrderCreated z obserwatora i nie nazywaj go OrderPlaced; drafty, importy i retry dowodzą, że te nazwy nie są synonimami. Nie twórz zdarzenia domenowego, gdy jeden natychmiastowy współpracownik i bezpośrednie wywołanie metody opisują pracę czytelniej. Nie używaj listenera zamiast transakcji, gdy kilka zapisów buduje jeden niezmiennik.

Obserwator pasuje do lokalnego skutku cyklu życia. Zdarzenie domenowe pasuje do faktu biznesowego z niezależnymi konsumentami. Akcja i transakcja pasują do niezmiennika. Transactional outbox jest potrzebny, gdy komunikat musi przetrwać lukę między commitem bazy a zewnętrznym publisherem. Architektura nie polega na liczbie mechanizmów, lecz na tym, by każda konsekwencja była widoczna i prawdziwa.

Pytania kontrolne przed dodaniem hooka

Przed dodaniem obserwatora zapytaj, czy każdy przyszły zapis tego modelu powinien wywołać konsekwencję. Jeśli odpowiedź zależy od trasy, aktora, przejścia statusu albo wartości sprzed aktualizacji, obserwator jest prawdopodobnie zbyt szeroki. Warunek umieść w akcji, gdzie intencja jest już jawna. Obserwator zaczynający się od if ($product->wasChanged(...)) nie jest automatycznie błędny, ale wiele takich gałęzi oznacza, że cykl życia trwałości stał się słabym zastępstwem intencji domenowej.

Przed dodaniem zdarzenia nazwij jego konsumentów. Potwierdzenie, rezerwacja zapasu i konwersja analityczna są niezależnymi skutkami; każdy może ewoluować, zawieść albo osobno przejść do kolejki. Gdy istnieje tylko jeden konsument i musi zakończyć się przed odpowiedzią sukcesu, pozostaw go bezpośrednim współpracownikiem akcji. Wartość zdarzenia domenowego to odsprzężony fan-out, a nie obowiązkowa ceremonia dla każdego settera.

Na końcu zdecyduj, które błędy mogą blokować klienta. Transakcja musi się nie powieść dla niepoprawnej linii lub braku zapasu, bo to niezmienniki zamówienia. Tymczasowa niedostępność analityki zwykle nie powinna zatrzymać checkoutu; listener ma ponowić pracę i zgłosić błąd przez failed(). Listener potwierdzenia może użyć backoffu. Ten jawny podział jest lepszy niż wyjątki obserwatora, które nieprzewidywalnie zmieniają zwykłą aktualizację modelu w błąd żądania.

Przy większej skali obserwuj kolejkę, failed jobs i wiek rekordów outbox jako sygnały operacyjne. Architektura zdarzeniowa jest uczciwa tylko wtedy, gdy zespół potrafi sprawdzić, które fakty zostały skonsumowane, które są ponawiane i które wymagają interwencji. To praktyczna różnica między użyteczną granicą a niewidzialną magią w tle.

Powiązane artykuły

Wsparcie istniejącego systemu

Potrzebujesz pomocy z działającą aplikacją?

Pomagam firmom rozwijać działające systemy, porządkować wdrożenia i dodawać nowe funkcje bez dokładania chaosu do projektu.

Komentarze (0)
Zaloguj się, aby dodać komentarz

Musisz być zalogowany, aby dodać komentarz.

Zaloguj się

Potrzebujesz kogoś, kto weźmie odpowiedzialność za kolejny krok?

Porozmawiajmy o Twoim projekcie i określmy zakres, który ma sens dla Twoich celów.