You can't change a third-party library's API. You CAN change how your own code sees it.
Every third-party SDK has its own opinions about method names, parameter order, and return types — opinions you had no say in. A legacy email library might expose SendMail(string to, string from, string subj, string body, bool isHtml), with five positional parameters in an order you'll never remember correctly. Your application code doesn't want to know or care about that shape — it just wants to send an email.
The Adapter Pattern — a Structural pattern from the design patterns lesson earlier in this Part — converts one interface into another interface a client expects, without modifying either side. You can't change the third-party library's source code, and you shouldn't have to change your own application's clean abstraction to match it. An adapter sits between the two, translating.
In this lesson, you'll build an adapter around an awkward third-party SDK, and see exactly why this is Dependency Inversion (081) applied specifically to external dependencies you don't own.
An adapter is a class that takes an object with one interface shape and presents it to your code as if it had a different, more convenient interface shape — without changing either the original object's class or the code that uses the adapter.
Convert the interface of a class into another interface clients expect. Adapter lets classes work together that couldn't otherwise, because of incompatible interfaces — most commonly, because one of those interfaces belongs to code you don't own and can't modify.
You depend on a third-party library or legacy SDK whose method names, parameter shapes, and conventions you have zero control over. Rather than scattering direct references to that library's specific types and awkward method calls throughout your application, you write one adapter class implementing your own clean interface, and every other class depends on that — never on the third-party library directly.
Without an adapter, a third-party SDK's awkward shape leaks directly into your application code, everywhere it's used:
// WITHOUT an adapter — a third-party payment SDK's awkward shape,
// referenced directly, scattered across every checkout-related class
using LegacyPayCo.Sdk; // a NuGet package you don't control
public sealed class CheckoutService
{
public async Task<bool> ChargeCustomerAsync(Order order)
{
var client = new LegacyPayCoClient(Config.MerchantId, Config.SecretKey);
// This SDK's method takes cents as an int, a 3-letter currency code,
// and returns an awkward result CODE, not a rich result object.
int resultCode = await client.ProcessTransaction(
(int)(order.Total * 100), "USD", order.CustomerId.ToString());
return resultCode == 0; // 0 means success?! Undocumented, easy to get wrong.
}
}
// Every class that charges a customer now directly references LegacyPayCoClient,
// its specific constructor arguments, its cents-as-int convention, and its
// magic result codes. Switching providers means touching every one of them.
LegacyPayCoClient (third-party, awkward shape, referenced everywhere it's needed)IPaymentGateway (your clean interface) → LegacyPayCoGatewayAdapter → LegacyPayCoClient (only the adapter touches the SDK directly)This is precisely the Dependency Inversion Principle (081), applied at the boundary with an external dependency: your high-level policy (CheckoutService) depends on an abstraction shaped around your needs, not around the low-level detail's (the SDK's) native shape. The adapter is the low-level implementation that conforms to your abstraction — it just happens to be adapting someone else's code instead of writing new code from scratch.
IPaymentGateway with a ChargeAsync(decimal amount, string customerId) method — decimal dollars, not cents-as-int; a rich PaymentResult, not a magic numberLegacyPayCoGatewayAdapter : IPaymentGateway — this is the ONLY class allowed to reference LegacyPayCoClient directlyPaymentResult, hide the SDK's specific constructor arguments behind your own constructorservices.AddScoped<IPaymentGateway, LegacyPayCoGatewayAdapter>(); — every other class depends only on IPaymentGateway// ── The third-party SDK — you did NOT write this, and cannot change it ──
namespace LegacyPayCo.Sdk;
public sealed class LegacyPayCoClient(string merchantId, string secretKey)
{
public Task<int> ProcessTransaction(int amountInCents, string currencyCode, string customerRef) =>
Task.FromResult(0); // 0 = success, anything else = a specific (undocumented) failure code
}
// ── YOUR clean interface — shaped around what YOUR application needs ──
public interface IPaymentGateway
{
Task<PaymentResult> ChargeAsync(decimal amount, string customerId);
}
public sealed record PaymentResult(bool Success, string? FailureReason = null);
// ── The adapter — the ONLY class that knows LegacyPayCoClient exists ──
public sealed class LegacyPayCoGatewayAdapter(LegacyPayCoClient client) : IPaymentGateway
{
public async Task<PaymentResult> ChargeAsync(decimal amount, string customerId)
{
int amountInCents = (int)(amount * 100); // translate: decimal → cents-as-int
int resultCode = await client.ProcessTransaction(amountInCents, "USD", customerId);
return resultCode switch // translate: magic code → rich result
{
0 => new PaymentResult(Success: true),
1 => new PaymentResult(Success: false, FailureReason: "Insufficient funds"),
_ => new PaymentResult(Success: false, FailureReason: "Unknown gateway error")
};
}
}
// ── Usage — CheckoutService never sees LegacyPayCoClient at all ──
public sealed class CheckoutService(IPaymentGateway gateway)
{
public async Task<bool> ChargeCustomerAsync(Order order)
{
var result = await gateway.ChargeAsync(order.Total, order.CustomerId.ToString());
return result.Success;
}
}
Code → Meaning → Result: CheckoutService depends only on IPaymentGateway — clean, decimal amounts, a proper result type. LegacyPayCoGatewayAdapter is the single, isolated place absorbing every awkward detail of the third-party SDK: the cents conversion, the currency code, the magic result numbers.
Six months later, the business decides to switch from LegacyPayCo to a different provider entirely — a completely different SDK, with its own different shape:
// ── A brand-new third-party SDK, with a completely different shape ──
namespace ModernPay.Sdk;
public sealed class ModernPayClient(string apiKey)
{
public async Task<ChargeResponse> CreateChargeAsync(decimal amountUsd, string customerToken) =>
await Task.FromResult(new ChargeResponse(Succeeded: true, DeclineReason: null));
}
public sealed record ChargeResponse(bool Succeeded, string? DeclineReason);
// ── A SECOND adapter, implementing the SAME IPaymentGateway interface ──
public sealed class ModernPayGatewayAdapter(ModernPayClient client) : IPaymentGateway
{
public async Task<PaymentResult> ChargeAsync(decimal amount, string customerId)
{
var response = await client.CreateChargeAsync(amount, customerId);
return new PaymentResult(response.Succeeded, response.DeclineReason);
}
}
// ── The ONLY change anywhere in the entire application — one line, the composition root ──
services.AddScoped<IPaymentGateway, ModernPayGatewayAdapter>();
// (was: services.AddScoped<IPaymentGateway, LegacyPayCoGatewayAdapter>();)
// CheckoutService's source code — unchanged. Every other class that charges a
// customer — unchanged. Nothing outside the new adapter and this one registration
// line has any idea the payment provider changed at all.
This is the real, practical payoff: swapping a vendor means writing one new adapter class and changing one registration line. Every class coded against IPaymentGateway — potentially dozens, scattered across controllers, background jobs, and admin tools — needs zero changes, because none of them ever depended on LegacyPayCoClient or ModernPayClient in the first place.
Your laptop charger has a specific plug shape (your application's expected interface). A hotel wall socket in another country has a completely different shape (the third-party SDK's actual interface) — and you certainly can't rewire the hotel's walls. A travel power adapter sits between the two: your laptop charger plugs into one side exactly as it always does, and the adapter's other side is shaped to fit whatever socket happens to be there. Neither your laptop charger nor the hotel's wiring changed — only the adapter, sitting between them, knows about both shapes.
LegacyPayCoGatewayAdapter holds a LegacyPayCoClient field — it does not, and typically cannot, inherit from a sealed third-party SDK classBoth hold a reference to another object and delegate to it — that's where the similarity ends. Decorator (242) implements the same interface as what it wraps, to add behavior while keeping the shape identical. Adapter implements a different interface than the thing it wraps, specifically to change the shape. If you're asking "does calling this method do something extra?" — that's Decorator. If you're asking "does calling this method translate to a completely different method signature underneath?" — that's Adapter.
Yes, applied to a specific, extremely common situation: DIP (081) is the general principle that high-level code should depend on abstractions it owns, not on low-level details. Adapter is what that principle looks like specifically when the "low-level detail" is a third-party library whose source you can't touch at all — you can't add an interface to someone else's SDK class, so you wrap it in your own class that implements your interface instead.
An adapter method that returns the SDK's own ChargeResponse type directly, instead of translating it into your own PaymentResult — now CheckoutService still needs a using ModernPay.Sdk; to even compile, defeating the entire purpose. Every method on the adapter must accept and return only your types — full translation in both directions, every time.
Writing IPaymentGateway with a method like ProcessTransaction(int amountInCents, string currencyCode) — just mirroring the vendor's shape under a new name is a fake abstraction; you've renamed the coupling, not removed it. Design IPaymentGateway around what your checkout logic naturally wants to say (dollars, a customer id, a rich result) — the exact same guidance DIP (081) gives for shaping any abstraction.
Calling LegacyPayCoClient directly everywhere because switching providers "will never happen" — vendor contracts end, pricing changes, SDKs get deprecated, and "never" is rarely true for anything you don't personally control. Even with no concrete plan to switch, an adapter is still worth it purely for testability — you can fake IPaymentGateway in a unit test without ever touching the real SDK or its network calls.
And when it's overkill: for a tiny script or a truly one-off integration where you're certain the library will never change and testability genuinely doesn't matter — wrapping a single, trivial, three-line SDK call in a full adapter class and interface can be more ceremony than the situation warrants. For anything living in a real application's core logic, though, the adapter almost always earns its cost.
using SomeThirdParty.Sdk; statement anywhere outside of one dedicated adapter class, the third-party library's shape has leaked further than it should have.
You've built an adapter around an awkward third-party SDK, and swapped it for a second provider without touching the rest of the app. Let's check your understanding.
1. What is the primary purpose of the Adapter pattern?
Correct: B
Why B is correct: This is the precise technical definition given in "What Is It?" — Adapter's job is translating between two incompatible interface shapes, touching neither the client nor the adapted class.
Why A is incorrect: Adding behavior around calls while keeping the same interface describes Decorator (242), not Adapter.
Why C is incorrect: Deciding which concrete class to construct describes Factory (240), a Creational pattern, not Adapter.
Why D is incorrect: Notifying subscribers on state change describes Observer, a different Behavioral pattern entirely.
Reinforcement: Adapter's defining trait is interface translation — converting one shape into another the client already expects.
2. In the worked example, why does LegacyPayCoGatewayAdapter.ChargeAsync return a PaymentResult instead of the SDK's raw integer result code?
Correct: B
Why B is correct: This is exactly Mistake 1 — if the adapter returns the SDK's own type, the abstraction leaks, and CheckoutService would need to understand the vendor's undocumented result codes, defeating the point of the adapter.
Why A is incorrect: Async methods can return any type, including int — this isn't a language limitation.
Why C is incorrect: PaymentResult is a type defined in this lesson's example, not a .NET runtime requirement.
Why D is incorrect: The approaches are not equivalent — one keeps the vendor's shape fully contained, the other lets it leak through.
Reinforcement: A correct adapter translates in both directions — parameters going in, and return values coming out.
3. How does Adapter differ from Decorator, given that both hold a reference to another object and delegate to it?
Correct: B
Why B is correct: This is the exact distinction drawn in Common Confusion — the interface shape is the tell: matching interface plus added behavior is Decorator; different, translated interface is Adapter.
Why A is incorrect: This reverses the actual distinction between the two patterns.
Why C is incorrect: They solve genuinely different problems — shape conversion versus behavior addition — despite sharing the "hold a reference and delegate" mechanic.
Why D is incorrect: Neither pattern is restricted by whether the wrapped class is sealed; that has no bearing on the distinction between them.
Reinforcement: Same interface = Decorator. Different, translated interface = Adapter. That's the reliable way to tell them apart.
4. A team switches payment providers from LegacyPayCo to ModernPay. Because the application was built against IPaymentGateway with an adapter per provider, what needs to change?
Correct: B
Why B is correct: This is the real-world example's exact payoff — because every consumer depended only on IPaymentGateway, swapping providers required just a new adapter and a one-line registration change.
Why A is incorrect: This describes the WITHOUT-an-adapter scenario, which is precisely the problem the pattern avoids — with the adapter in place, no other class ever referenced LegacyPayCoClient directly.
Why C is incorrect: The whole point of shaping IPaymentGateway around your own application's needs (Mistake 2) is that it should NOT need to change just because the vendor changed.
Why D is incorrect: Adapter is a compile-time design pattern, not an automatic runtime detection mechanism — a human still writes the new adapter and updates the registration.
Reinforcement: The adapter's isolation is what makes a vendor swap cheap — the cost of the third-party dependency is contained to one small, replaceable class.
You now know how to keep a third-party SDK's awkward shape from ever leaking into your application — and exactly what it costs to swap vendors when that one adapter class is all that stands between you and them.
dotnetmadeeasy.com — Learn C# and .NET, the right way.