CQRS-Lite w Laravelu: Rozdziel read model i write model bez podatku

9 min. czytania

W swoim użytecznym rdzeniu CQRS to jedna reguła: zapis chroni reguły biznesowe i zmienia stan, a odczyt zwraca kształt potrzebny odbiorcy. Laravel już pozwala na taki podział. Błędem jest zakładanie, że każda komenda potrzebuje busa, każde zapytanie drugiej bazy, a każda aktualizacja strumienia zdarzeń.

W tym artykule użyjemy ekranu operacyjnego zamówień. Pracownik może złożyć zamówienie, a wewnętrzny dashboard wyświetla ostatnie zamówienia, sumy według statusu i nazwę klienta. Ścieżka zapisu potrzebuje transakcji oraz invariantu zapasu. Dashboard nie potrzebuje ani pełnych modeli Order, ani łańcucha relacji ładowanych przez lazy loading. Ta asymetria jest powodem stosowania CQRS-lite.

Zacznij od zwykłej, transakcyjnej akcji zapisu

Write model jest właścicielem faktów, które muszą pozostać prawdziwe: zamówienie nie może zawierać niedostępnego produktu, jego suma jest wyliczana według ceny z momentu zakupu, a całość albo zostaje zapisana, albo nie zostaje zapisana wcale. Umieść te decyzje w jednej akcji o jednej, publicznej intencji.

php
<?php

namespace App\Actions\Orders;

use App\Models\Order;
use App\Models\Product;
use App\Models\User;
use Illuminate\Support\Facades\DB;
use Illuminate\Validation\ValidationException;

final class PlaceOrder
{
    /** @param array<int, array{product_id: int, quantity: int}> $lines */
    public function handle(User $customer, array $lines): Order
    {
        return DB::transaction(function () use ($customer, $lines): Order {
            $order = Order::query()->create([
                'user_id' => $customer->id,
                'status' => 'pending',
                'subtotal_cents' => 0,
            ]);

            $subtotalCents = 0;

            foreach ($lines as $line) {
                $product = Product::query()->lockForUpdate()->findOrFail($line['product_id']);

                if ($product->stock < $line['quantity']) {
                    throw ValidationException::withMessages([
                        'lines' => "{$product->name} is no longer available in that quantity.",
                    ]);
                }

                $product->decrement('stock', $line['quantity']);
                $order->lines()->create([
                    'product_id' => $product->id,
                    'product_name' => $product->name,
                    'unit_price_cents' => $product->price_cents,
                    'quantity' => $line['quantity'],
                ]);
                $subtotalCents += $product->price_cents * $line['quantity'];
            }

            $order->update(['subtotal_cents' => $subtotalCents]);

            return $order->refresh();
        }, attempts: 3);
    }
}

Nie ma tu „write DTO” stworzonego tylko dla ceremonii. Form request może zwalidować wejściowy kształt, a później przekazać zwalidowane lines do tej akcji. Transakcja świadomie obejmuje zapas, pozycje i sumę zamówienia. Przeniesienie kalkulacji do asynchronicznej projekcji sprawiłoby, że skuteczny checkout przez chwilę pokazywałby sumę zero; przeniesienie odejmowania zapasu do listenera znacznie ułatwiłoby overselling przy równoległych żądaniach.

Kontroler pozostaje przewidywalny:

php
public function store(PlaceOrderRequest $request, PlaceOrder $placeOrder): RedirectResponse
{
    $order = $placeOrder->handle($request->user(), $request->validated('lines'));

    return to_route('orders.show', $order)->with('success', 'Order placed.');
}

Komenda to tutaj operacja aplikacyjna, a nie paczka do zainstalowania. Nazwij ją językiem biznesu (PlaceOrder) i testuj tam, gdzie żyją jej invarianty.

Pierwszy read model zrób jako klasę zapytania, nie duplikat domeny

Dashboard odpowiada na inne pytanie: „pokaż ostatnie fakty operacyjne”. Zwrócenie Order::with('user')->latest()->paginate() jest kuszące, ale wiąże ekran z write modelem i często po cichu ładuje pola, których nie potrzebuje. Dedykowane zapytanie ujawnia kształt odpowiedzi, join, sortowanie i paginację.

php
<?php

namespace App\Queries\Orders;

use Illuminate\Contracts\Pagination\CursorPaginator;
use Illuminate\Database\Query\Builder;
use Illuminate\Support\Facades\DB;

final class RecentOrderDashboard
{
    /** @return CursorPaginator<object> */
    public function paginate(?string $cursor, int $perPage = 30): CursorPaginator
    {
        return $this->baseQuery()
            ->orderByDesc('orders.created_at')
            ->orderByDesc('orders.id')
            ->cursorPaginate(perPage: $perPage, cursorName: 'cursor', cursor: $cursor);
    }

    private function baseQuery(): Builder
    {
        return DB::table('orders')
            ->join('users', 'users.id', '=', 'orders.user_id')
            ->select([
                'orders.id', 'orders.status', 'orders.subtotal_cents',
                'orders.created_at', 'users.name as customer_name',
            ]);
    }
}

