Wzorzec Adapter w Laravelu: przejmij granicę wokół SDK zewnętrznego dostawcy

8 min. czytania

SDK do płatności, wysyłek lub CRM mówi zewnętrznym językiem. Jego obiekty, typy błędów, pola payloadu, tempo wydań i założenia HTTP są przydatne na krawędzi aplikacji. Stają się kosztowne, gdy pojawiają się w kontrolerach, jobach, modelach bazy i powiadomieniach. Aktualizacja SDK zamienia się wtedy w migrację całego kodu, a komunikat dostawcy może przypadkiem trafić do klienta.

Wzorzec Adapter daje tej krawędzi jednego właściciela. Aplikacja wywołuje mały kontrakt we własnym języku. Jedna implementacja mapuje go na SDK lub API dostawcy i tłumaczy wynik z powrotem. Celem nie jest ukrycie każdego pakietu Composera. Chodzi o posiadanie styku, gdy zależność jest krytyczna biznesowo, używana przez kilka przypadków użycia, trudna operacyjnie lub podatna na zmiany.

Ten przykład kupuje etykietę wysyłkową. Obejmuje zwykle pomijane decyzje: timeouty, idempotencję, mapowanie wyjątków, bezpieczne logi, binding kontenera i testy z fałszowanym HTTP.

Wyciek zaczyna się niewinnie

Bezpośrednie użycie jest zrozumiałe pierwszego dnia:

php
<?php

declare(strict_types=1);

namespace App\\Http\\Controllers;

use AcmeShip\\Client;
use AcmeShip\\Exception\\ApiException;
use App\\Models\\Order;

final class BuyShippingLabelController
{
    public function __invoke(Order $order, Client $client): void
    {
        try {
            $label = $client->labels()->create([
                'recipient' => ['name' => $order->shipping_name],
                'parcel' => ['weight_grams' => $order->weight_grams],
            ]);
        } catch (ApiException $exception) {
            abort(422, $exception->getMessage());
        }

        $order->update(['tracking_number' => $label->tracking_code]);
    }
}

Drugi wywołujący kopiuje mapowanie. Retry w kolejce musi wiedzieć, który wyjątek dostawcy jest chwilowy. Zmiana przewoźnika staje się wyszukiwaniem AcmeShip. Brakuje też spójnej odpowiedzi na proste pytanie domenowe: czy odrzucony kod pocztowy to coś innego niż niedostępny przewoźnik? Kontroler zna szczegóły, których nigdy nie powinien był poznawać.

Nie reaguj jednak wrapperem wokół stabilnej, dwuliniowej funkcji użytej raz. Bezpośrednie użycie jest wtedy czytelniejsze. Dodaj adapter, kiedy integracja ma na tyle dużo zmian lub ryzyka, by zasłużyć na chronioną granicę.

Niech kontrakt mówi językiem domeny

Kontrakt wystawia operację, a nie cały katalog opcji dostawcy. Niezmienne wartości zastępują kruche tablice asocjacyjne i żaden wywołujący nie potrzebuje modelu Eloquent ani obiektu odpowiedzi dostawcy, by go uruchomić.

php
<?php

declare(strict_types=1);

namespace App\\Shipping;

final readonly class CreateShipment
{
    public function __construct(
        public string $orderId,
        public string $recipientName,
        public string $addressLineOne,
        public string $postalCode,
        public string $countryCode,
        public int $weightGrams,
    ) {
        if ($weightGrams < 1) {
            throw new \\InvalidArgumentException('Shipment weight must be positive.');
        }
    }
}

final readonly class ShipmentLabel
{
    public function __construct(
        public string $trackingNumber,
        public string $downloadUrl,
    ) {}
}

interface ShippingGateway
{
    public function buyLabel(CreateShipment $shipment): ShipmentLabel;
}

Do tego kontraktu należą również błędy. Wywołujący nie powinien łapać namespace dostawcy ani parsować komunikatu. Kontrolowane odrzucenie pozwala działać, a awaria jest retryowalna. Zostaw celowo mały, maszynowo odczytywalny powód, zamiast przepuszczać dowolny tekst z systemu zewnętrznego.

php
<?php

declare(strict_types=1);

namespace App\\Shipping\\Exceptions;

use RuntimeException;

final class ShipmentRejected extends RuntimeException
{
    public function __construct(public readonly string $reason)
    {
        parent::__construct('The carrier rejected this shipment.');
    }
}

final class ShippingGatewayUnavailable extends RuntimeException
{
    public function __construct()
    {
        parent::__construct('Shipping labels are temporarily unavailable.');
    }
}

Tłumacz raz, na granicy dostawcy

To, czy dostawca udostępnia SDK, czy tylko API HTTP, nie zmienia interfejsu widzianego przez aplikację. Ta implementacja używa fabryki HTTP Laravel, dzięki czemu polityka transportu jest widoczna i testowalna. Gdy SDK jest wymagane, wywołaj je wewnątrz tej klasy; nie pozwalaj mu przeciekać poza interfejs.

php
<?php

declare(strict_types=1);

namespace App\\Shipping;

