Wzorzec Null Object w Laravelu: Zastąp defensywne łańcuchy null

7 min. czytania

Operator nullsafe świetnie sprawdza się przy jednej opcjonalnej relacji. Nie jest jednak decyzją architektoniczną o tym, co oznacza brak wartości. Gdy ten sam łańcuch ?-> występuje w checkoucie, fakturach, zasobach API i testach, każdy wywołujący odtwarza regułę biznesową: co dzieje się, gdy klient nie ma rabatu? Ta reguła zasługuje na nazwę, jedno miejsce implementacji i testy.

Null Object reprezentuje prawidłową nieobecność obiektem spełniającym ten sam kontrakt co prawdziwa implementacja. Wywołujący dostaje Discount, nigdy ?Discount, i uruchamia go bez warunku od szczegółów persistence. Zbudujemy małą granicę rabatów B2B oraz — co ważniejsze — określimy sytuacje, w których obiekt neutralny niebezpiecznie ukryłby błąd.

Powtarzana niepewność jest sygnałem ostrzegawczym

Ta pierwsza wersja jest rozsądna dla jednego miejsca wywołania:

php
<?php

declare(strict_types=1);

namespace App\Billing;

use App\Models\Customer;

final class CheckoutTotal
{
    public function total(int $subtotalCents, Customer $customer): int
    {
        $discountCents = $customer->activePromotion?->discountFor($subtotalCents) ?? 0;

        return max(0, $subtotalCents - $discountCents);
    }
}

Koszt pojawia się, gdy „brak promocji” rozlewa się po kodzie. Jeden wywołujący używa zera, drugi etykiety, a trzeci nie odróżnia braku kwalifikacji od niewczytanej relacji. Przyszły próg minimalny, ślad audytowy albo notatka na fakturze wymuszają znalezienie każdego warunku. Pytaniem nie jest to, czy null jest możliwy, lecz czy brak kwalifikującego rabatu jest normalnym, użytecznym stanem biznesowym. Jest. Awaria bazy, brak wymaganej umowy albo niepoprawna suma częściowa nie są takim stanem i muszą pozostać błędami.

Opisz jedną operację biznesową

Pieniądze przechowujemy w całkowitych centach i zwracamy zarówno kwotę, jak i wyjaśnienie. Etykietę można później utrwalić z zamówieniem, dzięki czemu stare faktury nie zależą od nazwy promocji z przyszłego miesiąca.

php
<?php

declare(strict_types=1);

namespace App\Billing;

final readonly class DiscountResult
{
    public function __construct(
        public int $amountCents,
        public string $label,
    ) {
        if ($amountCents < 0) {
            throw new \InvalidArgumentException('A discount cannot be negative.');
        }
    }
}
php
<?php

declare(strict_types=1);

namespace App\Billing;

interface Discount
{
    public function calculate(int $subtotalCents): DiscountResult;
}

Kontrakt nie mówi o HTTP, Eloquent ani bieżącym żądaniu. Serwis zamówienia zależy od polityki, a kontroler, job lub resolver wybiera implementację. Ta mała granica pozwala testom korzystać z tej samej operacji publicznej co kod produkcyjny.

Neutralne zachowanie ma być jawne

NoDiscount nie jest udawaną promocją. To prawidłowa polityka dla klienta, który nie spełnia warunków. Nadal musi egzekwować kontrakt: neutralność nie oznacza cichej akceptacji błędnych danych.

php
<?php

declare(strict_types=1);

namespace App\Billing;

final readonly class NoDiscount implements Discount
{
    public function calculate(int $subtotalCents): DiscountResult
    {
        if ($subtotalCents < 0) {
            throw new \InvalidArgumentException('Subtotal cannot be negative.');
        }

        return new DiscountResult(
            amountCents: 0,
            label: 'No eligible discount',
        );
    }
}

Obserwowalna etykieta ma znaczenie. Samo 0 może oznaczać brak oferty, błąd zaokrąglenia albo nieudaną próbę odczytu. Zamówienie zapisuje ten poprawny wynik bez udawania, że rabat rzeczywiście został zastosowany.

