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
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
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
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
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
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
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
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.