use App\\Shipping\\Exceptions\\ShipmentRejected;
use App\\Shipping\\Exceptions\\ShippingGatewayUnavailable;
use Illuminate\\Http\\Client\\ConnectionException;
use Illuminate\\Http\\Client\\Factory;
use Illuminate\\Http\\Client\\RequestException;
use Illuminate\\Support\\Facades\\Log;

final class AcmeShippingGateway implements ShippingGateway
{
    public function __construct(
        private readonly Factory $http,
        private readonly string $baseUrl,
        private readonly string $apiToken,
    ) {}

    public function buyLabel(CreateShipment $shipment): ShipmentLabel
    {
        try {
            $response = $this->http->baseUrl($this->baseUrl)
                ->acceptJson()->withToken($this->apiToken)
                ->connectTimeout(3)->timeout(10)->retry(2, 200, throw: false)
                ->withHeaders(['Idempotency-Key' => 'shipping-label-'.$shipment->orderId])
                ->post('/v1/labels', [
                    'recipient' => [
                        'name' => $shipment->recipientName,
                        'address_line_1' => $shipment->addressLineOne,
                        'postal_code' => $shipment->postalCode,
                        'country' => $shipment->countryCode,
                    ],
                    'parcel' => ['weight_grams' => $shipment->weightGrams],
                ]);
        } catch (ConnectionException $exception) {
            report($exception);

            throw new ShippingGatewayUnavailable();
        }

        if ($response->unprocessableEntity()) {
            $reason = $response->json('error.code', 'invalid_shipment');
            Log::notice('Carrier rejected shipment.', ['order_id' => $shipment->orderId, 'reason' => $reason]);

            throw new ShipmentRejected($reason);
        }

        try {
            $response->throw();
        } catch (RequestException $exception) {
            report($exception);

            throw new ShippingGatewayUnavailable();
        }

        return new ShipmentLabel(
            trackingNumber: $response->json('data.tracking_number'),
            downloadUrl: $response->json('data.label_url'),
        );
    }
}

Jawne timeouty są ważne: domyślny timeout odpowiedzi Laravel jest zwykle dłuższy, niż żądanie webowe powinno czekać na przewoźnika. Retry jest bezpieczne wyłącznie wtedy, gdy dostawca respektuje stabilny klucz idempotencji. Połączenie może paść już po utworzeniu etykiety; bez tej gwarancji retry może kupić drugą. Zanim potraktujesz nazwę nagłówka jak ochronę, sprawdź rzeczywistą semantykę dostawcy.

Adapter ponawia próbę transportową, nie całą operację biznesową. Job powinien decydować o polityce opóźnionych retry, bo wie, czy zamówienie może czekać. Nigdy automatycznie nie ponawiaj 422: błędny adres wymaga korekty. Nie mapuj też każdego 4xx na awarię. Uwierzytelnianie wymaga alarmu operatora, 429 może wymagać backoffu kolejki, a walidacja jest wynikiem workflow.

Zbindowanie kontraktu w composition root

Tylko service provider wybiera, który produkcyjny przewoźnik implementuje kontrakt. Konfiguracja zostaje w configu; env() nie rozlewa się po kodzie aplikacji.

php
<?php

declare(strict_types=1);

namespace App\\Providers;

use App\\Shipping\\AcmeShippingGateway;
use App\\Shipping\\ShippingGateway;
use Illuminate\\Support\\ServiceProvider;

final class ShippingServiceProvider extends ServiceProvider
{
    public function register(): void
    {
        $this->app->bind(ShippingGateway::class, function ($app): ShippingGateway {
            return new AcmeShippingGateway(
                http: $app->make('http'),
                baseUrl: config('services.acme_shipping.base_url'),
                apiToken: config('services.acme_shipping.token'),
            );
        });
    }
}

Akcja może teraz zapisać wartość aplikacji bez znajomości typów dostawcy:

php
<?php

declare(strict_types=1);

namespace App\\Actions\\Shipping;

use App\\Models\\Order;
use App\\Shipping\\CreateShipment;
use App\\Shipping\\ShippingGateway;

final class PurchaseOrderLabel
{
    public function __construct(private readonly ShippingGateway $gateway) {}

    public function handle(Order $order): void
    {
        $label = $this->gateway->buyLabel(new CreateShipment(
            orderId: (string) $order->getKey(), recipientName: $order->shipping_name,
            addressLineOne: $order->shipping_address_line_1, postalCode: $order->shipping_postal_code,
            countryCode: $order->shipping_country, weightGrams: $order->weight_grams,
        ));

        $order->update(['tracking_number' => $label->trackingNumber, 'shipping_label_url' => $label->downloadUrl]);
    }
}

Nie używaj singletona tylko po to, by oszczędzić alokację, jeżeli poświadczenia, nagłówki tenantów lub stan mutowalny mogą się różnić. Singleton ma sens dopiero, gdy konkretny klient jest wykazanie bezstanowy i bezpieczny dla modelu procesu.

Udowodnij tłumaczenie fałszywym HTTP

