Mert Özen Further With Every Line
Software Design Design Patterns — #07

Adapter Pattern: Fitting a Third-Party SDK to Your Own Interface

Mert Özen Nov 7, 2026 14 min 4 views
Adapter Pattern: Fitting a Third-Party SDK to Your Own Interface

Adapter fits a class you can't change to the interface the client expects; in C# that is almost always an object adapter, built with composition. Connecting two imaginary payment SDKs to a single IPaymentGateway, turning a callback into a Task with TaskCompletionSource, mapping error codes to domain exceptions and verifying both adapters with the same contract test.

Version note: The code in this post was built and run with C# 14 and .NET 10.

Guess First

Bookstore signed with a payment company for card payments, and the company sent an SDK (Software Development Kit). The SDKs in this post are imaginary; they don't represent a real product, but their signatures look the way I often see them in the field: the amount in minor units as a long, the result as a two-digit string code. We don't have the SDK's source, only its public surface:

namespace Vendor.CardSdk;

// The vendor's SDK: we don't have the source and can't change it
public sealed class CardProcessorClient(string apiKey)
{
    private int _sequence = 4100;

    public ChargeResponse Charge(long amountInMinorUnits, string currencyCode, string cardToken)
    {
        ArgumentException.ThrowIfNullOrEmpty(apiKey);
        Console.WriteLine($"[card-sdk] charge {amountInMinorUnits} minor units {currencyCode}");
        return cardToken switch
        {
            "tok_insufficient" => new ChargeResponse("51", null),
            "tok_issuer_down" => new ChargeResponse("91", null),
            "tok_stolen" => new ChargeResponse("43", null),
            _ => new ChargeResponse("00", $"AUTH-{++_sequence}")
        };
    }
}

public sealed record ChargeResponse(string ResultCode, string? AuthorizationId);

The colleague writing the order side injected the SDK straight into OrderService:

using Vendor.CardSdk;

Order order = new("BK-1001", 129.90m);
OrderService service = new(new CardProcessorClient("sk_test_bookstore"));
Console.WriteLine(service.Pay(order, "tok_visa"));

public sealed record Order(string Number, decimal Total);

public sealed class OrderService(CardProcessorClient cardClient)
{
    public string Pay(Order order, string cardToken)
    {
        ChargeResponse response = cardClient.Charge((long)order.Total, "TRY", cardToken);
        return response.ResultCode == "00"
            ? $"Order {order.Number} paid: {order.Total} TRY"
            : $"Order {order.Number} failed: code {response.ResultCode}";
    }
}

The order total is 129.90 TRY. How much was charged to the card, and what did the application tell the customer?

The Adapter Pattern: Target, Adaptee and Adapter

The output:

[card-sdk] charge 129 minor units TRY
Order BK-1001 paid: 129.90 TRY

The card was charged 1.29 TRY, and the screen says 129.90 TRY was paid. (long)129.90m dropped the fractional part and produced 129; the SDK read that as 129 minor units. The compiler said nothing, because the types match; what doesn't match is the two sides' money model. The real problem is that this line lives inside OrderService: the order code now knows what a minor unit is, that "00" means approved and which class the SDK wants. When a second payment provider is added, this knowledge will be written into the order code once more, in a different form.

GoF (Gang of Four: the 1994 Design Patterns book by Erich Gamma, Richard Helm, Ralph Johnson and John Vlissides) presents Adapter as the first of the structural patterns. Its intent is to convert the interface of a class into another interface the client expects, so classes that couldn't work together because of incompatible interfaces can. The book also gives the pattern's other name: Wrapper. There are three roles. The target is the interface the client talks to; here, Bookstore's own IPaymentGateway. The adaptee is the existing class to be fitted; here, the CardProcessorClient we can't change. The adapter is the class that implements the target and delegates the work to the adaptee.

Let's first write the target in Bookstore's language. Money is a Money, a decline and an outage are two separate exceptions, and there isn't a single word from the SDK in any of them:

namespace Bookstore.Payments;

