Structural Beginner 7 min read

Adapter Pattern in C#: Make Foreign APIs Fit Your Code

Adapter pattern in C# / .NET 10: wrap legacy SDK calls in your interfaces — sync-to-async, snake_case to PascalCase, and exception-to-Result conversion.

Table of contents
  1. What problem does the Adapter pattern solve in C#?
  2. How do you write a basic Adapter class in .NET 10?
  3. When is an extension method enough?
  4. When does the Adapter pattern misfire?
  5. How does Adapter compare to Decorator, Facade, and Proxy?
  6. What does a real C# / .NET 10 example look like?
  7. Where should you read next in this series?

You inherit a payment integration written in 2017 against the v1 "Stripely" SDK. The SDK exposes:

// Foreign — you can't change this.
public class StripelyClient
{
    public StripelyResult charge_amount(int amount_in_cents,
        string customer_id, string idempotency_key);
}
public class StripelyResult
{
    public bool   succeeded;
    public string error_message;
    public string transaction_id;
}

Three things are wrong, none of them the SDK's fault:

You cannot pass StripelyClient to your IPaymentProcessor consumer (the one we built in the Factory Method chapter). The interface does not match.

The Adapter pattern is the answer. Wrap the foreign type in a class that implements your interface; do the conversion in one place; let the rest of the codebase forget the foreign type ever existed. This is the most under-celebrated pattern in the GoF book — every real-world service has one of these per external SDK.

What problem does the Adapter pattern solve in C#?

The pattern earns its place when you have two interfaces that should fit together but do not, and you cannot or should not modify either side. Three concrete shapes:

  1. External SDK with the wrong shape. The Stripely SDK above. You do not own its source; you cannot change its names or signatures.
  2. Legacy code you do not want to touch. A Repository class from 2014 with synchronous methods. Modernising the call sites is a six-month project; wrapping it in an IAsyncRepository adapter is an afternoon.
  3. Two libraries with overlapping but incompatible APIs. Logging abstractions are the canonical example: NLog, Serilog, Microsoft.Extensions.Logging — same intent, different shapes. An adapter unifies them behind your codebase's chosen abstraction.

What is not an Adapter problem: "the foreign API is fine but I want to add retries" — that is Decorator. "The foreign API is fine but I want to hide it behind a simpler surface" — that is Facade. Adapter is specifically about interface mismatch.

How do you write a basic Adapter class in .NET 10?

Translate the foreign type to your interface. The body of every method is "convert input → call foreign → convert output":

// Your interface (what your codebase wants to see)
public sealed record PaymentResult(string Status, string? TransactionId, string? Error);

public interface IPaymentProcessor
{
    Task<PaymentResult> PayAsync(decimal amount, string customerId, string idemKey,
        CancellationToken ct);
}

// Adapter
public sealed class StripelyAdapter : IPaymentProcessor
{
    private readonly StripelyClient _legacy;
    public StripelyAdapter(StripelyClient legacy) => _legacy = legacy;

    public Task<PaymentResult> PayAsync(decimal amount, string customerId, string idemKey,
        CancellationToken ct)
    {
        ct.ThrowIfCancellationRequested();
        var cents = (int)(amount * 100m);          // type conversion
        var raw = _legacy.charge_amount(cents, customerId, idemKey);  // sync call
        var result = raw.succeeded
            ? new PaymentResult("succeeded", raw.transaction_id, null)
            : new PaymentResult("failed", null, raw.error_message);
        return Task.FromResult(result);            // bridge sync to async
    }
}

// Composition root
builder.Services.AddSingleton<StripelyClient>();
builder.Services.AddSingleton<IPaymentProcessor, StripelyAdapter>();

Three transformations happen in this one class:

The structural picture:

classDiagram
    class IPaymentProcessor {
        <<interface>>
        +PayAsync(amount, customerId, idemKey) PaymentResult
    }
    class StripelyAdapter {
        -StripelyClient legacy
        +PayAsync(amount, customerId, idemKey) PaymentResult
    }
    class StripelyClient {
        +charge_amount(cents, customer_id, idem_key) StripelyResult
    }
    IPaymentProcessor <|.. StripelyAdapter
    StripelyAdapter o-- StripelyClient : forwards to

Your CheckoutController injects IPaymentProcessor and never sees StripelyClient. The day Stripely ships v2, you replace StripelyAdapter and the controller does not change.

When is an extension method enough?

A separate adapter class is the right answer most of the time but it is not the only option. Three lighter alternatives:

The decision rule: does anyone's code want to depend on a target interface? If yes, you need a class adapter. If no, an extension method or static mapper is lighter.

When does the Adapter pattern misfire?

Three real-world traps to know about:

How does Adapter compare to Decorator, Facade, and Proxy?

These four wrappers — Adapter, Decorator, Facade, Proxy — look almost identical at the call site. The split is about intent:

Pattern Changes the interface? Adds behaviour? Controls access?
Adapter Yes (mismatch → match) No No
Decorator No Yes (logging, retry, caching) No
Facade Yes (many → one) No No
Proxy No No (or transparent forwarding) Yes (lazy load, ACL, remote)