Testy adaptera sprawdzają URL, payload, klucz idempotencji, mapowanie odpowiedzi i mapowanie błędów. preventStrayRequests() gwarantuje, że brak fake'a nie wykona prawdziwego wywołania przewoźnika. W testach akcji użyj prostego in-memory fake'a ShippingGateway, aby sprawdzały zapis, a nie powtarzały asercje transportu.

php
<?php

use App\\Shipping\\AcmeShippingGateway;
use App\\Shipping\\CreateShipment;
use App\\Shipping\\Exceptions\\ShipmentRejected;
use Illuminate\\Http\\Client\\Request;
use Illuminate\\Support\\Facades\\Http;

it('maps a carrier response and sends an idempotency key', function () {
    Http::preventStrayRequests();
    Http::fake(['https://shipping.example.test/v1/labels' => Http::response([
        'data' => ['tracking_number' => 'ACME-123', 'label_url' => 'https://labels.test/ACME-123.pdf'],
    ], 201)]);
    $gateway = new AcmeShippingGateway(Http::getFacadeRoot(), 'https://shipping.example.test', 'token');

    expect($gateway->buyLabel(new CreateShipment('42', 'Ada', '1 Example Street', '00-001', 'PL', 500))->trackingNumber)
        ->toBe('ACME-123');

    Http::assertSent(fn (Request $request): bool => $request->hasHeader('Idempotency-Key', 'shipping-label-42'));
});

it('maps a carrier validation error to a rejection', function () {
    Http::fake(['https://shipping.example.test/*' => Http::response(['error' => ['code' => 'invalid_postal_code']], 422)]);
    $gateway = new AcmeShippingGateway(Http::getFacadeRoot(), 'https://shipping.example.test', 'token');

    expect(fn () => $gateway->buyLabel(new CreateShipment('42', 'Ada', 'Street', 'bad', 'PL', 500)))
        ->toThrow(ShipmentRejected::class);
});

Dodaj równoważny test Http::failedConnection() dla ShippingGatewayUnavailable. Żaden z tych testów nie powinien nazywać wyjątku dostawcy.

Rozdziel stan workflow od stanu dostawy

Zakup etykiety ma dwa różne rodzaje stanu. Przewoźnik posiada informację, czy zaakceptował zakup i jaki nadał numer śledzenia. Aplikacja posiada informację, czy zamówienie jest gotowe do realizacji, czeka na zakup etykiety, czy można je bezpiecznie przekazać magazynowi. Traktowanie udanej odpowiedzi HTTP tak, jakby atomowo aktualizowała oba systemy, często prowadzi do podwójnych etykiet i zamówień wyglądających na wysłane bez etykiety.

Przy checkoutcie webowym zapisz zamówienie w stanie label_pending i wyślij job po commicie transakcji bazy. Job wywołuje gateway i zapisuje zwrócony ShipmentLabel w drugiej, krótkiej transakcji. Przejściowa awaria gatewaya zostawia stan oczekujący dla retry kolejki; ShipmentRejected przenosi zamówienie do stanu, który wsparcie może obsłużyć. Adapter nie powinien wysyłać tego joba ani wybierać tych stanów. Jego jedyną odpowiedzialnością jest tłumaczenie jednej próby zakupu. Polityka poza adapterem pozwala synchronicznej ścieżce administratora i asynchronicznemu checkoutowi dzielić tę samą granicę.

To rozdzielenie daje również ścieżkę odzyskiwania operacjom. Zapisz stabilny klucz idempotencji lub identyfikator przesyłki aplikacji obok zamówienia i pokaż go w logach oraz narzędziach wsparcia. Jeżeli worker umrze po akceptacji żądania przez przewoźnika, ale przed zapisem w bazie, retry może zapytać przewoźnika tym samym kluczem, zamiast zgadywać, czy utworzyć kolejną etykietę. Jeśli dostawca nie oferuje bezpiecznej idempotencji lub wyszukania, właściwą odpowiedzią może być kolejka ręcznego uzgodnienia, a nie optymistyczny retry.

Pułapki operacyjne i alternatywy

Nie zwracaj obiektów odpowiedzi dostawcy z interfejsu. Dodaje to ceremonialność bez usunięcia sprzężenia. Domyślnie nie loguj tokenów, pełnych adresów, surowych odpowiedzi ani PDF-ów etykiet; identyfikator zamówienia, kontrolowany powód, status HTTP i identyfikator żądania dostawcy zwykle wystarczą. Adapter nie rozwiązuje też transakcji rozproszonych: nie oznaczaj zamówienia jako wysłanego przed utworzeniem etykiety i nie oczekuj, że rollback bazy cofnie zakup u przewoźnika. Zapisz stan oczekujący i zapewnij obserwowalność retry.

Gdy kilku przewoźników implementuje tę samą operację, wybierz kilka adapterów resolverem lub wzorcem Strategy. Nie twórz jednego olbrzymiego adaptera z gałęziami if ($carrier === ...). Odwrotnie, jeżeli dobrze otypowane SDK jest użyte tylko w jednej odizolowanej klasie infrastruktury, ta klasa może już być wystarczającą granicą. Test jest prosty: czy kontroler, akcję lub job da się przeczytać bez znajomości klasy albo błędu dostawcy? Jeśli tak, styk jest Twój.

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.