Prawdziwa polityka używa tego samego wejścia i wyniku. Punkty bazowe eliminują zaokrąglenia floatów: 1_500 oznacza dokładnie piętnaście procent.

php
<?php

declare(strict_types=1);

namespace App\Billing;

final readonly class PercentageDiscount implements Discount
{
    public function __construct(private int $basisPoints)
    {
        if ($basisPoints < 0 || $basisPoints > 10_000) {
            throw new \InvalidArgumentException('Discount must be between 0 and 10,000 basis points.');
        }
    }

    public function calculate(int $subtotalCents): DiscountResult
    {
        if ($subtotalCents < 0) {
            throw new \InvalidArgumentException('Subtotal cannot be negative.');
        }

        return new DiscountResult(
            amountCents: intdiv($subtotalCents * $this->basisPoints, 10_000),
            label: sprintf('%s%% partner discount', $this->basisPoints / 100),
        );
    }
}

Stawka może później pochodzić z modelu promocji, a etykieta z klucza tłumaczeń. To decyzje aplikacji, nie powód, aby czynić kontrakt nullable.

Rozwiąż nullable relację na jednej granicy

Resolver celowo jest jedynym miejscem, które wie, że promocja może nie istnieć. Konwertuje nullable persistence w nienullową zależność, zanim zobaczy ją checkout. Binding w service providerze tu nie wystarcza, ponieważ właściwa polityka zmienia się dla klienta w czasie działania.

php
<?php

declare(strict_types=1);

namespace App\Billing;

use App\Models\Customer;

final readonly class DiscountResolver
{
    public function resolve(Customer $customer): Discount
    {
        $promotion = $customer->activePromotion;

        if ($promotion === null) {
            return new NoDiscount();
        }

        return new PercentageDiscount($promotion->discount_basis_points);
    }
}

Przy liście wielu klientów eager loaduj activePromotion. Null Object usuwa warunek rabatu; nie naprawia zapytania N+1. Nie łap tu wyjątku repozytorium i nie zwracaj NoDiscount: „brak kwalifikacji” oraz „timeout odczytu” wymagają innej reakcji operacyjnej.

Akcja opisuje teraz przepływ biznesowy, nie nullable mechanics:

php
<?php

declare(strict_types=1);

namespace App\Billing;

use App\Models\Customer;

final readonly class PlaceOrder
{
    public function __construct(private DiscountResolver $discountResolver) {}

    public function totalFor(Customer $customer, int $subtotalCents): int
    {
        $result = $this->discountResolver->resolve($customer)->calculate($subtotalCents);

        return $subtotalCents - $result->amountCents;
    }
}

Utrwal amountCents i label na zamówieniu. Ponowne wyliczenie historycznego zamówienia według aktualnej promocji uzależnia stare zobowiązanie od dzisiejszej konfiguracji.

Testuj kontrakt i ścieżkę neutralną

Najważniejsze są testy zachowania, nie sprawdzenie istnienia klasy. Te testy Pest dowodzą dokładnych centów i czynią neutralny wynik widocznym.

php
<?php

use App\Billing\NoDiscount;
use App\Billing\PercentageDiscount;

it('returns a visible zero discount for an eligible absence', function () {
    $result = (new NoDiscount())->calculate(12_500);

    expect($result->amountCents)->toBe(0)
        ->and($result->label)->toBe('No eligible discount');
});

it('calculates a percentage discount in integer cents', function () {
    $result = (new PercentageDiscount(1_500))->calculate(12_500);

    expect($result->amountCents)->toBe(1_875)
        ->and($result->label)->toBe('15% partner discount');
});

Dodaj feature test resolvera, gdy kwalifikacja jest zapisana w Eloquent. Powinien sprawdzać, że klient bez aktywnej promocji otrzymuje NoDiscount, a nie tylko że checkout nie zakończył się awarią. Wykryje to przypadkową zmianę znaczenia brakującej relacji.

Pułapki: neutralne nie znaczy udane