The cleanest sentence: Adapter is for changing the shape; Decorator is for changing the behaviour; Facade is for hiding complexity; Proxy is for controlling access. When in doubt, ask which of the four words best describes what you are about to do.

What does a real C# / .NET 10 example look like?

A complete adapter that handles every transformation a real codebase needs — type, naming, async, error, and cancellation:

// Foreign SDK (cannot be changed) ---------------------------------
public sealed class StripelyClient
{
    public StripelyResult charge_amount(int amount_in_cents,
        string customer_id, string idempotency_key)
    {
        // synchronous, blocks the calling thread
        // throws StripelyTimeoutException on network failure
        // sets succeeded=false on logical errors
        return /* ... */;
    }
}

public sealed class StripelyResult
{
    public bool   succeeded;
    public string error_message = "";
    public string transaction_id = "";
}

public sealed class StripelyTimeoutException : Exception { }

// Your interface --------------------------------------------------
public sealed record PaymentResult(string Status, string? TransactionId, string? Error);

public interface IPaymentProcessor
{
    Task<PaymentResult> PayAsync(decimal amount, string customerId, string idemKey,
        CancellationToken ct);
}

// Adapter ---------------------------------------------------------
public sealed class StripelyAdapter : IPaymentProcessor
{
    private readonly StripelyClient _legacy;
    private readonly ILogger<StripelyAdapter> _log;

    public StripelyAdapter(StripelyClient legacy, ILogger<StripelyAdapter> log)
        => (_legacy, _log) = (legacy, log);

    public Task<PaymentResult> PayAsync(decimal amount, string customerId, string idemKey,
        CancellationToken ct)
    {
        ct.ThrowIfCancellationRequested();
        var cents = checked((int)Math.Round(amount * 100m, MidpointRounding.AwayFromZero));

        try
        {
            // The SDK is synchronous. Offload to the thread pool so we don't block
            // the request thread. This is one of the few legitimate Task.Run uses.
            return Task.Run(() =>
            {
                var raw = _legacy.charge_amount(cents, customerId, idemKey);
                return raw.succeeded
                    ? new PaymentResult("succeeded", raw.transaction_id, null)
                    : new PaymentResult("failed", null, raw.error_message);
            }, ct);
        }
        catch (StripelyTimeoutException ex)
        {
            _log.LogWarning(ex, "Stripely timeout for customer {Customer}", customerId);
            return Task.FromResult(new PaymentResult("timeout", null, ex.Message));
        }
    }
}

// Composition root
builder.Services.AddSingleton<StripelyClient>();
builder.Services.AddKeyedSingleton<IPaymentProcessor, StripelyAdapter>("stripely");

// Test
public sealed class FakeStripely : StripelyClient
{
    public StripelyResult Result { get; set; } = new() { succeeded = true, transaction_id = "tx_test" };
    public new StripelyResult charge_amount(int cents, string c, string k) => Result;
}

[Fact]
public async Task Adapter_converts_decimal_to_cents_and_succeeded_to_status()
{
    var fake = new FakeStripely();
    var sut = new StripelyAdapter(fake, NullLogger<StripelyAdapter>.Instance);
    var result = await sut.PayAsync(9.99m, "cust_1", "idem_1", default);
    Assert.Equal("succeeded", result.Status);
    Assert.Equal("tx_test",   result.TransactionId);
}

The adapter is approximately twenty lines of business code. Most of the value is what is not there: the rest of the codebase never imports StripelyClient, never sees int cents, never branches on succeeded. The day a new payment provider ships, you write a sibling adapter and register it under a different key — see Factory Method for the registration shape.

A practical note that applies to every Adapter you will ever write: the adapter is the only place where the foreign API's vocabulary is allowed inside your codebase. Every translation must live there. The day a non-adapter class starts importing the foreign type, the boundary has leaked, and the cost of changing the foreign API has just multiplied. Treat the adapter as a sealed border crossing.

Frequently asked questions

What is the difference between Adapter and Decorator?
Adapter changes the interface of an existing object so it can be used where a different interface is expected; the behaviour stays the same. Decorator keeps the interface and adds new behaviour around the existing one. If you cannot pass the object to your code without a wrapper, you need an Adapter. If you can pass it but want to add logging or retry, you need a Decorator.
When is an extension method enough instead of an Adapter class?
An extension method is enough when the foreign API has the right shape (same async pattern, same error model) and only the method name or a tiny argument transformation is wrong. The moment you need to bridge sync to async, exception to Result, or batched to per-call, you need a class — extension methods cannot hold state and cannot implement an interface.
How does the Adapter pattern relate to Anti-Corruption Layer in DDD?
Anti-Corruption Layer (ACL) is the same pattern at architectural scale. A handful of Adapter classes (plus translators for vocabulary differences) sit at the boundary between your domain and an external system, so the external model never leaks in. Inside the ACL, each individual class is a textbook Adapter.
Should the adapter throw or return a Result type?
Match the calling code. If your codebase uses exceptions, the adapter catches the foreign SDK's exceptions and rethrows your own typed ones. If your codebase uses Result<T> or OneOf<>, the adapter catches the SDK's exceptions and returns a Result.Failure(...). The adapter's job is to make the foreign behaviour match your conventions, including error handling.