Sortowanie created_at, id musi być deterministyczne, żeby kursor cokolwiek znaczył. Samo created_at nie wystarcza, gdy dwa wiersze mają ten sam czas; dodanie id zapobiega duplikatom albo pominiętym wynikom, gdy pojawiają się nowe zamówienia. Paginacja kursorowa pasuje do operacyjnego feedu opartego na dopisywaniu. Słabo pasuje do raportu, w którym użytkownik przechodzi wprost na stronę 47 albo sortuje po dziesięciu kolumnach; tam uczciwą odpowiedzią jest zwykłe paginate().

Kontroler mapuje wiersze zapytania do świadomie małej odpowiedzi, zamiast wypuszczać obiekt bazy danych poza aplikację:

php
public function index(Request $request, RecentOrderDashboard $dashboard): InertiaResponse
{
    $orders = $dashboard->paginate($request->string('cursor')->toString());

    return Inertia::render('Orders/Index', [
        'orders' => [
            'data' => collect($orders->items())->map(fn (object $order): array => [
                'id' => $order->id,
                'customerName' => $order->customer_name,
                'status' => $order->status,
                'subtotalCents' => $order->subtotal_cents,
                'placedAt' => $order->created_at,
            ]),
            'nextCursor' => $orders->nextCursor()?->encode(),
        ],
    ]);
}

Dodaj projekcję dopiero po zmierzeniu problemu z zapytaniem

Klasa zapytania jest już read modelem i nie duplikuje danych. Projekcja się opłaca, gdy join i agregacja są stale kosztowne, gdy zewnętrzne źródło potrzebuje lokalnego, wygodnego dla ekranu kształtu albo gdy raport wymaga historycznego snapshotu, którego tabele zapisu nie reprezentują.

Karta dashboardu z dziennym przychodem według statusu nie powinna agregować wszystkich pozycji zamówień przy każdym żądaniu. Tabela projekcji czyni odczyt tanim i przewidywalnym:

php
Schema::create('daily_order_metrics', function (Blueprint $table): void {
    $table->date('metric_date');
    $table->string('status');
    $table->unsignedBigInteger('orders_count')->default(0);
    $table->unsignedBigInteger('revenue_cents')->default(0);
    $table->timestamp('projected_at');
    $table->primary(['metric_date', 'status']);
});

Użyj zdarzenia, aby zlecić aktualizację projekcji, ale dispatch zdarzenia nie jest rozproszoną transakcją. Zamówienie zostaje najpierw zatwierdzone; listener w kolejce może uruchomić się kilka sekund później, ponowić pracę albo zawieść. To jest akceptowalne tylko wtedy, gdy ekran mówi o tym wprost.

php
final class UpdateDailyOrderMetrics implements ShouldQueue
{
    public function handle(OrderPlaced $event): void
    {
        $order = $event->order;

        DB::table('daily_order_metrics')->upsert([
            [
                'metric_date' => $order->created_at->toDateString(),
                'status' => $order->status,
                'orders_count' => 1,
                'revenue_cents' => $order->subtotal_cents,
                'projected_at' => now(),
            ],
        ], ['metric_date', 'status'], [
            'orders_count' => DB::raw('orders_count + 1'),
            'revenue_cents' => DB::raw('revenue_cents + '.(int) $order->subtotal_cents),
            'projected_at' => now(),
        ]);
    }
}

Inkrementacja to surowe wyrażenie, więc odwołuje się wprost do kolumny tabeli. Nie używaj w nim VALUES(revenue_cents): MySQL oznaczył tę funkcję jako przestarzałą od wersji 8.0.20 na rzecz aliasów wiersza (INSERT ... AS new ON DUPLICATE KEY UPDATE col = new.col), a rzutowanie na liczbę całkowitą sprawia, że wyrażenie działa w obu wariantach. Ważniejsze: uczyń listener idempotentnym. Kolejka może ponowić zadanie po zapisaniu wiersza, ale przed potwierdzeniem joba. Zapisuj przetworzone ID zdarzenia albo odbudowuj metrykę dnia z wierszy źródłowych, zamiast ślepo inkrementować i zakładać, że każdy job wykona się dokładnie raz.

Świeżość danych jest częścią kontraktu odczytu

Eventual consistency zmienia to, co użytkownik może bezpiecznie wywnioskować, więc jej miejsce jest w interfejsie, a nie w backlogu. Potwierdzenie checkoutu powinno odczytać nowo zatwierdzone zamówienie z write modelu. Dashboard przychodów może pokazywać Updated at 10:42 i mówić, że odświeża się w ciągu minuty. Kolejka blokad fraudowych nie może swobodnie się opóźniać, jeżeli pracownicy wykorzystują ją do zwalniania wysyłek.

Dodaj do projekcji widoczny watermark:

php
$metrics = DB::table('daily_order_metrics')
    ->selectRaw('SUM(orders_count) as orders_count, SUM(revenue_cents) as revenue_cents')
    ->first();

$freshness = DB::table('daily_order_metrics')->max('projected_at');

