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
- What problem does the Adapter pattern solve in C#?
- How do you write a basic Adapter class in .NET 10?
- When is an extension method enough?
- When does the Adapter pattern misfire?
- How does Adapter compare to Decorator, Facade, and Proxy?
- What does a real C# / .NET 10 example look like?
- 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:
- Naming. Your codebase is PascalCase; the SDK is snake_case.
- Types. Your domain uses
decimalfor money; the SDK usesintcents. - Style. The SDK is synchronous and surfaces errors via boolean
fields; the rest of your code is
async Task<Result<T>>.
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:
- External SDK with the wrong shape. The Stripely SDK above. You do not own its source; you cannot change its names or signatures.
- Legacy code you do not want to touch. A
Repositoryclass from 2014 with synchronous methods. Modernising the call sites is a six-month project; wrapping it in anIAsyncRepositoryadapter is an afternoon. - 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:
- Type:
decimaltointcents. - Naming:
charge_amounttoPayAsync;transaction_idtoTransactionId. - Style: sync foreign call wrapped in
Task.FromResult; boolean result mapped to a typedPaymentResult.
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:
- Extension method. When the foreign API has the right async/error shape and you just want a more pleasant call site. Cannot implement an interface, cannot hold state.
- Lambda + DI registration. A
Func<TInput, TOutput>registered as an adapter for trivial conversions. Useful for one-shot mappings, not for stateful adapters. - Mapper class without an interface. Sometimes you only need the
output type translated and there is no consumer interface. A static
StripelyMapper.ToPaymentResult(StripelyResult)is enough.
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:
- The adapter quietly hides bugs in conversion. The decimal-to-cents
cast
(int)(amount * 100m)truncates 0.999 to 99 cents instead of 100. Adapter code needs the most disciplined unit tests in the codebase because the interface makes it look trivial. - One-way adaptation. The adapter handles
decimalto cents on the way in but forgets to convert back on responses. A consumer readsresult.AmountCentsthinking it is dollars. Convert symmetrically; do not skip the return path. - Adapter chains. Wrapping an Adapter that wraps another Adapter is almost always a sign that one of them should not exist. Three layers of indirection mean three places to debug a single bug. Collapse the chain when you can.
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.
Where should you read next in this series?
- Previous: Prototype — last Creational pattern; cloning instead of constructing.
- Next: Bridge — when there are two axes of variation and you do not want a class explosion.
- Cross-reference: Decorator — same shape (a class wrapping another), different intent (add behaviour vs change interface).
- Cross-reference: Factory Method — the adapter is usually the concrete type the factory returns.
- Decision tree: How to choose the right design pattern.
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?
When is an extension method enough instead of an Adapter class?
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?
Should the adapter throw or return a Result type?
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.