// Bookstore's own contract: the client only knows this (target)
public interface IPaymentGateway
{
    Task<PaymentReceipt> ChargeAsync(PaymentRequest request, CancellationToken cancellationToken = default);
}

public sealed record Money(decimal Amount, string Currency);

public sealed record PaymentRequest(string OrderNumber, Money Amount, string PaymentToken);

public sealed record PaymentReceipt(string Provider, string TransactionId);

public sealed class PaymentDeclinedException(string orderNumber, DeclineReason reason)
    : Exception($"Payment for {orderNumber} was declined: {reason}.")
{
    public DeclineReason Reason { get; } = reason;
}

public sealed class PaymentProviderUnavailableException(string provider, string detail)
    : Exception($"Payment provider '{provider}' is unavailable ({detail}).");

public enum DeclineReason { InsufficientFunds, SuspectedFraud, Other }

The adapter is the only place between this contract and the SDK:

using Vendor.CardSdk;

namespace Bookstore.Payments;

// Adapter: implements IPaymentGateway and delegates the work to the SDK (adaptee)
public sealed class CardGatewayAdapter(CardProcessorClient client) : IPaymentGateway
{
    public Task<PaymentReceipt> ChargeAsync(PaymentRequest request, CancellationToken cancellationToken = default)
    {
        cancellationToken.ThrowIfCancellationRequested();

        // Bookstore only works with two-decimal currencies (TRY, EUR, USD)
        long minorUnits = decimal.ToInt64(decimal.Round(request.Amount.Amount * 100m, 0));
        ChargeResponse response = client.Charge(minorUnits, request.Amount.Currency, request.PaymentToken);

        return response.ResultCode switch
        {
            "00" => Task.FromResult(new PaymentReceipt("card", response.AuthorizationId!)),
            "51" => throw new PaymentDeclinedException(request.OrderNumber, DeclineReason.InsufficientFunds),
            "43" => throw new PaymentDeclinedException(request.OrderNumber, DeclineReason.SuspectedFraud),
            "91" => throw new PaymentProviderUnavailableException("card", "issuer unavailable"),
            // We don't swallow unknown codes: treat as a decline but carry the code in Data
            string code => throw new PaymentDeclinedException(request.OrderNumber, DeclineReason.Other)
                { Data = { ["ResultCode"] = code } }
        };
    }
}

The minor-unit conversion, the SDK's result codes and the knowledge of which code is a decline and which an outage now sit in a single class. decimal.Round with decimal.ToInt64 correctly computes the part the opening cast silently cut off; the decimal places and rounding rules of currencies are covered in detail in the Solution Patterns series post "Money and Rounding". When an unknown code arrives, the adapter neither treats it as approval nor swallows it; it marks it as a decline and carries the code in the exception's Data, so it shows up in the logs.

Class Adapter or Object Adapter?

GoF defines two kinds of adapter. An object adapter, like the one above, holds the adaptee in a field and forwards calls to it: composition. A class adapter derives from both the target and the adaptee through multiple inheritance. Since a class in C# can have only one base class, a class adapter is only possible when the target is an interface and the adaptee is open for derivation:

using Bookstore.Payments;
using Vendor.CardSdk;

// Class adapter: derive from the adaptee, implement the target
public sealed class CardClassAdapter() : CardProcessorClient("sk_test_bookstore"), IPaymentGateway
{
    public Task<PaymentReceipt> ChargeAsync(PaymentRequest request, CancellationToken cancellationToken = default)
    {
        ChargeResponse response = Charge(decimal.ToInt64(request.Amount.Amount * 100m), request.Amount.Currency, request.PaymentToken);
        return Task.FromResult(new PaymentReceipt("card", response.AuthorizationId!));
    }
}
Program.cs(5,42): error CS0509: 'CardClassAdapter': cannot derive from sealed type 'CardProcessorClient'