Nie opisuj danych jako „live”, gdy worker ma opóźnienie. Alarmuj na wiek kolejki i nieudane joby projekcji. Jeśli konkretne żądanie potrzebuje spójności read-your-writes, zwróć wynik komendy albo dla tego żądania zapytaj główną bazę zapisu. Wysłanie go do repliki zaraz po zapisie po prostu przenosi zaskoczenie na opóźnienie replikacji.

Backfill projekcji traktuj jak operację produktową

Każda projekcja potrzebuje ścieżki naprawy, zanim będzie godna zaufania. Zdarzenia mogły zginąć przed wdrożeniem listenera, jego logika mogła się zmienić albo błąd produkcyjny może wymagać korekty. Komenda odbudowuje ograniczony okres na podstawie autorytatywnych wierszy zamówień i agreguje je w bazie, zamiast liczyć wiersz po wierszu.

php
use App\Models\Order;
use Illuminate\Console\Command;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\DB;

final class RebuildDailyOrderMetrics extends Command
{
    protected $signature = 'metrics:rebuild-orders {--from=} {--to=}';

    public function handle(): int
    {
        $from = Carbon::parse($this->option('from'))->startOfDay();
        $to = Carbon::parse($this->option('to'))->endOfDay();

        DB::transaction(function () use ($from, $to): void {
            DB::table('daily_order_metrics')
                ->whereBetween('metric_date', [$from->toDateString(), $to->toDateString()])
                ->delete();

            $rows = Order::query()
                ->whereBetween('created_at', [$from, $to])
                ->selectRaw('DATE(created_at) as metric_date, status, COUNT(*) as orders_count, SUM(subtotal_cents) as revenue_cents')
                ->groupByRaw('DATE(created_at), status')
                ->toBase()
                ->get()
                ->map(fn (object $row): array => [
                    'metric_date' => $row->metric_date,
                    'status' => $row->status,
                    'orders_count' => $row->orders_count,
                    'revenue_cents' => $row->revenue_cents,
                    'projected_at' => now(),
                ]);

            foreach ($rows->chunk(500) as $chunk) {
                DB::table('daily_order_metrics')->insert($chunk->values()->all());
            }
        });

        return self::SUCCESS;
    }
}

Ta poglądowa wersja nie jest bezpieczna do uruchomienia obok aktywnego listenera inkrementalnego: może wystąpić race condition i podwójne naliczanie. W produkcji zatrzymaj odpowiedniego konsumenta, odbuduj dane do wersjonowanej tabeli, zweryfikuj sumy względem orders, a później atomowo przełącz czytnik; albo zapisz high-water mark i odtwórz zdarzenia po tym punkcie. Backfill to praca operacyjna, dlatego udokumentuj blokadę, czas trwania, rollback i właściciela.

Osobno testuj komendy, zapytania i naprawę projekcji

Testuj kontrakty, a nie to, czy Laravel potrafi rozwiązać klasę z kontenera.

php
it('does not oversell a product', function (): void {
    $product = Product::factory()->create(['stock' => 1, 'price_cents' => 2_500]);
    $customer = User::factory()->create();

    app(PlaceOrder::class)->handle($customer, [['product_id' => $product->id, 'quantity' => 1]]);

    expect(fn () => app(PlaceOrder::class)->handle($customer, [[
        'product_id' => $product->id, 'quantity' => 1,
    ]]))->toThrow(ValidationException::class);

    expect($product->fresh()->stock)->toBe(0);
});

it('uses an id tie breaker for a stable dashboard cursor', function (): void {
    Order::factory()->count(2)->create(['created_at' => now(), 'user_id' => User::factory()]);

    $firstPage = app(RecentOrderDashboard::class)->paginate(null, 1);
    $secondPage = app(RecentOrderDashboard::class)->paginate($firstPage->nextCursor()?->encode(), 1);

    expect($firstPage->items()[0]->id)->not->toBe($secondPage->items()[0]->id);
});

Dodaj test projekcji z duplikatem zdarzenia i sprawdź, że jego suma nie rośnie drugi raz. Dodaj test integracyjny odbudowy na stałym zestawie danych. Takie testy zmuszają zespół do wyboru strategii idempotencji, zamiast odkrywania jej podczas burzy ponowień.

Kiedy CQRS-lite jest złym narzędziem

Nie rozdzielaj prostego formularza CRUD tylko dlatego, że „komendy i zapytania” brzmią architektonicznie. Jeśli jeden kontroler waliduje profil, aktualizuje jeden wiersz i renderuje go z powrotem, zwykły model Eloquent jest czytelny i szybki. Nie buduj projekcji, gdy indeks, eager-loaded relacja, ograniczony select albo cache’owana agregacja rozwiązują zmierzony problem. Nie wprowadzaj read repliki po to, by uleczyć zapytanie N+1.

Event sourcing to osobna decyzja: trzyma historię domain eventów jako źródło prawdy i wymaga replay, ewolucji schematów oraz silniejszej dyscypliny operacyjnej. CQRS-lite może używać zwykłych relacyjnych tabel zapisu i kilku celowych ścieżek odczytu. Widok bazy danych to kolejna droga pośrednia, gdy pochodny kształt jest czystym SQL-em i zawsze musi być aktualny; usuwa nieświeżość, ale nie koszt obliczeń.

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.