Komendy, akcje i Laravel Bus: Jeden use case na klasę

9 min. czytania

„Każdy use case za command busem” brzmi zdyscyplinowanie, dopóki zmiana jednego modelu nie wymaga komendy, handlera, mapowania i wywołania dispatch. Druga skrajność też nie jest lepsza: kontroler, który waliduje dane, zmienia zamówienie, zakłada blokadę, kolejkuje mail i wywołuje dostawcę, nie ma żadnej użytecznej granicy.

Laravel oferuje akcje, joby, eventy i dispatcher Bus. Cele tych mechanizmów się nakładają, ale nie znaczą one tego samego. W tym artykule rezerwujemy ograniczony stan magazynowy, aby wyznaczyć pragmatyczną granicę: zacznij od akcji dla pracy potrzebnej w tym procesie; dodaj komendę i handler, gdy wartość ma semantyka dispatchingu; użyj kolejki, gdy praca może zajść później; publikuj event tylko jako fakt, na który inne części aplikacji mogą zareagować.

Zacznij od najmniejszej użytecznej granicy

Wywoływalna akcja jest nazwaną operacją aplikacyjną. Przyjmuje dane domenowe, koordynuje zależności i zwraca znaczący wynik. Nie jest ani metodą modelu Eloquent, ani ogólnym śmietnikiem pod nazwą OrderService.

Administrator rezerwujący stan potrzebuje odpowiedzi, zanim przeglądarka wykona przekierowanie. Synchroniczna akcja jest więc naturalnym wyborem domyślnym.

php
<?php

declare(strict_types=1);

namespace App\Orders\Actions;

use App\Models\Order;
use App\Models\Product;
use Illuminate\Support\Facades\DB;

final class ReserveOrderInventory
{
    public function __invoke(Order $order): void
    {
        DB::transaction(function () use ($order): void {
            $order = Order::query()->lockForUpdate()->findOrFail($order->id);

            if ($order->inventory_reserved_at !== null) {
                return;
            }

            foreach ($order->items as $item) {
                $product = Product::query()->lockForUpdate()->findOrFail($item->product_id);

                if ($product->stock < $item->quantity) {
                    throw new InsufficientInventory($product);
                }

                $product->decrement('stock', $item->quantity);
            }

            $order->forceFill(['inventory_reserved_at' => now()])->save();
        }, attempts: 3);
    }
}

Transakcja i blokady są ochroną biznesową, nie funkcjami Busa. Drugi request może przyjść przed commitem pierwszego; bez blokady wiersza oba requesty mogą zobaczyć tę samą liczbę sztuk. Akcja jest też lokalnie idempotentna: kolejne wywołanie widzi inventory_reserved_at i nie może zmniejszyć stanu drugi raz.

Kontroler posiada odpowiedzialność za HTTP — autoryzację oraz walidację — a nie za reguły rezerwacji.

php
<?php

declare(strict_types=1);

namespace App\Http\Controllers\Admin;

use App\Http\Controllers\Controller;
use App\Models\Order;
use App\Orders\Actions\ReserveOrderInventory;
use Illuminate\Http\RedirectResponse;

final class ReserveOrderInventoryController extends Controller
{
    public function __invoke(Order $order, ReserveOrderInventory $reserve): RedirectResponse
    {
        $this->authorize('reserveInventory', $order);

        $reserve($order);

        return to_route('admin.orders.show', $order)->with('success', 'Inventory reserved.');
    }
}

To już jest jeden use case na klasę. Nie rób z niego komendy wyłącznie dlatego, że ma nazwę.

Komenda opisuje intencję, handler ją wykonuje

Granica zmienia się wtedy, gdy ten sam proces może uruchomić kontroler, zaplanowana komenda i integracja, a wspólne polityki dispatchingu są użyteczne. Komenda jest małym serializowalnym komunikatem: ID, skalary i klucz korelacji — nie Request, closure ani załadowany graf relacji.

php
<?php

declare(strict_types=1);

namespace App\Orders\Commands;

final readonly class ReserveInventory
{
    public function __construct(
        public int $orderId,
        public string $reservationKey,
    ) {
    }
}

Handler pobiera aktualny stan w chwili wykonania i deleguje główną operację domenową do akcji. Ponowne użycie akcji zapobiega temu, by przyszła ścieżka synchroniczna zyskała trochę inną implementację rezerwacji.

php
<?php

declare(strict_types=1);

namespace App\Orders\CommandHandlers;

use App\Models\Order;
use App\Orders\Actions\ReserveOrderInventory;
use App\Orders\Commands\ReserveInventory;

final class ReserveInventoryHandler
{
    public function __construct(private ReserveOrderInventory $reserveOrderInventory)
    {
    }

    public function handle(ReserveInventory $command): void
    {
        $order = Order::query()->findOrFail($command->orderId);

        ($this->reserveOrderInventory)($order);
    }
}

