← Open in the full interactive course (progress tracking, search & more)

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.

What Is It?

The Simple Explanation

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.

The Technical Definition

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.

The classic, most common real-world case

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.

Why Does It Exist? — The Problem It Solves

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.
PROBLEM → NEED → SOLUTION
PROBLEM
NEED
SOLUTION

Big Picture

WITHOUT vs WITH AN ADAPTER
WITHOUT — application code depends directly on the vendor SDK
WITH — one adapter absorbs the awkwardness

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.

How It Works

BUILDING AN ADAPTER — STEP BY STEP
1. DEFINE YOUR OWN INTERFACE, SHAPED AROUND YOUR APPLICATION'S NEEDS
2. WRITE THE ADAPTER CLASS IMPLEMENTING YOUR INTERFACE
3. INSIDE THE ADAPTER, TRANSLATE EVERY DETAIL
4. REGISTER THE ADAPTER FOR YOUR INTERFACE, USE THE INTERFACE EVERYWHERE ELSE

Simple Example

// ── 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.

Real-World Example — The Payoff: Swapping Providers

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.

Analogy — The Travel Power Adapter

Same laptop, same wall socket, different plug shape

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.

Under the Hood — Object Adapter vs. Delegation Mechanics

WHAT THE ADAPTER OBJECT ACTUALLY HOLDS AND DOES
1. THE ADAPTER HOLDS A REFERENCE TO THE ADAPTEE (COMPOSITION, NOT INHERITANCE)
2. EVERY METHOD ON THE ADAPTER TRANSLATES, THEN DELEGATES
3. THIS IS COMPILE-TIME COUPLING, JUST QUARANTINED TO ONE FILE

Common Confusion

Adapter vs. Decorator — the interface shape is the tell

Both 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.

"Isn't this just Dependency Inversion again?"

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.

Common Mistakes

Mistake 1 — Letting the third-party SDK's types leak past the adapter

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.

Mistake 2 — Shaping your own interface around the SDK instead of around your application's needs

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.

Mistake 3 — Skipping the adapter because "we'll only ever use this one SDK"

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.

When Should I Use It?

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.

Rule of thumb: if your application's core logic would need a using SomeThirdParty.Sdk; statement anywhere outside of one dedicated adapter class, the third-party library's shape has leaked further than it should have.

Mental Model

Adapter = your clean interface on one side, the third-party SDK's awkward shape on the other, one class translating between them.
The payoff = swap the vendor, write one new adapter, change one registration line — nothing else in the app moves.

Remember:
· Shape your own interface around what YOUR code needs — never mirror the vendor's shape under a new name.
· Adapter is Dependency Inversion (081), specifically at the boundary with code you don't own.
· Same interface as what's wrapped = Decorator (242). Different, translated interface = Adapter.

Key Takeaway


Check Your Understanding

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?

Show answer

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?

Show answer

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?

Show answer

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?

Show answer

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.