The SDK's authors made the class sealed; so do most SDKs. Even if it were open for derivation, a class adapter would have two costs. First, the adapter also inherits every public member of the adaptee: calling Charge with minor units through a CardClassAdapter instance would still be possible, and the model the adapter wants to hide would leak out. Second, the adapter is tied to a single concrete class; in tests it gets harder to pass a fake SDK client, to pass a different API (Application Programming Interface) key to the constructor or to use a subclass of the adaptee. GoF also notes that an object adapter lets a single adapter work with the adaptee and all its subclasses. In C# the default answer is the object adapter; it's enough to recognize the class adapter as a leftover from the C++ era. The SOLID (Single Responsibility, Open/Closed, Liskov Substitution, Interface Segregation, Dependency Inversion) and Design Principles series post "Composition over Inheritance: Fragile Base Class, Delegation and Pluggable Behavior" explains the general reasoning behind this preference.

A Second Provider: Fitting a Callback SDK to Task

A few months later a digital wallet integration arrives. This SDK comes from a completely different world: it reports results through a callback in the style that predates async/await, wants the amount as a string and reports failures with an error code instead of an exception:

namespace Vendor.WalletSdk;

// The second vendor's SDK: callback-based, wants the amount as a string
public sealed class WalletApiClient
{
    public void BeginPayment(WalletPaymentRequest request, Action<WalletPaymentResult> onCompleted)
    {
        Console.WriteLine($"[wallet-sdk] pay {request.Amount} {request.Currency}");
        ThreadPool.QueueUserWorkItem(_ =>
        {
            Thread.Sleep(50);
            WalletPaymentResult result = request.WalletToken switch
            {
                "wlt_low_balance" => new(false, null, "INSUFFICIENT_BALANCE"),
                "wlt_timeout" => new(false, null, "UPSTREAM_TIMEOUT"),
                _ => new(true, $"WP-{request.MerchantReference}", null)
            };
            onCompleted(result);
        });
    }
}

public sealed record WalletPaymentRequest(string MerchantReference, string Amount, string Currency, string WalletToken);

public sealed record WalletPaymentResult(bool Succeeded, string? PaymentId, string? ErrorCode);

Bookstore's interface returns a Task. The standard tool for turning a callback into a task is TaskCompletionSource<T>: it creates a task, and you decide when and with what result to complete it.

using System.Globalization;
using Vendor.WalletSdk;

namespace Bookstore.Payments;

// Adapter fitting a callback-based SDK to a Task-returning interface
public sealed class WalletGatewayAdapter(WalletApiClient client) : IPaymentGateway
{
    public Task<PaymentReceipt> ChargeAsync(PaymentRequest request, CancellationToken cancellationToken = default)
    {
        // A canceled request must never reach the SDK
        cancellationToken.ThrowIfCancellationRequested();

        TaskCompletionSource<PaymentReceipt> tcs = new(TaskCreationOptions.RunContinuationsAsynchronously);
        CancellationTokenRegistration registration = cancellationToken.Register(() => tcs.TrySetCanceled(cancellationToken));

        WalletPaymentRequest walletRequest = new(
            MerchantReference: request.OrderNumber,
            // The SDK expects "129.90"; culture-invariant formatting is a must
            Amount: request.Amount.Amount.ToString("F2", CultureInfo.InvariantCulture),
            Currency: request.Amount.Currency,
            WalletToken: request.PaymentToken);

        client.BeginPayment(walletRequest, result =>
        {
            registration.Dispose();
            if (result.Succeeded)
            {
                tcs.TrySetResult(new PaymentReceipt("wallet", result.PaymentId!));
                return;
            }

            tcs.TrySetException(result.ErrorCode switch
            {
                "INSUFFICIENT_BALANCE" => new PaymentDeclinedException(request.OrderNumber, DeclineReason.InsufficientFunds),
                "UPSTREAM_TIMEOUT" => new PaymentProviderUnavailableException("wallet", "upstream timeout"),
                _ => new PaymentDeclinedException(request.OrderNumber, DeclineReason.Other)
            });
        });

        return tcs.Task;
    }
}

