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
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
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
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
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
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
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
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
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;EmptyShippingAddressuż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.