Wzorzec Strategy w Laravelu: Elastyczne ceny B2B

9 min. czytania

Instrukcja match często jest właściwym początkiem. Jeden typ klienta, dwa rabaty, reguła mieszcząca się na ekranie: nie ma nagrody za wprowadzenie pięciu klas, zanim pojawi się drugi warunek.

Kłopot zaczyna się, gdy ta instrukcja staje się miejscem, w którym gromadzi się polityka cenowa. Klienci detaliczni widzą cenę katalogową. Partnerzy dostają rabat procentowy. Klienci kontraktowi mają wynegocjowane ceny, minimalne ilości, a być może także datę ważności. Potem ktoś dodaje marketplace z własną regułą prowizji. Mały warunek staje się punktem zapalnym zmian dla reguły biznesowej, którą warto rozumieć i testować niezależnie.

Wzorzec Strategy nadaje każdemu algorytmowi wyceny nazwę i granicę. Laravel rozwiązuje następnie właściwą implementację przez kontener. Ten artykuł buduje tę granicę dla katalogu B2B, wyjaśnia, gdzie pasuje contextual binding, oraz — równie ważne — gdzie nie pasuje.

Sygnałem ostrzegawczym nie jest pierwszy match

Ten serwis jest całkowicie rozsądny, dopóki aplikacja ma zaledwie dwie proste reguły:

php
<?php

declare(strict_types=1);

namespace App\Services;

use App\Models\Product;

final class PriceCalculator
{
    public function quote(Product $product, string $customerType): int
    {
        return match ($customerType) {
            'retail' => $product->price_cents,
            'partner' => (int) round($product->price_cents * 0.85),
            'contract' => $this->contractPriceFor($product),
            default => throw new \InvalidArgumentException("Unknown customer type [{$customerType}]."),
        };
    }

    private function contractPriceFor(Product $product): int
    {
        return $product->price_cents;
    }
}

Problemem nie jest sam match. Problem polega na tym, że każde przyszłe zachowanie cenowe musi teraz zmienić tę jedną klasę. Gałąź kontraktowa będzie potrzebowała repozytorium. Gałąź partnera może potrzebować promocji. Reguła marketplace'u może wymagać zewnętrznego serwisu podatkowego. Pojawiają się zależności, testy wymagają coraz bardziej rozbudowanego przygotowania, a zmiana dla jednego kanału może przypadkowo zepsuć inny.

To jest moment na wyodrębnienie strategii — nie wcześniej.

Nadaj operacji biznesowej mały kontrakt

Najpierw jasno określ wejście i wyjście. Pieniądze pozostają liczbą całkowitą centów, więc wartość zmiennoprzecinkowa nie zamieni po cichu ceny €19.99 w €19.98. Wycena zawiera też dość informacji, aby API Resource albo strona checkoutu mogły później wyjaśnić cenę.

php
<?php

declare(strict_types=1);

namespace App\Pricing;

use App\Models\Product;

final readonly class PriceRequest
{
    public function __construct(
        public Product $product,
        public int $quantity,
        public ?string $customerId = null,
    ) {
        if ($quantity < 1) {
            throw new \InvalidArgumentException('Quantity must be at least one.');
        }
    }
}
php
<?php

declare(strict_types=1);

namespace App\Pricing;

final readonly class PriceQuote
{
    public function __construct(
        public int $unitPriceCents,
        public string $rule,
    ) {
        if ($unitPriceCents < 0) {
            throw new \InvalidArgumentException('A price cannot be negative.');
        }
    }

    public function totalCents(int $quantity): int
    {
        return $this->unitPriceCents * $quantity;
    }
}
php
<?php

declare(strict_types=1);

namespace App\Pricing;

interface PriceRule
{
    public function quote(PriceRequest $request): PriceQuote;
}

PriceRule celowo nie mówi nic o kontrolerach, zapytaniach Eloquent ani bieżącym requestcie HTTP. Wywołujący przekazuje żądanie i otrzymuje wycenę. Ten mały kontrakt sprawia, że każdy algorytm można podmienić.

Implementuj reguły tam, gdzie należą

Reguła detaliczna jest celowo nudna. Jej wartością nie jest sprytna arytmetyka, lecz uczynienie domyślnej polityki jawną, testowalną jednostką.

php
<?php

declare(strict_types=1);

namespace App\Pricing\Rules;

use App\Pricing\PriceQuote;
use App\Pricing\PriceRequest;
use App\Pricing\PriceRule;

final class RetailPriceRule implements PriceRule
{
    public function quote(PriceRequest $request): PriceQuote
    {
        return new PriceQuote(
            unitPriceCents: $request->product->price_cents,
            rule: 'retail',
        );
    }
}

Partnerzy dostają udokumentowaną stawkę. Trzymanie jej w konstruktorze czyni ją konfiguracją, a nie ukrytą polityką. Service provider może później odczytać ją z config/pricing.php, nie zmieniając publicznego API strategii.

php
<?php

declare(strict_types=1);

namespace App\Pricing\Rules;