Here you can see the adapter resolving three separate mismatches in one place. The model difference between callbacks and Task: RunContinuationsAsynchronously prevents the code after await from running on the SDK's thread, inside the callback. The error model difference: the SDK's string codes become Bookstore's two exceptions. And the data format difference: the amount is written to a string in a culture-invariant way. That last one is a serious source of bugs on a server running in Turkey:

using System.Globalization;

decimal amount = 129.90m;
CultureInfo.CurrentCulture = new CultureInfo("tr-TR");

Console.WriteLine(amount.ToString());
Console.WriteLine(amount.ToString("F2", CultureInfo.InvariantCulture));
129,90
129.90

If the server's culture is tr-TR, a plain ToString() sends "129,90" to the SDK. Whether the SDK rejects it or reads it as 12990 or 129 is up to its implementation. Let's run both adapters with the same client code:

using Bookstore.Payments;
using Vendor.CardSdk;
using Vendor.WalletSdk;

IPaymentGateway[] gateways =
[
    new CardGatewayAdapter(new CardProcessorClient("sk_test_bookstore")),
    new WalletGatewayAdapter(new WalletApiClient())
];

string[][] tokens = [["tok_visa", "tok_insufficient", "tok_issuer_down"], ["wlt_ada", "wlt_low_balance", "wlt_timeout"]];

for (int g = 0; g < gateways.Length; g++)
{
    foreach (string token in tokens[g])
    {
        PaymentRequest request = new("BK-1001", new Money(129.90m, "TRY"), token);
        try
        {
            PaymentReceipt receipt = await gateways[g].ChargeAsync(request);
            Console.WriteLine($"  paid via {receipt.Provider}: {receipt.TransactionId}");
        }
        catch (PaymentDeclinedException ex)
        {
            Console.WriteLine($"  declined: {ex.Reason}");
        }
        catch (PaymentProviderUnavailableException ex)
        {
            Console.WriteLine($"  unavailable: {ex.Message}");
        }
    }
}
[card-sdk] charge 12990 minor units TRY
  paid via card: AUTH-4101
[card-sdk] charge 12990 minor units TRY
  declined: InsufficientFunds
[card-sdk] charge 12990 minor units TRY
  unavailable: Payment provider 'card' is unavailable (issuer unavailable).
[wallet-sdk] pay 129.90 TRY
  paid via wallet: WP-BK-1001
[wallet-sdk] pay 129.90 TRY
  declined: InsufficientFunds
[wallet-sdk] pay 129.90 TRY
  unavailable: Payment provider 'wallet' is unavailable (upstream timeout).

The client loop is unaware that two SDKs exist. Both providers report the same three cases the same way: approval, a decline for insufficient funds and a provider outage. Deciding which adapter to use isn't the client's job either but that of the composition root at application startup; selecting one by key at runtime is the topic of this series' Strategy pattern post.

Adapters Inside .NET

