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ście | Kiedy stosować | Wada |
|---|---|---|
| Adapter | Masz gotowy, zewnętrzny/legacy typ o niekompatybilnym interfejsie i chcesz go użyć przez własną abstrakcję | Dodatkowa klasa i mapowanie do utrzymania |
| AutoMapper / Mapster | Mapowanie prostych DTO ↔ DTO bez logiki biznesowej w tłumaczeniu | Nie nadaje się, gdy trzeba tłumaczyć zachowanie (np. kody statusów na enum), nie tylko pola |
| Bezpośrednie wstrzyknięcie SDK do serwisu domenowego | Nigdy w kodzie produkcyjnym z realnymi testami | Coupling z vendorem, brak testowalności, przeciek szczegółów SDK do domeny |
| Facade | Chcesz uprościć dostęp do wielu obiektów podsystemu jednym interfejsem | To 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
sealedSDK-ó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.
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ę →