use App\Pricing\PriceQuote;
use App\Pricing\PriceRequest;
use App\Pricing\PriceRule;

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

    public function quote(PriceRequest $request): PriceQuote
    {
        $discountedPrice = intdiv(
            $request->product->price_cents * (10_000 - $this->discountBasisPoints),
            10_000,
        );

        return new PriceQuote(
            unitPriceCents: $discountedPrice,
            rule: 'partner',
        );
    }
}

Wycena kontraktowa ma zależność, której nie mają pozostałe dwie reguły: źródło wynegocjowanych cen. Ta zależność należy do tej strategii, a nie do ogólnego kalkulatora, który każdy kanał musi ze sobą nosić.

php
<?php

declare(strict_types=1);

namespace App\Pricing\Contracts;

use App\Models\Product;

interface ContractPriceRepository
{
    public function unitPriceCentsFor(string $customerId, Product $product): ?int;
}
php
<?php

declare(strict_types=1);

namespace App\Pricing\Rules;

use App\Pricing\Contracts\ContractPriceRepository;
use App\Pricing\PriceQuote;
use App\Pricing\PriceRequest;
use App\Pricing\PriceRule;

final class ContractPriceRule implements PriceRule
{
    public function __construct(
        private readonly ContractPriceRepository $contractPrices,
    ) {}

    public function quote(PriceRequest $request): PriceQuote
    {
        if ($request->customerId === null) {
            throw new \LogicException('A contract quote requires a customer.');
        }

        $unitPriceCents = $this->contractPrices->unitPriceCentsFor(
            customerId: $request->customerId,
            product: $request->product,
        );

        if ($unitPriceCents === null) {
            throw new \DomainException('No active contract price exists for this product.');
        }

        return new PriceQuote(
            unitPriceCents: $unitPriceCents,
            rule: 'contract',
        );
    }
}

Wyjątek jest decyzją biznesową. Część firm wraca do ceny detalicznej, gdy brak wiersza kontraktowego; inne muszą zablokować checkout. Wyraź tę politykę w tej regule albo osobnym dekoratorze fallbacku. Nie ukrywaj jej za ?? $product->price_cents, chyba że jest to rzeczywiście uzgodnione zachowanie handlowe.

Contextual binding służy znanemu konsumentowi

Contextual binding Laravela odpowiada na pytanie: „gdy ta klasa potrzebuje interfejsu, którą konkretną implementację powinna otrzymać?”. Świetnie działa, gdy kanał jest znany od punktu wejścia.

Przykładowo endpoint storefrontu zawsze używa cen detalicznych, a portal partnera zawsze reguły partnerskiej. Oba serwisy zależą od tego samego uczciwego kontraktu:

php
<?php

declare(strict_types=1);

namespace App\Pricing;

final class StorefrontQuoteService
{
    public function __construct(
        private readonly PriceRule $priceRule,
    ) {}

    public function quote(PriceRequest $request): PriceQuote
    {
        return $this->priceRule->quote($request);
    }

    public function rule(): PriceRule
    {
        return $this->priceRule;
    }
}
php
<?php

declare(strict_types=1);

namespace App\Pricing;

final class PartnerPortalQuoteService
{
    public function __construct(
        private readonly PriceRule $priceRule,
    ) {}

    public function quote(PriceRequest $request): PriceQuote
    {
        return $this->priceRule->quote($request);
    }

    public function rule(): PriceRule
    {
        return $this->priceRule;
    }
}

Zarejestruj wybory raz w providerze. Provider zawiera decyzje kompozycyjne; strategie zawierają decyzje cenowe.

php
<?php

declare(strict_types=1);

namespace App\Providers;

use App\Pricing\PartnerPortalQuoteService;
use App\Pricing\PriceRule;
use App\Pricing\Rules\PartnerPriceRule;
use App\Pricing\Rules\RetailPriceRule;
use App\Pricing\StorefrontQuoteService;
use Illuminate\Contracts\Foundation\Application;
use Illuminate\Support\ServiceProvider;

final class PricingServiceProvider extends ServiceProvider
{
    public function register(): void
    {
        $this->app->when(StorefrontQuoteService::class)
            ->needs(PriceRule::class)
            ->give(RetailPriceRule::class);

        $this->app->when(PartnerPortalQuoteService::class)
            ->needs(PriceRule::class)
            ->give(fn (Application $app): PartnerPriceRule => new PartnerPriceRule(
                discountBasisPoints: (int) $app['config']->get('pricing.partner_discount_basis_points'),
            ));
    }
}

To działa, ponieważ StorefrontQuoteService i PartnerPortalQuoteService są różnymi konsumentami. Globalne powiązanie PriceRule sprawiłoby, że oba otrzymałyby tę samą implementację. Powiązanie według typu zalogowanego użytkownika także nie jest tym, co robi contextual binding: kontener rozwiązuje zależności klas, nie inną strategię dla każdego atrybutu requestu.

Wybór w runtime potrzebuje resolvera

