🔥 Zapisy zamknięte, ale możesz pobrać Roadmapę .NET i dołączyć do listy oczekujących — Pobierz i dołącz do Listy VIP →

Wzorzec Adapter w C# – integracja z zewnętrznym SDK bez zatruwania domeny

Problem, który realnie rozwiązuje Adapter

Wyobraź sobie typowy scenariusz w projekcie komercyjnym: musisz zintegrować system z bramką płatniczą dostarczoną przez zewnętrznego vendora. Dostajesz paczkę NuGet `LegacyPayments.Sdk`, a w niej coś takiego:

// Kod z zewnętrznego pakietu - NIE MASZ na niego wpływu
public class LegacyPaymentGatewayClient
{
    public LegacyPaymentGatewayClient(string merchantId, string apiSecret) { /* ... */ }

    // Zwraca kod numeryczny, bo tak zaprojektowano SDK w 2014 roku
    public int ExecuteCharge(string cardToken, decimal amountInCents, string currencyIso)
    {
        // wywołanie HTTP do zewnętrznego systemu, retry, itd.
        return 200; // np. 200 = sukces, 402 = odrzucona płatność, 500 = błąd
    }

    public LegacyTransactionDetails FetchTransaction(string legacyTxnId) { /* ... */ }
}

Twoja domena (warstwa `Application`/`Domain` w Clean Architecture) nie powinna nigdy zobaczyć typu `LegacyPaymentGatewayClient`, kodów `int` ani parametru `amountInCents`. Dlaczego? Bo:

1. Coupling – jeśli `OrderService` wstrzykuje bezpośrednio `LegacyPaymentGatewayClient`, to każda zmiana w SDK vendora (aktualizacja NuGet, zmiana API) wymusza zmiany w logice biznesowej.

2. Testowalność – klasy z zamkniętych SDK najczęściej nie implementują żadnego interfejsu i mają `sealed`/niewirtualne metody. Nie zmockujesz `LegacyPaymentGatewayClient` w testach jednostkowych `OrderService` bez owijania go w coś swojego.

3. Semantyka domenowa – `int ExecuteCharge(…)` zwracający magiczne kody statusów to przeciek szczegółów implementacyjnych do logiki biznesowej, która operuje na pojęciach typu `PaymentResult.Success` / `PaymentResult.Declined`.

To dokładnie miejsce na Adapter (Object Adapter).

Implementacja

1. Target — interfejs, którego oczekuje Twoja domena

public interface IPaymentProcessor
{
    PaymentResult Charge(PaymentRequest request);
}

public record PaymentRequest(string CardToken, decimal Amount, string Currency);

public enum PaymentResult
{
    Success,
    Declined,
    GatewayError
}

2. Adaptee — to, co dostałeś od vendora (bez zmian, nie masz do niego dostępu)

`LegacyPaymentGatewayClient` powyżej — traktujemy go jako czarną skrzynkę.

3. Adapter — most między dwoma światami

public sealed class LegacyPaymentGatewayAdapter : IPaymentProcessor
{
    private readonly LegacyPaymentGatewayClient _client;

    public LegacyPaymentGatewayAdapter(LegacyPaymentGatewayClient client)
    {
        _client = client;
    }

    public PaymentResult Charge(PaymentRequest request)
    {
        // Tłumaczenie modelu domenowego na format oczekiwany przez SDK
        var amountInCents = (long)(request.Amount * 100);

        var statusCode = _client.ExecuteCharge(
            request.CardToken,
            amountInCents,
            request.Currency);

        // Tłumaczenie kodów wynikowych SDK na semantykę domenową
        return statusCode switch
        {
            200 => PaymentResult.Success,
            402 => PaymentResult.Declined,
            _ => PaymentResult.GatewayError
        };
    }
}

4. Rejestracja w DI (ASP.NET Core)

To jest miejsce, które w większości artykułów o Adapterze jest pomijane, a w praktyce to właśnie tutaj wzorzec „spina się” z resztą aplikacji:

builder.Services.AddSingleton(_ =>
    new LegacyPaymentGatewayClient(
        merchantId: builder.Configuration["Payments:MerchantId"]!,
        apiSecret: builder.Configuration["Payments:ApiSecret"]!));

builder.Services.AddScoped<IPaymentProcessor, LegacyPaymentGatewayAdapter>();

`OrderService` w warstwie Application zależy wyłącznie od `IPaymentProcessor` nigdy od `LegacyPaymentGatewayClient`. To sprawia, że test jednostkowy wygląda tak:

