Wzorce Factory i Builder w Laravelu: Twórz poprawne Value Objecty

8 min. czytania

Fabryki Eloquent tworzą modele testowe. Wzorzec Factory ma szersze zastosowanie: daje złożonej regule tworzenia jeden dom. Builder pomaga, gdy wywołujący uzupełniają opcjonalne pola stopniowo. Żaden z tych wzorców nie jest powodem, by opakować każdy konstruktor kolejną klasą. Mają sens wtedy, gdy obiekt ma reguły, które muszą być spełnione, zanim trafi do pozostałej części aplikacji.

Etykiety wysyłkowe dobrze pokazują tę różnicę. Kontroler otrzymuje z żądania HTTP napisy. Klient przewoźnika potrzebuje adresu, obsługiwanego kraju, dodatniej wagi i usługi wysyłkowej. Jeżeli te dane podróżują przez aplikację jako tablica, każdy odbiorca musi pamiętać nazwy kluczy, wartości domyślne i walidację. Ktoś w końcu o czymś zapomni.

Tablice są słabym kontraktem

To znajomy punkt wyjścia:

php
public function store(Request $request, CarrierClient $carrier): RedirectResponse
{
    $label = $carrier->createLabel($request->all());

    return to_route('shipments.show', $label->shipmentId());
}

Kod ukrywa kilka decyzji. Czy country jest dwuliterowym kodem ISO? Czy weight_grams może mieć wartość zero? Co się dzieje, gdy nie ma service? Klient przewoźnika albo powtarza walidację HTTP, albo przyjmuje niepoprawne dane, albo po cichu wybiera domyślne wartości. Żadna z tych decyzji nie jest widoczna na granicy systemu.

Celem nie jest uniemożliwienie wysłania niepoprawnych danych. Form Request Laravel nadal wykonuje to zadanie i zwraca przydatne błędy dla konkretnych pól. Chodzi o to, aby nie dało się przekazać do przypadku użycia niepoprawnego żądania domenowego. Po skonstruowaniu ShippingLabelRequest powinien być bezpieczny dla akcji, joba, komendy lub klienta API.

Umieść lokalne niezmienniki w value objectach

Zacznij od wartości, które znaczą coś poza jedną metodą. Surowy napis nie mówi czytelnikowi, czy jest kodem kraju, adresem e-mail czy nazwą usługi. Te obiekty mówią, a ich nazwane konstruktory są jedynym miejscem egzekwującym lokalne niezmienniki.

php
<?php

declare(strict_types=1);

namespace App\Shipping;

use DomainException;

final readonly class CountryCode
{
    private const array Supported = ['DE', 'GB', 'PL'];

    private function __construct(public string $value) {}

    public static function from(string $value): self
    {
        $normalized = strtoupper(trim($value));

        if (! in_array($normalized, self::Supported, true)) {
            throw new DomainException("Unsupported destination country [{$normalized}].");
        }

        return new self($normalized);
    }
}

final readonly class Weight
{
    private function __construct(public int $grams) {}

    public static function fromGrams(int $grams): self
    {
        if ($grams < 1 || $grams > 30_000) {
            throw new DomainException('Shipment weight must be between 1 and 30000 grams.');
        }

        return new self($grams);
    }
}

readonly chroni przed przypadkową mutacją po utworzeniu. Nie czyni jednak obiektu poprawnym samo z siebie: tę pracę wykonują nazwane konstruktory. Nie zamieniaj w value object każdej kolumny bazy. CountryCode zwraca koszt, bo obsługiwane kierunki i normalizacja mają znaczenie w kilku miejscach. Jednorazowe, opcjonalne pole notatki zwykle nie.

Złożone żądanie może teraz deklarować własne reguły, nie wiedząc nic o HTTP:

php
<?php

declare(strict_types=1);

namespace App\Shipping;

use DomainException;

final readonly class ShippingAddress
{
    public function __construct(
        public string $recipient,
        public string $lineOne,
        public string $postalCode,
        public string $city,
        public CountryCode $country,
    ) {
        if (mb_strlen(trim($recipient)) < 2) {
            throw new DomainException('A recipient name is required.');
        }
    }
}

final readonly class ShippingLabelRequest
{
    public function __construct(
        public ShippingAddress $address,
        public Weight $weight,
        public string $service,
        public ?string $reference = null,
    ) {
        if (! in_array($service, ['economy', 'express'], true)) {
            throw new DomainException("Unsupported shipping service [{$service}].");
        }
    }
}