Gdy pojedynczy endpoint może wyceniać klientów detalicznych, partnerów lub klientów kontraktowych, wybór jest biznesową logiką runtime. Użyj małego resolvera z jawną mapą, zamiast liczyć, że kontener wywnioskuje regułę z requestu HTTP.

php
<?php

declare(strict_types=1);

namespace App\Pricing;

use App\Pricing\Rules\ContractPriceRule;
use App\Pricing\Rules\PartnerPriceRule;
use App\Pricing\Rules\RetailPriceRule;

final class PriceRuleResolver
{
    public function __construct(
        private readonly RetailPriceRule $retail,
        private readonly PartnerPriceRule $partner,
        private readonly ContractPriceRule $contract,
    ) {}

    public function forCustomerType(string $customerType): PriceRule
    {
        return match ($customerType) {
            'retail' => $this->retail,
            'partner' => $this->partner,
            'contract' => $this->contract,
            default => throw new \InvalidArgumentException("Unknown customer type [{$customerType}]."),
        };
    }
}

Zauważ, że match nie zniknął. Przeniósł się w jedyne miejsce, którego zadaniem jest wybór strategii. Algorytmy nie znają już stringów typów klientów, a dodanie nowej reguły zmienia resolver zamiast każdej gałęzi cenowej w aplikacji.

Testuj politykę, nie magię kontenera

Każda strategia jest zwykłym obiektem PHP. Jej najcenniejsze testy to szybkie testy jednostkowe, które utrwalają zachowanie handlowe. Ten przykład Pest testuje zaokrąglanie w liczbach całkowitych centów bez bootowania Laravela:

php
<?php

declare(strict_types=1);

use App\Models\Product;
use App\Pricing\PriceRequest;
use App\Pricing\Rules\PartnerPriceRule;

test('partner pricing applies the configured discount in integer cents', function (): void {
    $product = new Product(['price_cents' => 19_99]);
    $rule = new PartnerPriceRule(discountBasisPoints: 1_500);

    $quote = $rule->quote(new PriceRequest(product: $product, quantity: 3));

    expect($quote->unitPriceCents)->toBe(16_99)
        ->and($quote->totalCents(3))->toBe(50_97)
        ->and($quote->rule)->toBe('partner');
});

Dodaj jeden feature test dla konfiguracji kontenera. Powinien dowieść, że storefront otrzymuje RetailPriceRule, a portal partnera PartnerPriceRule; nie musi powtarzać każdej asercji cenowej.

php
<?php

declare(strict_types=1);

use App\Pricing\PartnerPortalQuoteService;
use App\Pricing\StorefrontQuoteService;
use App\Pricing\Rules\PartnerPriceRule;
use App\Pricing\Rules\RetailPriceRule;

test('the quote services receive their contextual pricing rules', function (): void {
    expect(app(StorefrontQuoteService::class)->rule())
        ->toBeInstanceOf(RetailPriceRule::class);

    expect(app(PartnerPortalQuoteService::class)->rule())
        ->toBeInstanceOf(PartnerPriceRule::class);
});

Metoda rule() czyni konfigurację obserwowalną w tym małym przykładzie. W aplikacji produkcyjnej wybierz asercję na poziomie zachowania przez endpoint, gdy ujawnienie konkretnej strategii wyciekałoby niepotrzebny szczegół implementacyjny. Istotna granica jest jasna: testy jednostkowe chronią każdą regułę, a pojedynczy test integracyjny chroni provider.

Nie używaj Strategy, gdy warunek wciąż jest czytelniejszy

Strategy ma koszt: więcej plików, więcej nazw i kolejną warstwę pośrednią. Zachowaj match, gdy istnieje tylko kilka stabilnych, trywialnych gałęzi i żadna nie ma własnych zależności ani cyklu życia. Pięcioliniowy formatter nie staje się lepszy dlatego, że ma interfejs.

Nie zmieniaj też każdej konfigurowalnej wartości w strategię. Inna stawka VAT z konfiguracji jest danymi. Inny proces kalkulacji — z zależnościami, przypadkami brzegowymi i osobno znaczącą nazwą — jest zachowaniem. To drugie zasługuje na strategię; pierwsze zwykle na wartość konfiguracji albo rekord bazy.

Wreszcie Strategy nie zastępuje poprawnego modelowania cen. Nie rozstrzyga, czy ceny zawierają podatek, jak łączą się promocje ani jak audytować wycenę po złożeniu zamówienia. To decyzje domenowe, które trzeba podjąć, zanim kod będzie mógł je wiernie zaimplementować.

Użyteczna granica

Rezultatem nie jest „brak warunków”. To system, w którym wybór polityki cenowej następuje raz, a każda polityka jest niezależnie czytelna, testowalna i zmienialna. Kontener Laravela czyni wybory statyczne deklaratywnymi; resolver utrzymuje wybory runtime w uczciwej formie.

To najbardziej praktyczne zastosowanie wzorca Strategy: wprowadź go, gdy rosnący warunek ukrywa kilka algorytmów biznesowych, a potem utrzymaj interfejs na tyle mały, by następna reguła miała oczywiste miejsce do życia.

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.