[Fact]
public void PlaceOrder_WhenPaymentDeclined_ShouldMarkOrderAsFailed()
{
    var paymentProcessorMock = new Mock<IPaymentProcessor>();
    paymentProcessorMock
        .Setup(p => p.Charge(It.IsAny<PaymentRequest>()))
        .Returns(PaymentResult.Declined);

    var sut = new OrderService(paymentProcessorMock.Object);

    var result = sut.PlaceOrder(SampleOrder());

    Assert.Equal(OrderStatus.PaymentFailed, result.Status);
}

Bez adaptera musiałbyś mockować zapieczętowaną klasę SDK — w wielu przypadkach niewykonalne bez dodatkowych bibliotek typu Microsoft Fakes.

Object Adapter vs Class Adapter — i dlaczego w C# to nie jest realny wybór

GoF opisuje dwa warianty:

Class Adapter – adapter dziedziczy jednocześnie po `Adaptee` i implementuje `Target`, korzystając z wielodziedziczenia.

Object Adapter – adapter trzyma referencję do `Adaptee` w polu (kompozycja) i implementuje interfejs `Target`.

W C# nie masz wielodziedziczenia klas, więc Class Adapter w klasycznej postaci nie istnieje. Jedyne, co można zrobić, to imitacja przez dziedziczenie po `Adaptee` (jeśli nie jest `sealed`) i dodatkowe zaimplementowanie interfejsu `Target` na tej samej klasie — ale to rzadko ma sens, bo:

– większość SDK-ów zewnętrznych ma klasy `sealed` (jak w naszym przykładzie) — dziedziczenie jest fizycznie niemożliwe,

– nawet gdyby było możliwe, łączysz w jednej klasie odpowiedzialność „bycia adaptee” i „bycia adapterem” — naruszasz SRP.

Wniosek praktyczny: w C# w 99% przypadków produkcyjnych piszesz Object Adapter przez kompozycję. To nie jest ograniczenie — to jest zgodne z zasadą „composition over inheritance”, którą C# i tak promuje w całym ekosystemie (DI, `IHttpClientFactory`, middleware pipeline).

Adapter vs alternatywy

PodejścieKiedy stosowaćWada
AdapterMasz gotowy, zewnętrzny/legacy typ o niekompatybilnym interfejsie i chcesz go użyć przez własną abstrakcjęDodatkowa klasa i mapowanie do utrzymania
AutoMapper / MapsterMapowanie prostych DTO ↔ DTO bez logiki biznesowej w tłumaczeniuNie nadaje się, gdy trzeba tłumaczyć zachowanie (np. kody statusów na enum), nie tylko pola
Bezpośrednie wstrzyknięcie SDK do serwisu domenowegoNigdy w kodzie produkcyjnym z realnymi testamiCoupling z vendorem, brak testowalności, przeciek szczegółów SDK do domeny
FacadeChcesz uprościć dostęp do wielu obiektów podsystemu jednym interfejsemTo inny problem — Facade upraszcza, Adapter tłumaczy niekompatybilny interfejs

Kiedy Adapter to przesada

Jeśli zewnętrzny typ ma interfejs zbliżony do tego, czego potrzebujesz (różnica to np. nazewnictwo metod), rozważ zwykłe metody rozszerzające zamiast pełnej klasy adaptera — mniej ceremonii dla trywialnego przypadku:

public static class LegacyResultExtensions
{
    public static PaymentResult ToPaymentResult(this int statusCode) =>
        statusCode switch { 200 => PaymentResult.Success, 402 => PaymentResult.Declined, _ => PaymentResult.GatewayError };
}

Pełny Adapter ma sens, gdy tłumaczenie dotyczy całego kontraktu (metody + parametry + typy zwracane), a nie pojedynczej wartości.

Podsumowanie

  • Adapter tłumaczy niekompatybilny interfejs zewnętrznej/legacy klasy na interfejs oczekiwany przez Twoją domenę — bez modyfikowania żadnej z dwóch stron.
  • W C# praktycznie zawsze implementujesz wariant obiektowy (kompozycja) — Class Adapter jest fizycznie niedostępny dla sealed SDK-ów i narusza SRP nawet gdy jest możliwy.
  • Realna wartość ujawnia się przy testowalności (mockowanie interfejsu zamiast zapieczętowanej klasy) i izolacji zmian (aktualizacja SDK vendora nie dotyka logiki biznesowej).
  • Nie każde mapowanie wymaga pełnego Adaptera — dla trywialnych przypadków wystarczy metoda rozszerzająca lub AutoMapper.

Dodaj komentarz

czytanie to początek

Zamień wiedzę w umiejętności

Pobierz darmową Roadmapę .NET i ułóż takie tematy jak ten w spójną ścieżkę do pierwszej pracy.

Pobieram roadmapę →