Adapter is so common in the BCL (Base Class Library, .NET's core library) that you usually use it without naming it. The best known is StreamReader: it fits a byte-reading Stream to the character- and line-reading TextReader contract.

using System.Text;

// A byte-reading Stream is adapted to a line-reading TextReader
using MemoryStream bytes = new(Encoding.UTF8.GetBytes("BK-1001;129.90\nBK-1002;54.00\n"));
using TextReader reader = new StreamReader(bytes, Encoding.UTF8);

while (reader.ReadLine() is { } line)
    Console.WriteLine(line.Split(';')[0]);
BK-1001
BK-1002

Whether it's a MemoryStream, a FileStream or a network stream doesn't matter; StreamReader turns them all into the same ReadLine. This is the object adapter advantage GoF mentions: a single adapter works with all subclasses of the adaptee. Other examples:

  • Task.Factory.FromAsync: fits the old BeginXxx/EndXxx method pair (APM (Asynchronous Programming Model)) to Task; the C# Async series post on Thread and ThreadPool explains these older models.
  • TaskCompletionSource<T>: the general tool for fitting any callback- or event-based API to a task; the heart of the wallet adapter above.
  • ChannelReader<T>.ReadAllAsync(): exposes a channel as an IAsyncEnumerable<T>, so it can be read with await foreach.
  • PipeReader.AsStream() and PipeReader.Create(stream): a two-way adapter between System.IO.Pipelines and the Stream world.
  • The ILoggerProvider implementations of libraries like Serilog: they fit their own logging API to ASP.NET Core's ILogger.

Adapter and the Anti-Corruption Layer

In his DDD (Domain-Driven Design, 2003) book, Eric Evans describes a larger version of the same idea: the Anti-Corruption Layer. When integrating with a system that has its own model (an old ERP (Enterprise Resource Planning) system, another team's service, a third-party platform), it's a translation layer that keeps that system's concepts from leaking into your model. Evans describes this layer as a combination of facades, adapters and translators.

The difference is scale. An adapter resolves a single interface mismatch: a method signature, an error model, a data format. An Anti-Corruption Layer stands between two models: it decides that the other side's "client account" is not the same thing as your "customer", how status codes map to your states and which side is the source of truth for which field. The two adapters above behave like a small anti-corruption layer, because they translate the SDKs' notions of codes and amounts into Bookstore's Money and DeclineReason. The larger translation between bounded contexts and the context map concept will be the topic of our planned Domain-Driven Design series.

Testing an Adapter: Contract Tests

Two adapters implement the same interface, but who guarantees they carry the same meaning? If one reports a provider outage as PaymentProviderUnavailableException and the other as a decline, the client's "on an outage, try another provider" rule works for only one of them. The tool for this is a contract test: write the behavior every adapter must provide once and run it for every adapter. In xUnit the plainest way is an abstract test class:

using Bookstore.Payments;
using Vendor.CardSdk;
using Vendor.WalletSdk;

// Behavior every adapter must provide: written once, run for every adapter
public abstract class PaymentGatewayContract
{
    protected abstract IPaymentGateway CreateGateway();
    protected abstract string ApprovedToken { get; }
    protected abstract string InsufficientFundsToken { get; }
    protected abstract string OutageToken { get; }

    private static PaymentRequest Request(string token) => new("BK-1001", new Money(129.90m, "TRY"), token);

    [Fact]
    public async Task Approved_payment_returns_a_transaction_id()
    {
        PaymentReceipt receipt = await CreateGateway().ChargeAsync(Request(ApprovedToken));
        Assert.False(string.IsNullOrWhiteSpace(receipt.TransactionId));
    }

    [Fact]
    public async Task Insufficient_funds_is_a_decline_with_reason()
    {
        PaymentDeclinedException ex = await Assert.ThrowsAsync<PaymentDeclinedException>(
            () => CreateGateway().ChargeAsync(Request(InsufficientFundsToken)));
        Assert.Equal(DeclineReason.InsufficientFunds, ex.Reason);
    }

    [Fact]
    public async Task Provider_outage_is_unavailable_not_declined()
    {
        await Assert.ThrowsAsync<PaymentProviderUnavailableException>(
            () => CreateGateway().ChargeAsync(Request(OutageToken)));
    }

    [Fact]
    public async Task Canceled_request_throws_OperationCanceledException()
    {
        await Assert.ThrowsAnyAsync<OperationCanceledException>(
            () => CreateGateway().ChargeAsync(Request(ApprovedToken), new CancellationToken(canceled: true)));
    }
}

public sealed class CardGatewayAdapterTests : PaymentGatewayContract
{
    protected override IPaymentGateway CreateGateway() => new CardGatewayAdapter(new CardProcessorClient("sk_test"));
    protected override string ApprovedToken => "tok_visa";
    protected override string InsufficientFundsToken => "tok_insufficient";
    protected override string OutageToken => "tok_issuer_down";
}

public sealed class WalletGatewayAdapterTests : PaymentGatewayContract
{
    protected override IPaymentGateway CreateGateway() => new WalletGatewayAdapter(new WalletApiClient());
    protected override string ApprovedToken => "wlt_ada";
    protected override string InsufficientFundsToken => "wlt_low_balance";
    protected override string OutageToken => "wlt_timeout";
}

The first version of the wallet adapter didn't have the UPSTREAM_TIMEOUT line; a timeout fell into the _ branch and became a decline. dotnet test caught it for the wallet:

  Failed WalletGatewayAdapterTests.Provider_outage_is_unavailable_not_declined [52 ms]
  Error Message:
   Assert.Throws() Failure: Exception type was not an exact match
Expected: typeof(Bookstore.Payments.PaymentProviderUnavailableException)
Actual:   typeof(Bookstore.Payments.PaymentDeclinedException)
---- Bookstore.Payments.PaymentDeclinedException : Payment for BK-1001 was declined: Other.

Failed!  - Failed:     1, Passed:     7, Skipped:     0, Total:     8, Duration: 169 ms

After adding the line:

Passed!  - Failed:     0, Passed:     8, Skipped:     0, Total:     8, Duration: 162 ms

Four tests, two adapters, eight runs. When a new provider arrives, writing a new four-line subclass proves that it follows the same contract too. The SDKs here are fake classes running in memory; for a real SDK you'd run the same contract tests against the provider's sandbox environment in a separate integration test run. The C# Advanced series post "Unit Testing in C#: xUnit, Theory/InlineData and Testable Code" covers the basics of xUnit.

Common Pitfalls

Accumulating business rules in the adapter. Once rules like "require 3D Secure above 5,000 TRY", "apply a discount for corporate customers" or "switch to the other provider on the second attempt" get into an adapter, the same rule gets written separately in every adapter and the copies drift apart. The adapter only translates; the code that decides lives on the other side of the target interface, in Bookstore's own services.

Leaking SDK types through the target interface. If IPaymentGateway.ChargeAsync returned a ChargeResponse, the interface's name would belong to Bookstore but the contract itself would remain tied to the SDK. Every type in the target should be your model.

Turning every failure into one generic exception. Saying "payment failed" is easy, but the client needs to tell a decline from an outage: in one case the customer is asked for another card, in the other another provider is tried. Don't silently turn unknown codes into approvals or declines either; carry the code and log it.

Overestimating what cancellation means. The CancellationToken in the wallet adapter only stops waiting; if the request has left the SDK, the payment can still go through on the provider's side. With money movements, cancellation and retries can lead to the same order being charged twice; the Solution Patterns series post "Idempotency Keys: Not Creating the Same Order Twice on Double Clicks and Retries" deals with this problem.

Writing a separate adapter for every SDK method, or copying the SDK's entire surface into the target. The target interface should be as large as Bookstore needs. If the SDK has twenty methods and you use three, the target has three methods. Resilience behaviors like retries and circuit breakers aren't the adapter's job either but that of separate layers, the topic of the upcoming External Service Integration and Resilience series.

Wrapping when there's no need. Wrapping every third-party library in an interface and adapter that copy its API one-to-one usually gains nothing. If the library already fits your model (for example, it uses .NET's own abstractions like ILogger or HttpClient), is used in a single place and replacing it isn't on the table, the adapter is just an extra file and an extra indirection. An adapter earns its keep when the models really are incompatible, or when there's more than one provider for the same role.

A Small Experiment

★☆☆ Change the opening OrderService to depend on IPaymentGateway and run it with CardGatewayAdapter. To test the minor-unit conversion, try orders of 0.01 TRY, 0.005 TRY and 1,000,000 TRY; write down what you expect for each first.

★★☆ Add the code "54" (expired card) to the card SDK. Do you add a new value to DeclineReason, or does it stay Other? Pin down your decision by adding a new test to the contract test.

★★★ Write a FailoverPaymentGateway that tries the second provider on an outage; it should implement IPaymentGateway itself. Is this class an adapter? If not, which pattern does it resemble, and in which case does the risk of charging the same order twice appear?

Adapter fitted two existing pieces to each other after the fact. In the next post we move to a pattern that looks similar but has the opposite intent: Bridge separates abstraction and implementation up front so the two can grow independently. Our example is the class explosion that comes from multiplying message types by notification channels.