Mapuj komendę tylko raz. Mapowanie ma wartość, ponieważ komunikat celowo nie zna implementacji; nie jest ceremonią dla klasy, która mogłaby po prostu wywołać akcję.

php
<?php

declare(strict_types=1);

namespace App\Providers;

use App\Orders\CommandHandlers\ReserveInventoryHandler;
use App\Orders\Commands\ReserveInventory;
use Illuminate\Contracts\Bus\Dispatcher;
use Illuminate\Support\ServiceProvider;

final class CommandBusServiceProvider extends ServiceProvider
{
    public function boot(Dispatcher $bus): void
    {
        $bus->map([
            ReserveInventory::class => ReserveInventoryHandler::class,
        ]);
    }
}

Wywołujący wyraża intencję, zamiast wybierać implementację:

php
$bus->dispatch(new ReserveInventory(
    orderId: $order->id,
    reservationKey: (string) $request->header('Idempotency-Key'),
));

dispatchSync() pasuje tam, gdzie wywołujący potrzebuje wyniku handlera od razu. dispatch() nie jest automatycznie asynchroniczny: staje się kolejką dopiero, gdy wysyłany komunikat jest queueable. Sama fasada Bus nie oznacza, że odpowiedź HTTP została odłączona od pracy.

Middleware służy polityce przekrojowej

Bus może przesłać zmapowane komendy przez middleware. Jest to dobre miejsce na stabilną politykę: dodanie kontekstu korelacji, audyt zaakceptowanych komend lub odrzucanie zapisów podczas maintenance. To słabe miejsce dla reguł zamówienia, bo ukryty middleware utrudnia wyjaśnienie nieudanej rezerwacji.

php
<?php

declare(strict_types=1);

namespace App\Orders\Bus;

use Closure;
use Illuminate\Log\LogManager;

final class LogCommand
{
    public function __construct(private LogManager $log)
    {
    }

    public function handle(object $command, Closure $next): mixed
    {
        $this->log->info('Dispatching command', ['command' => $command::class]);

        return $next($command);
    }
}
php
$bus->pipeThrough([
    \App\Orders\Bus\LogCommand::class,
]);

Pipeline utrzymuj krótki i obserwowalny. Taki, który po cichu otwiera transakcje, autoryzuje użytkowników i łapie wszystkie wyjątki, dubluje zwykłe granice Laravela i zaskakuje w komendach CLI oraz workerach.

Kolejkuj wolną lub ponawialną pracę świadomie

Rezerwacja z reguły pozostaje synchroniczna. Powiadomienie API magazynu nie. Uczyń granicę kolejki jawną jobem, który przechowuje wyłącznie ID zamówienia, dobrze znosi retry i zostaje zwolniony po commicie transakcji rezerwacji.

php
<?php

declare(strict_types=1);

namespace App\Orders\Jobs;

use App\Models\Order;
use App\Services\WarehouseClient;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldBeUnique;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\Middleware\RateLimited;
use Illuminate\Queue\SerializesModels;
use Throwable;

final class NotifyWarehouseOfReservation implements ShouldQueue, ShouldBeUnique
{
    use Dispatchable;
    use InteractsWithQueue;
    use Queueable;
    use SerializesModels;

    public int $tries = 5;

    public function __construct(public int $orderId)
    {
    }

    public function uniqueId(): string
    {
        return 'warehouse-reservation:'.$this->orderId;
    }

    public function backoff(): array
    {
        return [5, 30, 120];
    }

    public function middleware(): array
    {
        return [new RateLimited('warehouse-api')];
    }

    public function handle(WarehouseClient $warehouse): void
    {
        $order = Order::query()->findOrFail($this->orderId);

        $warehouse->reserve(
            externalReference: (string) $order->id,
            items: $order->items->map(fn ($item): array => [
                'sku' => $item->product->sku,
                'quantity' => $item->quantity,
            ])->all(),
        );
    }

    public function failed(Throwable $exception): void
    {
        report($exception);
    }
}

Wyślij job z akcji dopiero po bezpiecznym commicie lokalnego stanu:

php
NotifyWarehouseOfReservation::dispatch($order->id)->afterCommit();

Unikalność ogranicza zduplikowaną pracę w kolejce; nie uczyni API HTTP dokładnie jednorazowym. Worker może paść po akceptacji requestu przez dostawcę, lecz przed potwierdzeniem joba. Endpoint magazynu potrzebuje własnego klucza idempotencji, a aplikacja powinna utrwalać stan dostarczenia tam, gdzie wynik ma znaczenie. Ustaw też retry_after połączenia kolejki wyżej niż timeout joba, inaczej wolny worker może być przetworzony dwa razy.

Testuj granicę, która ma znaczenie

Testuj akcję dla zachowania transakcyjnego. Dispatching testuj osobno: Bus::fake() potwierdza intencję, a nie działanie handlera. Te skupione testy Pest wyrażają obie prawdy.

php
<?php