Wzorzec jest bezpieczny wyłącznie wtedy, gdy neutralny wynik jest prawidłowy w tym samym kontrakcie. NoDiscount jest bezpieczny, bo pobranie pełnej ceny jest legalne i widoczne. Poniższe przykłady nie są bezpiecznymi Null Objectami:

  • NullPaymentGateway, który zgłasza sukces po nieudanym obciążeniu;
  • AnonymousUser, który przechodzi kontrolę dostępu;
  • EmptyShippingAddress używany do zakupu etykiety dostawy;
  • NullInventoryReservation, który przepuszcza zamówienie ponad stan.

Każdy oznacza błąd, brak wymaganej wartości albo decyzję bezpieczeństwa. Rzuć wyjątek domenowy, zwróć błąd walidacji lub wymodeluj stan oczekujący. Null Object może pominąć opcjonalny efekt uboczny, np. analitykę w skupionym teście, ale nigdy nie może wymyślać sukcesu.

Kiedy go nie używać

Użyj ?-> dla naprawdę ubocznej opcjonalnej wartości z jednym lokalnym konsumentem: dodanie pseudonimu do notyfikacji nie uzasadnia klasy. Użyj match dla dwóch stabilnych, prostych ścieżek bez niezależnych zależności. Gdy algorytmy rozwijają się osobno, lepszą granicą będzie Strategy; Null Object może być jedną ze strategii dla prawidłowego przypadku „brak”.

Użyj typu option/wynikowego, gdy wywołujący musi świadomie obsłużyć nieobecność. Brak dokumentu compliance powinien wymusić wybór uploadu albo pobrania; pusty dokument ukryłby ważną decyzję. Reguła praktyczna jest prosta: wybieraj Null Object tylko wtedy, gdy brak jest oczekiwany, neutralny i bezpieczny. Rozwiąż go raz na granicy, testuj równie rygorystycznie jak aktywne zachowanie i pozostaw prawdziwe błędy widoczne.

Oddziel wybór od tworzenia obiektu

Umieszczenie warunku null bezpośrednio w kontrolerze działa do chwili, gdy zamówienia zaczną przychodzić również z importów, odnowień subskrypcji i integracji kolejki. Resolver jest jednym miejscem polityki: później może sprawdzić datę ważności, segment klienta, minimalną sumę częściową lub wykluczone produkty bez zmiany każdego wywołującego zamówienie.

Zachowaj jego mały zakres. Resolver wybiera implementację, PercentageDiscount liczy, a model lub repozytorium odpowiada na pytania o trwałość. Jeśli aktywna reguła potrzebuje wynegocjowanych stawek, jurysdykcji podatkowej albo zewnętrznej kampanii, daj tej implementacji jawną zależność. Nie dodawaj nullable opcjonalnych argumentów do Discount::calculate() tylko dlatego, że jeden algorytm potrzebuje więcej informacji niż inny.

Wyjaśnia to również użycie kontenera Laravel. Może on wstrzyknąć DiscountResolver do PlaceOrder, bo jego zależności są stabilne. Nie może globalnie zbindować końcowego Discount bez utraty kontekstu per klient. Contextual binding jest dobry, gdy znany konsument zawsze wymaga jednej polityki; kwalifikacja w czasie działania należy do resolvera.

Na koniec zdecyduj, czy promocja ze stawką zero różni się od braku promocji. Obie dają zero centów, ale analityka i komunikacja z klientem mogą wymagać rozróżnienia. Gdy ma ono znaczenie, wymodeluj PercentageDiscount(0) albo osobną politykę; nie zlewaj istotnych stanów, bo ich arytmetyka jest taka sama.

Wróć do tej granicy, gdy pojawi się nowy wywołujący. Jeżeli musi pytać przez instanceof NoDiscount, w kontrakcie brakuje informacji albo konsument próbuje odzyskać szczegół persistence. Dodaj znaczące pole wyniku lub przenieś decyzję do resolvera. Konsumenci powinni interesować się wyliczonym wynikiem i jego udokumentowaną etykietą, nigdy konkretną klasą, która je stworzyła.

Ta dyscyplina utrzymuje granicę trwałą mimo zmian polityki.

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.