Ta granica jest celowa: odrzuca usługę, której biznes nie potrafi zrealizować, ale nie pyta bazy, czy klient ma kredyt. Reguły konstrukcji są deterministycznymi faktami o tej wartości. Polityka zależna od czasu lub trwałego stanu powinna być w akcji albo serwisie domenowym, gdzie zależności są jawne i testowalne.

Fabryka tłumaczy jedną granicę na drugą

Odpowiedzialnością fabryki jest tłumaczenie, nie drugi kontroler. Przyjmuje już zwalidowany kształt HTTP i tworzy obiekty domenowe. To dobre miejsce na normalizację, na przykład przycięcie referencji, mapowanie nazwy pola formularza na nazwę domenową lub zmianę liczby całkowitej w Weight.

php
<?php

declare(strict_types=1);

namespace App\Shipping;

final class ShippingLabelRequestFactory
{
    /**
     * @param array{
     *     recipient: string,
     *     address_line_1: string,
     *     postal_code: string,
     *     city: string,
     *     country: string,
     *     weight_grams: int,
     *     service: string,
     *     reference?: string|null
     * } $input
     */
    public function fromValidated(array $input): ShippingLabelRequest
    {
        return new ShippingLabelRequest(
            address: new ShippingAddress(
                recipient: trim($input['recipient']),
                lineOne: trim($input['address_line_1']),
                postalCode: trim($input['postal_code']),
                city: trim($input['city']),
                country: CountryCode::from($input['country']),
            ),
            weight: Weight::fromGrams($input['weight_grams']),
            service: $input['service'],
            reference: filled($input['reference'] ?? null) ? trim($input['reference']) : null,
        );
    }
}

Nakładanie się z walidacją żądania jest zamierzone. Form Request chroni publiczny interfejs i raportuje błędy w standardowym formacie Laravel. Value objecty chronią każdego wywołującego, także job w kolejce, który nigdy nie przeszedł przez to żądanie. Nie pozwól jednak, aby fromValidated() stało się zgodą na przekazywanie dalej niezaufanych tablic; jego nazwa dokumentuje warunek wstępny.

Tak wygląda granica Laravel i następujący po niej przypadek użycia:

php
<?php

declare(strict_types=1);

namespace App\Http\Controllers;

use App\Actions\CreateShippingLabel;
use App\Http\Requests\StoreShippingLabelRequest;
use App\Shipping\ShippingLabelRequestFactory;
use Illuminate\Http\RedirectResponse;

final class ShippingLabelController
{
    public function store(
        StoreShippingLabelRequest $request,
        ShippingLabelRequestFactory $factory,
        CreateShippingLabel $createShippingLabel,
    ): RedirectResponse {
        $label = $createShippingLabel->handle($factory->fromValidated($request->validated()));

        return to_route('shipments.show', $label->shipmentId());
    }
}
php
<?php

declare(strict_types=1);

namespace App\Http\Requests;

use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;

final class StoreShippingLabelRequest extends FormRequest
{
    public function rules(): array
    {
        return [
            'recipient' => ['required', 'string', 'max:120'],
            'address_line_1' => ['required', 'string', 'max:120'],
            'postal_code' => ['required', 'string', 'max:20'],
            'city' => ['required', 'string', 'max:120'],
            'country' => ['required', Rule::in(['DE', 'GB', 'PL'])],
            'weight_grams' => ['required', 'integer', 'min:1', 'max:30000'],
            'service' => ['required', Rule::in(['economy', 'express'])],
            'reference' => ['nullable', 'string', 'max:80'],
        ];
    }
}

Tak mały kontroler ma znaczenie. Wykonuje pracę HTTP, wywołuje jeden translator i deleguje operację biznesową. Akcja może być użyta przez formularz administracyjny, komendę importu i worker kolejki, nie otrzymując Request ani niejasnej tablicy.

Użyj buildera do stopniowego konstruowania

Fabryka jest najlepsza, gdy wszystkie dane wejściowe istnieją od razu. Builder przydaje się, gdy konstruowanie rzeczywiście przebiega etapami: import najpierw odczytuje odbiorcę, potem adres, a na końcu opcje wysyłki. Płynne wywołania pokazują, które wybory już podjęto, a build() pozostaje jedynym wyjściem.

php
<?php

declare(strict_types=1);

namespace App\Shipping;

use LogicException;

final class ShippingLabelRequestBuilder
{
    private ?ShippingAddress $address = null;

    private ?Weight $weight = null;

    private ?string $service = null;

    private ?string $reference = null;

    public function forAddress(ShippingAddress $address): self
    {
        $this->address = $address;

        return $this;
    }

    public function weighing(Weight $weight): self
    {
        $this->weight = $weight;

        return $this;
    }

    public function usingService(string $service): self
    {
        $this->service = $service;

        return $this;
    }