use App\Models\Order;
use App\Models\Product;
use App\Orders\Actions\ReserveOrderInventory;
use App\Orders\Commands\ReserveInventory;
use Illuminate\Support\Facades\Bus;

it('reserves inventory only once for an order', function () {
    $product = Product::factory()->create(['stock' => 10]);
    $order = Order::factory()->hasItems(1, [
        'product_id' => $product->id,
        'quantity' => 3,
    ])->create();

    $reserve = app(ReserveOrderInventory::class);

    $reserve($order);
    $reserve($order->fresh());

    expect($product->fresh()->stock)->toBe(7)
        ->and($order->fresh()->inventory_reserved_at)->not->toBeNull();
});

it('dispatches a reservation command', function () {
    Bus::fake();

    Bus::dispatch(new ReserveInventory(orderId: 42, reservationKey: 'checkout-42'));

    Bus::assertDispatched(ReserveInventory::class, fn (ReserveInventory $command): bool =>
        $command->orderId === 42 && $command->reservationKey === 'checkout-42'
    );
});

Dla handlera wywołaj handle() z factory albo rozwiąż dispatcher w teście integracyjnym bez fake'a. Dla joba magazynowego użyj fake'a klienta HTTP i sprawdź nagłówek idempotencji; fake kolejki nie odkryje źle zbudowanego requestu zdalnego.

Awaria, której abstrakcja nie usuwa

Obiekt komendy nie jest granicą transakcji. Jeżeli handler zmienia zamówienie, zapisuje rekord outboxa, a następnie prosi zdalną płatność, żaden podział na klasy nie sprawi, że trzy systemy zatwierdzą się razem. Najpierw zakończ lokalną transakcję. Następnie dostarcz zewnętrzny efekt asynchronicznie, przekazując klucz idempotencji rozumiany przez odbiorcę. W biznesowo krytycznym przepływie tabela outbox daje workerowi trwały zapis pracy, która ma finalnie opuścić bazę.

Nie umieszczaj uwierzytelnionego obiektu użytkownika w komendzie z nadzieją, że kolejka odtworzy jego uprawnienia jutro. Autoryzuj w punkcie wejścia, gdy kontekst requestu istnieje; zapisuj tylko ID aktora, jeśli ślad audytowy go potrzebuje; a gdy handler może być też wywołany przez niezaufaną integrację, zastosuj w nim autoryzację domenową. Nie polegaj również na instancji modelu przechwyconej przy dispatchu: rekord może zostać usunięty, zmieniony lub przestać spełniać warunki operacji, zanim worker do niego dotrze.

Retry wymagają klasyfikacji. 429 albo timeout połączenia może zasługiwać na backoff; nieprawidłowe SKU magazynu powinno szybko zakończyć job błędem i utworzyć alert możliwy do obsługi. Łapanie każdego Throwable i zwracanie sukcesu zmienia widoczny failed job w utraconą pracę. Hook failed() służy do raportowania albo odzyskania stanu, nie do udawania, że efekt uboczny nastąpił. Ustal timeout, liczbę prób, backoff i priorytet kolejki jako świadome decyzje operacyjne, a potem obserwuj liczbę i wiek failed jobów w Horizon lub dashboardzie.

Na końcu: nie stosuj command busa dla zamaskowanego odczytu. Handler dla GetOrderSummaryCommand często jest mniej czytelny niż query object albo metoda repozytorium, która zwraca dane bezpośrednio do wywołującego. Komendy zmieniają stan lub żądają procesu; query odpowiadają na pytania. Ten podział zachowuje czytelne call site'y i nie pozwala, by ogólny bus stał się jedynym mechanizmem dependency injection w aplikacji.

Akcje, komendy, joby i eventy nie są wymienne

Użyj akcji dla operacji, której znany wywołujący potrzebuje teraz. Użyj komendy i handlera, gdy jej komunikat oraz pipeline dispatchingu tworzą użyteczną granicę. Użyj joba dla pracy, która może poczekać, być ponowiona lub uruchomiona na innym workerze. Użyj eventu po fakcie — InventoryReserved — gdy niezależne listenery mogą zareagować. Nie używaj eventu jako prośby o jedną obowiązkową operację: bez listenera cicho znika.

Większość use case'ów CRUD potrzebuje tylko akcji i Form Request. Osobny handler komendy dla ChangeProfilePhoto daje mało, gdy jest wywołany raz, zwraca od razu i nie ma wspólnej polityki. Nie kolejkuj też zapisu do bazy wyłącznie po to, by był „asynchroniczny”, jeżeli następna strona potrzebuje wyniku.

Wzorzec zasługuje na koszt dopiero wtedy, gdy semantyka wykonania naprawdę jest zmienna lub przekrojowa. Nazwij use case, utrzymuj komunikat mały, czyń efekty uboczne idempotentnymi i wybierz najprostszą granicę, która zachowuje te prawdy.

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.