    public function withReference(?string $reference): self
    {
        $this->reference = $reference;

        return $this;
    }

    public function build(): ShippingLabelRequest
    {
        if ($this->address === null || $this->weight === null || $this->service === null) {
            throw new LogicException('Address, weight and service are required before building a label request.');
        }

        return new ShippingLabelRequest(
            address: $this->address,
            weight: $this->weight,
            service: $this->service,
            reference: $this->reference,
        );
    }
}

W teście builder czyta się lepiej niż tablica z dziewięcioma kluczami:

php
$request = (new ShippingLabelRequestBuilder())
    ->forAddress(new ShippingAddress('Ada Lovelace', '10 Code Street', '00-001', 'Warsaw', CountryCode::from('PL')))
    ->weighing(Weight::fromGrams(750))
    ->usingService('express')
    ->withReference('order-1001')
    ->build();

Nie rejestruj mutowalnego buildera jako singletonu w kontenerze Laravel. Jego stan wycieknie między kolejnymi rozwiązaniami w długo działającym workerze. Twórz go w miejscu użycia albo binduj jako transient. Unikaj też buildera z dwudziestoma metodami, który tylko odzwierciedla model danych: obiekt command z jasnym konstruktorem albo dedykowany formularz jest często bardziej uczciwy.

Testuj kontrakt konstrukcji bezpośrednio

Najważniejsze testy nie sprawdzają prywatnych szczegółów implementacji. Dowodzą obietnic, na których polegają wywołujący: normalizacji, odrzucania niepoprawnych wartości i kompletnego żądania po build().

php
<?php

use App\Shipping\CountryCode;
use App\Shipping\ShippingAddress;
use App\Shipping\ShippingLabelRequestBuilder;
use App\Shipping\ShippingLabelRequestFactory;
use App\Shipping\Weight;
use DomainException;
use LogicException;

it('normalizes validated input into a shipping label request', function () {
    $request = (new ShippingLabelRequestFactory())->fromValidated([
        'recipient' => ' Ada Lovelace ',
        'address_line_1' => '10 Code Street',
        'postal_code' => '00-001',
        'city' => 'Warsaw',
        'country' => 'pl',
        'weight_grams' => 750,
        'service' => 'express',
        'reference' => ' order-1001 ',
    ]);

    expect($request->address->recipient)->toBe('Ada Lovelace')
        ->and($request->address->country->value)->toBe('PL')
        ->and($request->reference)->toBe('order-1001');
});

it('rejects an unsupported country even outside HTTP validation', function () {
    CountryCode::from('US');
})->throws(DomainException::class);

it('does not build an incomplete request', function () {
    (new ShippingLabelRequestBuilder())
        ->forAddress(new ShippingAddress('Ada Lovelace', '10 Code Street', '00-001', 'Warsaw', CountryCode::from('PL')))
        ->weighing(Weight::fromGrams(750))
        ->build();
})->throws(LogicException::class);

Pierwszy test jest testem jednostkowym; nie potrzebuje bazy ani kernela HTTP. Dodaj test feature dla Form Request, gdy istotny jest endpoint, oraz osobny test CreateShippingLabel z fałszywym klientem przewoźnika. Taki podział sprawia, że błąd konstrukcji łatwo zdiagnozować.

Kiedy wzorce są złym narzędziem

Nie wprowadzaj fabryki i buildera dlatego, że konstruktor ma trzy parametry. Mały niemutowalny obiekt z czytelnym konstruktorem jest już dobrym API. Podobnie sam Form Request wystarczy, gdy dane nigdy nie opuszczają kontrolera i nie mają znaczenia domenowego. Fabryki mogą stać się przebraniem dla workflow biznesowego; jeśli konstrukcja potrzebuje stanu magazynowego, kredytu lub zapytań do bazy, nazwij tę operację akcją albo serwisem.

Alternatywą dla buildera jest zwykle nazwany konstruktor albo jawny command. CreateShipment::forOrder($order, $service) jest bardziej bezpośredni, gdy zamówienie dostarcza wszystkie wymagane dane. Builder jest wartościowy tylko wtedy, gdy wywołujący rzeczywiście wybierają opcjonalne wartości krok po kroku. Alternatywą dla własnych value objectów są walidacja i casty Laravel, często wystarczające na pojedynczej granicy trwałości danych.

Użyj najmniejszej granicy, która zachowuje znaczenie. Gdy tablice zaczynają nieść reguły biznesowe i opcjonalne ścieżki konstrukcji, fabryki, buildery i value objecty zamieniają te niejawne umowy w kod, któremu Laravel i kolejny maintainer mogą ufać.

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.