The pattern that lets two incompatible interfaces talk to each other — like a USB-C to HDMI dongle, but in code. When you need to use a third-party library, a legacy system, or a class you cannot change, an Adapter wraps it and exposes the interface your code expects. .NET is built on this pattern: StreamReader adapts a raw byte Stream to a text-oriented reader; DataAdapter bridges ADO.NET DataSets to databases; the entire COM interop story is one giant Adapter. This page builds the pattern from first principles in C# / .NET 8, walks through 6 production bug studies, contrasts classic vs modern variants (object adapter, class adapter, two-way adapter, async adapter), and shows when an Adapter is the right tool versus when a Facade or Decorator would serve you better.
What Adapter is and the real problem it solves (incompatible interfaces you can’t change)
The difference between Object Adapter (composition) and Class Adapter (inheritance)
Where .NET uses Adapter heavily: StreamReader, DataAdapter, COM interop
When to reach for Adapter vs Facade vs Decorator
When two pieces of code speak different “languages” and you can’t change either one, you write a translator in the middle — that’s the Adapter pattern.
Think about a US laptop in a UK hotel room. Your laptop has a US two-prong plug. The wall socket wants a UK three-prong connector. You can’t modify the laptop. You can’t modify the hotel’s wiring. So you buy a small plastic device — a power adapter — that accepts the US plug on one side and presents a UK plug shape on the other. Your laptop happily charges; the socket happily receives power. Neither side knew a translation happened. That tiny device — doing nothing but bridging two incompatible shapes — is the Adapter pattern in physical form.
You’re writing a payments service. Your codebase expects every payment processor to implement IPaymentProcessor with a method called Charge(decimal amount, string cardId). Then you integrate a third-party vendor library called PayWizard. But PayWizard’s API looks completely different: SubmitTransaction(new PaymentRequest { Cents = ... }). You can’t change PayWizard — it’s a vendor library. You can’t change the 47 call sites in your codebase that call Charge() — that’s months of work and regression risk. What you can do is write a PayWizardAdapter that implements IPaymentProcessor and internally calls PayWizard’s API. One class, 10 lines, problem gone.
This is the core idea: Adapter is a wrapper class that implements the target interface your code expects, while internally calling the adaptee (the thing with the incompatible interface). Your existing code never knows PayWizard exists. If you switch vendors tomorrow, you write a new adapter — zero changes to your 47 call sites.
Adapter is a GoF Structural pattern that wraps an existing class with an incompatible interface and exposes the interface your code already expects. It translates one API shape into another without touching either side. Think of it as a plug adapter: the laptop and the socket stay exactly as they are; the adapter device bridges them.
The Object Adapter uses composition — it holds a private reference to the adaptee and delegates calls. This is the standard approach (~95% of real codebases) and works even when the adaptee is a sealed vendor class. The Class Adapter uses multiple inheritance to extend both the target and adaptee simultaneously — this only works in languages that support multiple class inheritance (Java and C++ can do it; C# cannot), so in .NET you will almost always see Object Adapter.
.NET is full of Adapters you already use daily: StreamReader adapts a raw byte Stream into a text reader; DataAdapter bridges ADO.NET DataSets to database connections; the entire COM interop layer is a giant Adapter that wraps unmanaged COM interfaces behind managed .NET interfaces. Logging shims like Microsoft.Extensions.Logging abstract over log4net, NLog, or Serilog using adapter-style wrappers. Every time you see a class that “wraps X to look like Y,” you are looking at an Adapter.
Adapter translates between two incompatible interfaces by wrapping the class you cannot change. Object Adapter uses composition; Class Adapter uses inheritance (C# uses Object Adapter). .NET ships dozens of built-in Adapters, including StreamReader, DataAdapter, and the COM interop layer.
Section 2
Why You Need This — The “Incompatible Interfaces” Problem
Interface mismatches are everywhere in real software. You integrate a vendor SDK. You inherit a legacy codebase. You consume an open-source library. In every case, the external code’s API shape never quite matches what your application already expects. The naive “just change all the call sites” approach sounds fine until you count: 47 call sites, 12 services, 6 engineers, 3 weeks of regression testing, and a product manager asking why payments are down for a month.
The Payments Integration Story
Here is the concrete scenario. Your system processes payments. Every part of your codebase talks through one interface:
// The interface your entire codebase already uses
public interface IPaymentProcessor
{
Task<PaymentResult> Charge(decimal amount, string cardId);
Task Refund(string transactionId);
}
This contract is used in your OrderService, SubscriptionService, InvoiceService, and 44 other places. Then you sign a contract with PayWizard, and their C# SDK looks like this:
// PayWizard vendor SDK — you cannot change this
public class PayWizardClient
{
public TransactionResponse SubmitTransaction(PaymentRequest request) { ... }
public void ReverseTransaction(string txId, string reason) { ... }
}
public class PaymentRequest
{
public long Cents { get; set; } // NOT decimal — their API uses cents as long
public string CardToken { get; set; } // NOT cardId — different naming convention
public string Currency { get; set; } // Extra field your interface doesn't have
}
public class TransactionResponse
{
public string TransactionId { get; set; }
public bool Approved { get; set; }
public string ErrorCode { get; set; } // Their error model is completely different
}
Three mismatches right away: the method is named differently, the parameter types are different (decimal vs long cents), and the return type is different. And PayWizard’s ReverseTransaction takes a reason string that your Refund method doesn’t pass at all.
Before-and-After-Adapter
The left side is what happens without an Adapter: every call site manually constructs a PaymentRequest, converts the decimal to cents, maps field names, and parses the error codes. That translation logic is now duplicated 47 times. When PayWizard releases v2 with a renamed field or a different error format, you update 47 files. When you switch vendors, you update 47 files. When a bug exists in the translation, it is in 47 places.
The right side shows the Adapter. Every call site stays untouched — they still call Charge(amount, cardId) on IPaymentProcessor exactly as before. The PayWizardAdapter class handles all translation in one place: decimal-to-cents, field renaming, error mapping. Switching from PayWizard to Stripe? Write a StripeAdapter. Your 47 call sites never change.
The Search-and-Replace Tax
The “just search-and-replace it” instinct runs deep in engineering teams. But search-and-replace has a hidden tax that compounds every time the dependency changes:
Search-and-Replace-Tax
Every time an external dependency changes, you pay the full cost again without an adapter: 47 files each for v1 integration, v2 upgrade, vendor switch, and the new vendor’s v2. With an adapter, each event touches exactly one file. The adapter pattern pays its overhead back on the very first vendor change.
The rule of thumb: If you control both sides of the integration, just align the interfaces and no adapter is needed. If you control neither side, you may need a separate translation layer at the system level. Adapter is specifically for when you control your code but cannot change the external class you are integrating with.
Without an Adapter, every integration change propagates to all call sites. With an Adapter, your codebase is insulated from external API shapes — switch vendors or upgrade SDKs by swapping one class. The more call sites you have, the faster an Adapter pays for itself.
Section 3
The Analogy — The Power Adapter
You pack your US laptop and fly to London. You get to the hotel and plug in — and nothing happens. The wall socket is a completely different shape. Three rectangular holes arranged in a triangle. Your laptop’s US plug has two flat blades. These are incompatible interfaces.
What do you do? You don’t redesign your laptop’s plug. You don’t rewire the hotel’s electrical system. You buy a small plastic adapter at the airport shop. One side accepts your US plug. The other side presents a UK plug shape to the socket. Electricity flows. Your laptop charges. Neither side was modified. The adapter device did nothing except translate between two shapes.
Now map this to code:
Power Adapter analogy
In code
Concrete example
Your US laptop (the client)
Client — code that uses the target interface
OrderService calling IPaymentProcessor
US plug shape (what your laptop provides)
Target Interface — what your code expects
IPaymentProcessor.Charge(decimal, string)
UK wall socket (the existing thing you can’t change)
Adaptee — existing class with incompatible interface
PayWizardClient.SubmitTransaction(PaymentRequest)
The plastic adapter device
Adapter — implements Target, wraps Adaptee
PayWizardAdapter : IPaymentProcessor
Electricity flowing through
The method call being delegated
Charge() internally calls SubmitTransaction()
Your laptop doesn’t know it’s in the UK
Client doesn’t know it’s using PayWizard
OrderService only sees IPaymentProcessor
Power-Adapter-vs-Code-Adapter
The side-by-side diagram makes the parallel explicit. In both cases: two things exist that speak different languages, you cannot change either, and a small intermediary device absorbs all translation. The critical insight is that the laptop (client) never knows it’s in a foreign country. It just plugs into what it always expected — a US socket shape. The translation is invisible.
Currency Conversion at an Airport Kiosk — You hold US dollars (your data format). You want to pay at a Japanese store that only accepts yen. The currency kiosk takes your dollars, converts them using an exchange rate, and hands you yen. Your wallet (the client) held dollars and knows only dollars. The store (the adaptee) knows only yen. The kiosk (the adapter) knows about both formats and translates. In code terms: Charge(decimal amount) is your dollars, SubmitTransaction(long cents) is yen, and the adapter does the amount * 100 conversion in the middle.
The Human Interpreter — A diplomat (client) speaks only English. A foreign president (adaptee) speaks only Mandarin. An interpreter (adapter) stands between them: when the diplomat says something in English, the interpreter listens, reformulates it in Mandarin, delivers it to the president, hears the response in Mandarin, translates back to English, and reports to the diplomat. The diplomat never learns Mandarin. The president never learns English. The interpreter knows both. This maps perfectly: the interpreter implements the “English interface” for the diplomat, while internally speaking the “Mandarin interface” to the president.
HDMI-to-VGA Dongle — Your modern laptop outputs HDMI. The conference room projector only has a VGA input. You use a small dongle that accepts HDMI on one end and outputs VGA on the other. This analogy captures an important nuance: the conversion is lossy (HDMI carries digital audio; VGA cannot). Similarly, Adapters in code sometimes must make compromises — if the adaptee’s API doesn’t support a feature the target interface requires, the adapter must either throw a NotSupportedException, return a default, or do something creative. Adapter doesn’t always produce a perfect mapping — sometimes it is “best possible translation.”
The thread connecting all these analogies: Two incompatible things. A small translator in the middle. Neither side changes. The client never knows about the translation. The adaptee never knows it is being wrapped. That’s Adapter in one sentence — and once you see it in a power socket, you will recognize it in code immediately.
The Adapter pattern mirrors a physical plug adapter: a translator between two incompatible shapes. Client, Target Interface, Adaptee, and Adapter are the four roles. Neither the client nor the adaptee changes; the adapter absorbs all translation.
Section 4
UML & Structure — The 4 Roles
Before looking at the diagram, meet the four participants in every Adapter. Each one has a clear, non-overlapping job:
1. Target — The interface that the client already knows and expects. This is the contract your application uses everywhere. IPaymentProcessor with its Charge() and Refund() methods. The rest of your codebase is programmed against this interface. The Adapter must implement this.
2. Client — The code that uses the Target interface. It does not know PayWizard exists. It does not know a translation is happening. It calls processor.Charge(amount, cardId) and gets a result. As far as it is concerned, the processor is just “something that implements IPaymentProcessor.”
3. Adaptee — The existing class with the incompatible interface. This is what you need to use but cannot change. PayWizardClient with its SubmitTransaction(PaymentRequest). Could be a sealed vendor class, a legacy service, a COM component — anything you don’t control.
4. Adapter — The bridge class you write. It implements the Target interface (so the client can use it like any IPaymentProcessor), and it holds a reference to or extends the Adaptee (so it can delegate the actual work). This is the only new class you create. Everything else already exists.
GoF 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.”
Class Diagram
Adapter-UML-Class-Diagram
Reading the diagram: The client (OrderService) depends only on the target interface (IPaymentProcessor) via a dashed “uses” arrow — it knows nothing about PayWizard. The adapter (PayWizardAdapter) implements the target interface (the dashed arrow with the open triangle pointing at IPaymentProcessor). The adapter also holds a reference to the adaptee (the filled diamond “HAS-A” arrow to PayWizardClient). The adaptee is completely unaware of the adapter.
Object Adapter vs Class Adapter
There are two structural variants of Adapter. Understanding both matters because you will encounter the term “class adapter” in literature and interviews, even though in C# you will almost always write an object adapter.
Object-Adapter-vs-Class-Adapter
The Object Adapter (left) uses composition: the Adapter class holds a private reference to the Adaptee and delegates calls. This works perfectly in C# because the Adaptee can be any class — sealed, final, from a NuGet package — it doesn’t matter. You just hold a reference.
The Class Adapter (right) uses multiple inheritance: the Adapter literally extends both the Target and the Adaptee at the same time. In Java (with interface + class combo) and C++ this is possible. In C# it is not — C# supports implementing multiple interfaces but inheriting from only one class. In practice, every C# adapter you write will be an Object Adapter.
Adapter has four roles: Target (the interface you expect), Client (uses Target), Adaptee (incompatible class you cannot change), Adapter (implements Target, wraps Adaptee). Object Adapter uses composition (standard in C#). Class Adapter uses multiple inheritance (Java/C++ only — not available in C#).
Section 5
Classic Implementation in C# — Object Adapter
Time to build the canonical Object Adapter. We are using the payment processor scenario from S2. Four files: the target interface, the adaptee (vendor SDK), the adapter itself, and a usage example. Read each file in order — together they tell the complete story.
// IPaymentProcessor.cs
// The TARGET interface — the contract your entire application uses.
// This file is yours. You wrote it. You control it.
namespace Payments.Abstractions;
public record PaymentResult(
bool Success,
string TransactionId,
string? ErrorMessage = null
);
public interface IPaymentProcessor
{
// Charge a card. Amount is in whole currency units (dollars, euros, etc.)
Task<PaymentResult> Charge(decimal amount, string cardId);
// Refund a previously completed transaction by its ID.
Task<PaymentResult> Refund(string transactionId);
}
This is the shape your application already knows. amount is a decimal (standard .NET monetary type — avoids floating-point rounding errors). cardId is a string token. The return type PaymentResult is a simple record that carries success flag, transaction ID, and an optional error message. Your 47 call sites all speak this language.
// PayWizardClient.cs
// The ADAPTEE — the vendor SDK. You did NOT write this.
// You cannot change it. It is a sealed class from a NuGet package.
namespace PayWizard.Sdk;
public sealed class PayWizardClient
{
private readonly string _apiKey;
public PayWizardClient(string apiKey)
=> _apiKey = apiKey;
// Note: amount is in CENTS as a long — not decimal dollars.
// Note: cardToken, not cardId.
public TransactionResponse SubmitTransaction(PaymentRequest request)
{
// ... actual HTTP call to PayWizard servers ...
return new TransactionResponse
{
TxRef = Guid.NewGuid().ToString(),
Status = "APPROVED",
ErrCode = null
};
}
// Note: requires a reason string that IPaymentProcessor.Refund does NOT have.
public void ReverseTransaction(string txRef, string reason)
{
// ... actual HTTP call ...
}
}
public class PaymentRequest
{
public long Cents { get; init; } // CENTS, not decimal dollars
public string CardToken { get; init; } = "";
public string Currency { get; init; } = "USD";
public string MerchantRef { get; init; } = "";
}
public class TransactionResponse
{
public string TxRef { get; init; } = ""; // their name for transaction ID
public string Status { get; init; } = ""; // "APPROVED" / "DECLINED"
public string? ErrCode { get; init; } // null on success
}
Three incompatibilities jump out immediately. First, monetary amounts: your interface uses decimal amount (e.g., 12.50m) but PayWizard expects long Cents (e.g., 1250L). Second, naming: your interface calls it cardId but PayWizard calls it cardToken. Third, the refund operation: PayWizard’s ReverseTransaction requires a reason string, but your Refund interface method has no such parameter — the adapter must supply a sensible default. These three mismatches are exactly what the adapter will handle.
// PayWizardAdapter.cs
// The ADAPTER — the class you write to bridge the two worlds.
// Implements IPaymentProcessor (Target) and wraps PayWizardClient (Adaptee).
namespace Payments.Adapters;
using Payments.Abstractions;
using PayWizard.Sdk;
public sealed class PayWizardAdapter : IPaymentProcessor
{
// Composition: hold a reference to the adaptee — NOT inherit from it.
// Why sealed? Adapters are typically leaf classes — no point subclassing a translator.
private readonly PayWizardClient _client;
public PayWizardAdapter(PayWizardClient client)
=> _client = client;
// IPaymentProcessor.Charge → PayWizardClient.SubmitTransaction
public Task<PaymentResult> Charge(decimal amount, string cardId)
{
// Translation 1: decimal dollars → long cents
// Why: PayWizard API works in integer cents to avoid floating-point issues.
var cents = (long)Math.Round(amount * 100, MidpointRounding.AwayFromZero);
// Translation 2: build PaymentRequest with renamed fields
var request = new PaymentRequest
{
Cents = cents,
CardToken = cardId, // field rename: cardId → CardToken
Currency = "USD", // default currency — could be injected via config
MerchantRef = Guid.NewGuid().ToString("N")[..16] // required field, we generate
};
// Delegate the actual work to the adaptee
var response = _client.SubmitTransaction(request);
// Translation 3: map TransactionResponse → PaymentResult
var result = response.Status == "APPROVED"
? new PaymentResult(true, response.TxRef)
: new PaymentResult(false, response.TxRef, $"PayWizard error: {response.ErrCode}");
return Task.FromResult(result); // PayWizardClient is synchronous; wrap in Task
}
// IPaymentProcessor.Refund → PayWizardClient.ReverseTransaction
public Task<PaymentResult> Refund(string transactionId)
{
// Translation 4: supply the "reason" field that IPaymentProcessor.Refund doesn't have.
// We use a sensible default — the adapter absorbs this mismatch.
_client.ReverseTransaction(transactionId, reason: "CUSTOMER_REQUESTED");
// PayWizard.ReverseTransaction is void — we infer success (it would throw on failure).
var result = new PaymentResult(true, transactionId);
return Task.FromResult(result);
}
}
Walk through the adapter line-by-line. The class is sealed and implements IPaymentProcessor — that’s the target interface. The constructor accepts a PayWizardClient (the adaptee) via constructor injection.
In Charge(), four translations happen. The decimal-to-cents conversion uses Math.Round(..., AwayFromZero) — this is the correct rounding mode for monetary values in .NET (banker’s rounding is the default and can surprise you). The field rename from cardId to CardToken is trivial but explicit. The MerchantRef is a required PayWizard field with no equivalent in your interface — the adapter generates a value. Finally, the TransactionResponse is mapped to PaymentResult. The adapter also wraps the synchronous SDK in Task.FromResult to satisfy the async target interface.
In Refund(), the adapter handles the extra reason parameter by supplying a default string. This is a common adapter responsibility: when the adaptee requires more information than the target interface provides, the adapter must bridge the gap with a sensible default, a configuration value, or a derived value.
// Program.cs (or Startup / DI registration)
// Shows how the adapter is wired up and used. The client code is unchanged.
using Microsoft.Extensions.DependencyInjection;
using Payments.Abstractions;
using Payments.Adapters;
using PayWizard.Sdk;
// --- Dependency Injection wiring (typical ASP.NET Core setup) ---
var services = new ServiceCollection();
// Register the vendor SDK
services.AddSingleton(new PayWizardClient(apiKey: Environment.GetEnvironmentVariable("PAYWIZARD_KEY")!));
// Register the adapter as IPaymentProcessor.
// Every class that asks for IPaymentProcessor will get a PayWizardAdapter.
services.AddScoped<IPaymentProcessor, PayWizardAdapter>();
var provider = services.BuildServiceProvider();
// --- OrderService never mentions PayWizard ---
var processor = provider.GetRequiredService<IPaymentProcessor>();
var result = await processor.Charge(49.99m, "card_tok_visa_4242");
if (result.Success)
Console.WriteLine($"Charged! TxID: {result.TransactionId}");
else
Console.WriteLine($"Failed: {result.ErrorMessage}");
// Switch to Stripe tomorrow?
// Change ONE line above: services.AddScoped<IPaymentProcessor, StripeAdapter>();
// Everything else stays identical.
The DI registration is the payoff. Every part of your application that asks for IPaymentProcessor gets a PayWizardAdapter — but none of them know that. The OrderService, SubscriptionService, and all 44 other call sites simply call processor.Charge() and get back a PaymentResult. PayWizard is completely hidden. If you switch vendors, you change one DI registration line. If you write tests, you use a fake IPaymentProcessor and the real adapter code is never involved in unit tests.
Execution Flow: What Happens at Runtime
When OrderService calls processor.Charge(49.99m, "card_tok_visa_4242"), here is exactly what happens inside the adapter chain:
Adapter-Execution-Flow
Six steps, but from the client’s perspective it is just one: call Charge(), get back PaymentResult. The adapter is the entire middle section — it receives the call in the client’s language (step 1), translates inputs (step 2), calls the adaptee in its language (step 3), receives the response (step 4), translates the response back (step 5), and returns the result in the client’s language (step 6). The adapter absorbs all the impedance between the two interfaces.
Object Adapter in C#: implement the Target interface, hold a private reference to the Adaptee via constructor injection, translate inputs before delegating, translate outputs before returning. The client never imports the vendor SDK. Swap adapters via DI for testing or vendor changes.
Section 6
Junior vs Senior — Different Mental Models
Both junior and senior engineers can write an Adapter that compiles and passes tests. The difference is not in the pattern itself — it is in what the engineer understands the adapter’s real job to be. A junior sees a class wrapper. A senior sees a contract layer, an error isolation boundary, and a testability seam. Those mental models produce dramatically different code in production.
The junior engineer understands that an Adapter translates between two interfaces. They write a class that implements IPaymentProcessor, call PayWizardClient inside, and it works. The translation is there. The tests pass. Ship it.
What the junior misses: the adapter is the only place in your entire codebase that knows about PayWizard. That means it is also the only place where PayWizard’s failure modes enter your system. If the adapter just passes exceptions up raw, PayWizard-specific exception types will leak into your business logic, and your 47 call sites will need to know about PayWizardTransactionException and PayWizardNetworkException. The vendor leaks through. The isolation fails.
Common junior mistake: letting vendor exceptions propagate unchanged. Your OrderService should never catch PayWizardTransactionException. If it does, you have not actually isolated anything — you have just moved the vendor coupling one layer up.
// Junior implementation — works, but leaks vendor details
public sealed class PayWizardAdapter : IPaymentProcessor
{
private readonly PayWizardClient _client;
public PayWizardAdapter(PayWizardClient client)
=> _client = client;
public Task<PaymentResult> Charge(decimal amount, string cardId)
{
// Just translate and call. If PayWizard throws, the exception
// propagates up raw to OrderService. OrderService now knows
// about PayWizard internals — the isolation is broken.
var response = _client.SubmitTransaction(new PaymentRequest
{
Cents = (long)(amount * 100), // bug: floating-point imprecision
CardToken = cardId,
});
return Task.FromResult(
new PaymentResult(response.Status == "APPROVED", response.TxRef)
);
// No error message mapping. No logging. No retry. No timeout.
}
public Task<PaymentResult> Refund(string transactionId)
{
_client.ReverseTransaction(transactionId, "refund"); // magic string
return Task.FromResult(new PaymentResult(true, transactionId));
// What if ReverseTransaction throws? OrderService catches PayWizardException.
// What if PayWizard takes 30 seconds? OrderService request times out.
}
}// Problems the junior adapter causes downstream:
// In OrderService — vendor exception leaks through:
try
{
var result = await _processor.Charge(49.99m, cardId);
}
catch (PayWizardTransactionException ex) // 🚨 OrderService knows about PayWizard!
{
// Now OrderService is coupled to the vendor.
// If you swap to Stripe, OrderService must change too.
// The whole point of the adapter is defeated.
}
// Floating-point bug:
var badCents = (long)(2.30m * 100); // = 229 (not 230) on some platforms!
// Correct version: (long)Math.Round(amount * 100, MidpointRounding.AwayFromZero)
The senior engineer sees the adapter as a boundary guard. Everything on the outside of the adapter speaks the application’s language: PaymentResult, PaymentException, clean domain types. Everything on the inside speaks vendor language: TransactionResponse, PayWizardNetworkException, raw HTTP responses. The adapter’s job is to ensure that nothing from the inside leaks to the outside. Not exception types. Not error codes. Not field names. Not timing characteristics.
This means the senior’s adapter does more than call the vendor: it translates exceptions into domain exceptions, adds structured logging at the integration boundary (so you can see every PayWizard call in your APM without adding logging to 47 call sites), handles retries for transient failures (network blips should not fail an order), enforces timeouts (vendor slowness should not cascade), and maps error codes to domain-meaningful results.
// Senior implementation — the adapter owns the boundary
public sealed class PayWizardAdapter : IPaymentProcessor
{
private readonly PayWizardClient _client;
private readonly ILogger<PayWizardAdapter> _logger;
private static readonly TimeSpan Timeout = TimeSpan.FromSeconds(10);
public PayWizardAdapter(PayWizardClient client, ILogger<PayWizardAdapter> logger)
{
_client = client;
_logger = logger;
}
public async Task<PaymentResult> Charge(decimal amount, string cardId)
{
// Correct monetary conversion — never use (long)(amount * 100)
var cents = (long)Math.Round(amount * 100, MidpointRounding.AwayFromZero);
_logger.LogInformation(
"PayWizard Charge starting. Amount: {Amount}, Card: {CardId}",
amount, MaskCard(cardId)); // log at boundary, mask PII
try
{
var request = new PaymentRequest
{
Cents = cents,
CardToken = cardId,
Currency = "USD",
MerchantRef = Guid.NewGuid().ToString("N")[..16]
};
// Wrap synchronous SDK in Task.Run with a timeout
using var cts = new CancellationTokenSource(Timeout);
var response = await Task.Run(
() => _client.SubmitTransaction(request), cts.Token);
var result = MapToResult(response);
_logger.LogInformation(
"PayWizard Charge complete. Success: {Success}, TxId: {TxId}",
result.Success, result.TransactionId);
return result;
}
catch (OperationCanceledException)
{
// Translate timeout → domain result. Never let CancellationTokenSource
// or PayWizard-specific TimeoutException leak to OrderService.
_logger.LogWarning("PayWizard Charge timed out after {Timeout}s", Timeout.TotalSeconds);
return new PaymentResult(false, "", "Payment gateway timed out. Please retry.");
}
catch (Exception ex) when (IsTransient(ex))
{
// Could add Polly retry here — the senior knows retries belong at the boundary
_logger.LogWarning(ex, "PayWizard Charge transient failure");
return new PaymentResult(false, "", "Payment temporarily unavailable.");
}
catch (Exception ex)
{
// Non-transient vendor failure → log full exception, return clean domain result
_logger.LogError(ex, "PayWizard Charge unexpected failure");
return new PaymentResult(false, "", "Payment failed. Please contact support.");
}
}
public async Task<PaymentResult> Refund(string transactionId)
{
_logger.LogInformation("PayWizard Refund starting. TxId: {TxId}", transactionId);
try
{
using var cts = new CancellationTokenSource(Timeout);
await Task.Run(
() => _client.ReverseTransaction(transactionId, "CUSTOMER_REQUESTED"),
cts.Token);
_logger.LogInformation("PayWizard Refund complete. TxId: {TxId}", transactionId);
return new PaymentResult(true, transactionId);
}
catch (Exception ex)
{
_logger.LogError(ex, "PayWizard Refund failed. TxId: {TxId}", transactionId);
return new PaymentResult(false, transactionId, "Refund failed. Support has been notified.");
}
}
private static PaymentResult MapToResult(TransactionResponse r) =>
r.Status == "APPROVED"
? new PaymentResult(true, r.TxRef)
: new PaymentResult(false, r.TxRef, TranslateErrorCode(r.ErrCode));
private static string TranslateErrorCode(string? code) => code switch
{
"INSUFFICIENT_FUNDS" => "Card declined: insufficient funds.",
"CARD_EXPIRED" => "Card declined: card is expired.",
"INVALID_CVV" => "Card declined: security code mismatch.",
_ => $"Card declined (code: {code})."
};
// Transient: network timeouts, temporary service unavailability
private static bool IsTransient(Exception ex) =>
ex is HttpRequestException or TaskCanceledException;
// Mask PII: show only last 4 chars of card token in logs
private static string MaskCard(string cardId) =>
cardId.Length > 4 ? $"****{cardId[^4..]}" : "****";
}// Key differences between junior and senior adapters:
// 1. EXCEPTION TRANSLATION
// Junior: PayWizardNetworkException propagates to OrderService
// Senior: All exceptions caught, translated to domain-meaningful PaymentResult
// OrderService never sees a vendor-specific type
// 2. STRUCTURED LOGGING AT BOUNDARY
// Junior: no logging — you have no visibility into PayWizard calls
// Senior: every call logged with amount, cardId (masked), result, timing
// One APM query shows all payment gateway activity
// 3. CORRECT MONETARY CONVERSION
// Junior: (long)(amount * 100) — floating-point imprecision bug
// Senior: Math.Round(amount * 100, MidpointRounding.AwayFromZero)
// 4. TIMEOUT HANDLING
// Junior: vendor takes 30s? entire request thread hangs
// Senior: CancellationTokenSource wraps call, returns clean error in 10s
// 5. ERROR CODE TRANSLATION
// Junior: returns "PayWizard error: INSUFFICIENT_FUNDS" — vendor string leaks
// Senior: TranslateErrorCode() maps to user-facing English — no vendor leakage
// 6. PII PROTECTION IN LOGS
// Junior: logs full cardId — potential compliance violation
// Senior: MaskCard() logs only last 4 chars
// All six of these are concerns that belong ONLY in the adapter.
// If you do not handle them here, they scatter across 47 call sites.
Mental Model Comparison
Jr-vs-Sr-Adapter-Comparison
The visual makes the mental model gap clear. The junior’s adapter is a thin membrane — it translates the method signature but lets everything else through: raw exceptions, vendor error strings, vendor timing behavior. The senior’s adapter is a thick wall — it absorbs all vendor-specific behavior and emits only clean domain types and domain-meaningful errors. The client side of the wall has no idea what vendor it is talking to.
Translates method signature onlyVendor exceptions propagate rawNo logging at boundaryNo timeout handlingError codes as vendor stringsOwns the entire integration boundaryAll exceptions translated to domain typesStructured logs with PII maskingTimeout + graceful degradationError codes mapped to user-facing EnglishJunior sees Adapter as a method translator. Senior sees it as a boundary layer that must absorb all vendor-specific behavior — exceptions, error codes, timeouts, logging — so nothing vendor-specific leaks into the application. The adapter is the only place in the system that speaks the vendor’s language, so it should handle everything that language requires.
Section 7
Historical Evolution — How Adapter Evolved in .NET
The Adapter pattern was always part of .NET from day one — but how you write an adapter has changed dramatically across each major platform generation. In .NET 1.0 you wrote heavyweight COM interop wrappers by hand. By .NET 8 you can express the same idea in four lines using primary constructors, or skip the class entirely and use a source generator. Understanding this evolution helps you recognize adapters in old codebases and choose the right modern idiom for new ones.
The timeline above shows five distinct eras. Each one reduced the ceremony required to write a correct adapter, which is why adapter code in a .NET 8 repo looks almost nothing like adapter code in a .NET 1.1 repo — even though both are implementing the same structural pattern.
Era 1 — .NET 1.0 / 1.1 (2002–2003): COM Interop & Raw Wrappers
Before .NET existed, most Windows applications were stitched together from older Microsoft components that spoke a completely different "binary dialect" than managed code does today. That older component system is called COM (Component Object Model). The .NET 1.0 team faced a massive compatibility problem: billions of lines of COM objects existed in the wild, and managed .NET code couldn't call them directly because the memory models and calling conventions were completely different.
The solution was a special wrapper class that .NET generates for you whenever you reference a COM component — it makes the foreign component look and feel like a normal .NET object. That generated wrapper is called the Runtime Callable Wrapper (RCW). The RCW implements a managed interface your .NET code can call, and internally it translates every call into the COM binary protocol. You didn't have to write the adapter — the toolchain wrote it for you.
But for custom adapters in application code, .NET 1.0 was verbose. No generics, no lambdas, no extension methods. Every adapter was a full class with explicit field declarations, explicit constructor, and explicit method-by-method delegation:
// .NET 1.0 era — verbose, no generics, no type inference
// Imagine adapting a legacy ILegacyLogger to a new ILogger interface
public interface ILogger
{
void Log(string message, string level);
}
public class LegacySystemLogger
{
public void Write(string msg) { /* writes to flat file */ }
public void WriteError(string msg) { /* writes to error log */ }
}
// The adapter — all ceremony, all hand-written
public class LegacyLoggerAdapter : ILogger
{
private LegacySystemLogger _legacy; // explicit field
public LegacyLoggerAdapter(LegacySystemLogger legacy) // explicit ctor
{
_legacy = legacy;
}
public void Log(string message, string level) // explicit method impl
{
if (level == "ERROR")
_legacy.WriteError(message);
else
_legacy.Write(message);
}
}
This pattern worked fine — and it's still valid today. But notice that roughly 60% of the code above is boilerplate: the field declaration, the constructor assignment, the cast. None of that is the interesting part of the adapter. Later .NET versions gradually eliminated this ceremony.
Era 2 — .NET 2.0 / 3.0 (2005–2006): Generics Make Adapters Type-Safe
.NET 2.0 introduced generics, and the impact on adapters was significant. Before generics, collection adapters had to use object everywhere, which meant boxing/unboxing overhead and runtime cast exceptions instead of compile-time type errors. With generics you could write a single adapter class that works for any element type and catches mismatches at compile time.
// .NET 2.0+ — generic adapter wraps an old non-generic IList
// into the new generic IList
public class NonGenericListAdapter<T> : IList<T>
{
private readonly System.Collections.IList _inner;
public NonGenericListAdapter(System.Collections.IList inner)
{
_inner = inner;
}
public T this[int index]
{
get => (T)_inner[index]; // one cast, here, not at every call site
set => _inner[index] = value;
}
public int Count => _inner.Count;
public bool IsReadOnly => _inner.IsReadOnly;
public void Add(T item) => _inner.Add(item);
public bool Contains(T item) => _inner.Contains(item);
public bool Remove(T item) { int i = _inner.IndexOf(item); if (i < 0) return false; _inner.RemoveAt(i); return true; }
public int IndexOf(T item) => _inner.IndexOf(item);
public void Insert(int i, T v) => _inner.Insert(i, v);
public void RemoveAt(int i) => _inner.RemoveAt(i);
public void Clear() => _inner.Clear();
public void CopyTo(T[] arr, int i) => _inner.CopyTo(arr, i);
public IEnumerator<T> GetEnumerator()
{ foreach (object o in _inner) yield return (T)o; }
System.Collections.IEnumerator System.Collections.IEnumerable.GetEnumerator()
=> _inner.GetEnumerator();
}
The cast from object to T now happens in exactly one place — the adapter — instead of scattered across every call site. Type errors surface at compile time. The generic adapter also enabled things like IEnumerable<T> adapters, which became ubiquitous once LINQ arrived.
Era 3 — .NET 3.5 / LINQ (2007): Extension Methods as Lightweight Adapters
.NET 3.5 introduced extension methods, and with them came a subtler form of adapter that doesn't require a wrapper class at all. Extension methods let you add methods to a type you cannot modify — which is exactly what an adapter does, just without the indirection layer. The canonical example is LINQ itself: .AsEnumerable(), .Select(), .Where() all adapt IEnumerable<T> sequences into a query pipeline without any wrapper object.
// Extension method as a lightweight adapter
// Imagine a legacy API that returns DataTable — you need IEnumerable<OrderRow>
public static class DataTableAdapterExtensions
{
// This extension "adapts" DataTable to IEnumerable<OrderRow>
// without creating a wrapper class — the method IS the adapter logic
public static IEnumerable<OrderRow> AsOrderRows(this DataTable table)
{
foreach (DataRow row in table.Rows)
{
yield return new OrderRow
{
OrderId = (int)row["OrderId"],
Amount = (decimal)row["Amount"],
CreatedAt = (DateTime)row["CreatedAt"]
};
}
}
}
// Call site — looks like a native method on DataTable
DataTable raw = db.GetOrdersRaw();
IEnumerable<OrderRow> orders = raw.AsOrderRows(); // adapter call, no class needed
This is still an Adapter structurally — it translates one API shape (DataTable rows) into another (typed OrderRow objects) — but it skips the class declaration entirely. The trade-off: extension-method adapters work well for simple data transformations but can't implement an interface. When you need the call site to treat your adapter as an IPaymentProcessor, you still need a full adapter class. Extension methods are best for "make this data look like that data" scenarios.
Era 4 — .NET Core / ASP.NET Core (2016+): Dependency Injection Makes Adapter Wiring Trivial
.NET Core shipped a built-in DI container as a first-class citizen. This changed how adapters get wired into applications. Instead of constructing your adapter manually and passing it around, you register it in the DI container once, and every class that depends on the target interface automatically receives the adapter.
// .NET Core / .NET 5+ DI wiring — Program.cs or Startup.cs
var builder = WebApplication.CreateBuilder(args);
// Register the adaptee (PayWizard SDK client)
builder.Services.AddSingleton<PayWizardClient>();
// Register the adapter as the implementation of IPaymentProcessor
// DI container will auto-inject PayWizardClient into PayWizardAdapter's ctor
builder.Services.AddScoped<IPaymentProcessor, PayWizardAdapter>();
// Now every class that depends on IPaymentProcessor automatically gets
// PayWizardAdapter — zero manual construction anywhere
var app = builder.Build();
// OrderService constructor just declares what it needs
public class OrderService
{
private readonly IPaymentProcessor _payments;
// DI injects PayWizardAdapter here automatically
public OrderService(IPaymentProcessor payments) => _payments = payments;
}
The DI container eliminates the "wiring tax" — you don't have to write a factory or composition root that manually constructs your adapter chain. More importantly, it makes swapping adapters trivial: changing from PayWizardAdapter to StripeAdapter is a one-line change in Program.cs. Every consumer is automatically updated. This is why Adapter + DI is the dominant pattern in modern .NET microservices.
Era 5 — .NET 8+ (2023+): Primary Constructors and Source Generators
.NET 8 introduced primary constructors in C# 12, which dramatically reduces adapter boilerplate. The field declaration and constructor body that took 6 lines in .NET 1.0 now takes zero — the constructor parameter becomes implicitly available throughout the class.
// .NET 8 / C# 12 — primary constructor eliminates boilerplate
// Compare to the 15-line .NET 1.0 version above
public class PayWizardAdapter(PayWizardClient client) : IPaymentProcessor
{
public async Task<PaymentResult> Charge(decimal amount, string cardId)
{
var response = await client.SubmitTransactionAsync(new PaymentRequest
{
Cents = (long)(amount * 100),
CardToken = cardId,
Currency = "USD"
});
return new PaymentResult(response.Approved, response.TransactionId);
}
public async Task Refund(string transactionId) =>
await client.ReverseTransactionAsync(transactionId, "customer_request");
}
// With source generators (Mapperly example) — for pure data mapping adapters,
// the ENTIRE implementation is auto-generated from an interface declaration:
[Mapper]
public partial class OrderDtoMapper
{
public partial OrderDto ToDto(Order order); // implementation generated
public partial Order FromDto(OrderDto dto); // implementation generated
}
Source generators like Mapperly and AutoMapper take this further: for the common case of "map object type A to object type B," you declare the interface and the generator writes the full adapter class at compile time. This is especially useful for the adapter variant that just reshapes one DTO into another, which is arguably the most frequent adapter use case in web APIs.
Adapter has been in .NET since day one as COM interop, but the boilerplate has steadily shrunk: verbose hand-written wrappers (.NET 1.0) → type-safe generic adapters (.NET 2.0) → extension-method mini-adapters (3.5/LINQ) → trivial DI wiring (.NET Core) → primary constructors and source generators (.NET 8). The structural intent never changed; only the ceremony did.
Section 8
Framework Map — Where .NET Uses Adapter
Knowing the Adapter pattern in theory is fine. Recognizing it in code you already use daily is better — it wires the abstraction to something concrete in your memory. The .NET BCL and ecosystem are full of adapters, and once you see them, you can't unsee them.
Each node above is a built-in .NET Adapter. The accordion below walks through each one with code and an explanation of exactly which parts map to Target / Adaptee / Adapter.
1 — StreamReader / StreamWriter (System.IO)
When you open a file in .NET, the lowest-level object you get is just a flowing sequence of raw bytes — no idea what a letter, a word, or a line is. That low-level byte sequence is called a Stream. It knows nothing about characters, encoding, or line endings. But most application code wants to work with strings: read a line of text, write a JSON string, process a CSV row. StreamReader is an Adapter that wraps a byte Stream (the Adaptee) and exposes a text-reading interface (the Target) with methods like ReadLine() and ReadToEnd().
// StreamReader IS the Adapter.
// Target interface (what callers want): TextReader (ReadLine, ReadToEnd, etc.)
// Adaptee: Stream (raw bytes, no text concept)
using var fileStream = new FileStream("orders.csv", FileMode.Open);
// Adapter wraps the adaptee (fileStream) and adds encoding awareness
using var reader = new StreamReader(fileStream, Encoding.UTF8);
// Caller uses text-reader interface — has no idea bytes are involved
string? line;
while ((line = reader.ReadLine()) != null) // Target API call
{
ProcessCsvLine(line);
}
// reader internally calls fileStream.Read(byte[], ...) on every ReadLine()
This is an Adapter because: the Target is TextReader (the abstract class StreamReader extends), the Adaptee is the underlying Stream, and the Adapter is StreamReader itself — it implements the TextReader interface by calling Stream.Read internally and applying the encoding translation. StreamWriter is the exact mirror image in the write direction.
DataSet is an in-memory relational database — tables, rows, relationships — all managed objects. But a database server speaks SQL over a network connection via IDbCommand and result sets. DbDataAdapter bridges these two worlds: it takes a SQL SELECT command and fills a DataSet, and it takes a DataSet with changes and generates INSERT/UPDATE/DELETE commands to push them back. It adapts the "I want an in-memory table" interface to the "I can execute SQL commands" interface.
// SqlDataAdapter IS the Adapter.
// Target: DataSet/DataTable (in-memory relational model)
// Adaptee: SqlConnection + IDbCommand (database connection protocol)
using var connection = new SqlConnection(connectionString);
using var adapter = new SqlDataAdapter(
"SELECT * FROM Orders WHERE CustomerId = @id", connection);
adapter.SelectCommand!.Parameters.AddWithValue("@id", customerId);
var dataset = new DataSet();
adapter.Fill(dataset, "Orders"); // Adapter translates SQL result → DataTable
DataTable orders = dataset.Tables["Orders"]!;
// orders is now a plain in-memory table — no SQL, no connection, just data
Without DataAdapter you would have to write the loop yourself: open command, execute reader, create DataTable, add columns based on schema, loop rows, add DataRows — about 30 lines of repetitive plumbing. The adapter encapsulates that translation so every call site can treat the database as if it were just a table in memory.
MEL (Microsoft.Extensions.Logging) defines ILogger as the target interface. Your application code only knows about ILogger.LogInformation(), ILogger.LogError(), and so on. The actual log destination — whether that is a console window, a Serilog rolling file, NLog, Application Insights, or all four — is wired up via Provider classes that adapt MEL's interface to the concrete library's API.
// Serilog adapter (from Serilog.Extensions.Logging package)
// Target: ILogger (MEL interface that application code calls)
// Adaptee: Serilog.ILogger (completely different API shape)
// In Program.cs — register the adapter:
Log.Logger = new LoggerConfiguration()
.WriteTo.Console()
.WriteTo.File("app.log", rollingInterval: RollingInterval.Day)
.CreateLogger();
builder.Logging.ClearProviders();
builder.Logging.AddSerilog(); // AddSerilog() registers a SerilogLoggerProvider adapter
// Application code never knows Serilog exists — it just uses ILogger<T>
public class OrderService(ILogger<OrderService> logger)
{
public void ProcessOrder(int id)
{
logger.LogInformation("Processing order {OrderId}", id);
// ^ this call goes through the adapter, which calls Serilog.ILogger.Information()
}
}
SerilogLoggerProvider is the Adapter class. It implements MEL's ILoggerProvider (Target), holds a reference to Serilog's ILogger (Adaptee), and translates MEL log level enums, message templates, and structured parameters into Serilog's format. Every third-party .NET logging library ships one of these adapters — because the alternative is every application developer learning two different logging APIs.
4 — COM Interop RCW / CCW (System.Runtime.InteropServices)
COM interop is the largest built-in use of the Adapter pattern in all of .NET. Every time you use [ComImport] or call an Office automation API from C#, you are using a machine-generated Adapter class called a Runtime Callable Wrapper (RCW). When COM code calls back into .NET, a COM Callable Wrapper (CCW) is generated instead.
// COM interop adapter — automating Microsoft Excel from C#
// The Excel.Application type below is actually an RCW adapter generated by tlbimp.exe
// from the Excel COM type library
// Add reference: Microsoft.Office.Interop.Excel
using Excel = Microsoft.Office.Interop.Excel;
var excel = new Excel.Application(); // creates an RCW wrapping the COM object
excel.Visible = true;
var workbook = excel.Workbooks.Add(); // RCW adapter translates to COM vtable call
var worksheet = (Excel.Worksheet)workbook.Sheets[1];
worksheet.Cells[1, 1] = "Hello from .NET"; // RCW marshals the string to BSTR
workbook.SaveAs(@"C:\temp\output.xlsx");
excel.Quit();
// Marshal.ReleaseComObject releases the COM reference count — must do this
// because GC doesn't know about COM reference counting
System.Runtime.InteropServices.Marshal.ReleaseComObject(excel);
Excel.Application looks like a normal .NET class — but it's an RCW adapter. Every property access and method call is translated to a COM vtable call, COM's HRESULT error codes are translated to .NET exceptions, and COM's reference-counting memory model is hidden behind .NET's GC. The entire 200+ method Excel API is adapted, line by line, by a generated class. This is why forgetting to call Marshal.ReleaseComObject causes Excel to stay open in Task Manager — the COM reference count never reaches zero because the adapter holds it.
5 — Non-Generic to Generic Collection Adapters
The .NET 1.x collection types — ArrayList, Hashtable, SortedList — don't implement the generic IList<T>, IDictionary<K,V>, or IEnumerable<T> interfaces. When you're working with legacy code that returns these non-generic collections but you need to plug them into modern LINQ queries or generic APIs, you need an adapter. ArrayList.Cast<T>() is a built-in LINQ adapter. CollectionsMarshal provides low-level adapters between generic collection internals and span-based APIs.
// Scenario: legacy method returns ArrayList, modern code needs IList<string>
// Option 1: LINQ adapter (functional, works for iteration)
System.Collections.ArrayList legacyList = GetLegacyData(); // returns non-generic ArrayList
// Cast<T> is a built-in adapter — adapts IEnumerable (non-generic) to IEnumerable<string>
IEnumerable<string> modern = legacyList.Cast<string>();
string first = modern.First(); // LINQ works now
// Option 2: CollectionsMarshal — adapter to get Span<T> from List<T> internals
// (avoids copying when you need high-perf array access)
var list = new List<int> { 1, 2, 3, 4, 5 };
Span<int> span = System.Runtime.InteropServices.CollectionsMarshal.AsSpan(list);
// span is now a zero-copy view into list's internal array — adapter with no allocation
// Option 3: custom wrapper adapter (when you need to implement IList<T> fully)
var adapted = new NonGenericListAdapter<string>(legacyList);
SomeModernMethod(adapted); // passes as IList<string>
Cast<T>() is a Adapter because it wraps the non-generic IEnumerable (Adaptee) and returns a generic IEnumerable<T> (Target) without copying data — it's a lazy sequence adapter that casts elements on demand. CollectionsMarshal.AsSpan adapts the List<T> interface to a Span<T> view — a zero-allocation adapter that's critical in high-performance .NET code like ASP.NET Core's request processing pipeline.
.NET ships Adapter everywhere you look: StreamReader/Writer adapts bytes to text, DbDataAdapter bridges DataSets to SQL, ILogger providers adapt MEL to Serilog/NLog, RCW/CCW adapts managed .NET to COM, and collection helpers like Cast<T> adapt non-generic sequences to generic ones. Recognizing these patterns in daily code cements the abstraction far better than any textbook example.
Section 9
When To Use Adapter — Decision Framework
Adapter is a precise tool for a specific problem. The most common mistake is using it when you don't need it — wrapping code just because wrapping feels "architecturally correct." Before writing an adapter class, run through these criteria. The rule of thumb: if you can change one of the two interfaces, change it. Adapter is the tool you reach for when you genuinely cannot change either side.
The flowchart captures the key decision points. A full Adapter class is only warranted when all three questions produce "no, no, yes" — you don't control the interfaces, it's not a one-off, and you need a proper interface contract (typically for DI or unit testing). If any question resolves earlier, a lighter approach is better.
Quick-check heuristic: If you can answer YES to all three of these, write an Adapter: (1) I cannot change the Adaptee's interface. (2) I cannot change the callers' expected interface. (3) This translation will happen in more than one place, or I need to mock it in tests.
Use Adapter when you face an interface mismatch you cannot resolve by changing either side — typically vendor SDKs, legacy code, multi-vendor abstraction, test seams, or async-to-sync bridging. Skip it when you control both sides, when it's a one-off, when you're adding behavior (that's Decorator), or when you're simplifying a subsystem (that's Facade).
Section 10
Adapter vs Facade vs Decorator vs Strategy
These four patterns are the most commonly confused group in all of GoF. In interviews, "what's the difference between Adapter and Decorator?" is almost a guaranteed question. The confusion is understandable — they all involve wrapping one class inside another. But the intent is completely different, and intent is what GoF patterns are really about.
The single clearest distinguishing question is: "What happens to the interface?" Adapter changes it. Decorator preserves it. Facade replaces many with one simpler one. Strategy has no interface wrapping at all — it's about choosing between implementations of the same interface. Let's go deeper on each comparison.
Adapter vs Facade
Both adapt "what you have" to "what you want" — but the scope is completely different. An Adapter wraps one thing and translates its interface. A Facade wraps many things and creates a new, simpler interface on top. Another way to think about it: Adapter is about making an existing interface compatible; Facade is about hiding complexity behind a new interface you design from scratch.
AdapterFacadeWraps ONE classWraps MANY classesTranslates existing interfaceCreates a new simpler interfaceClient doesn't know Adaptee existsClient knows it's a simplified viewInterface shape changes (A → B)Interface shape simplifies (N → 1)Use when: vendor SDK mismatchUse when: complex subsystem
Concrete example: wrapping PayWizardClient.SubmitTransaction() to look like IPaymentProcessor.Charge() is an Adapter — one class, one interface translation. Creating an OrderFacade that internally calls InventoryService, PaymentService, ShippingService, and EmailService so callers just invoke orderFacade.PlaceOrder(cart) — that's a Facade.
Adapter vs Decorator
This is the most commonly confused pair. Both wrap a single class. Both use composition. The critical difference: Decorator preserves the interface — input shape equals output shape. Adapter changes the interface — input shape does not equal output shape. Decorator is for adding behavior; Adapter is for translating interfaces.
AdapterDecoratorChanges the interfacePreserves the interfaceIn: IAdaptee → Out: ITargetIn: IComponent → Out: IComponentCaller sees different API shapeCaller sees identical API shapeGoal: compatibilityGoal: additional behaviorExample: wrap PayWizard as IPaymentProcessorExample: add retry + logging to IPaymentProcessor
The litmus test: if the caller's code doesn't change after the wrapping — if it still calls the exact same method names on the exact same interface — it's a Decorator. If the caller's code changes because it now uses a different interface than before — it's an Adapter.
Adapter vs Strategy
Strategy is the odd one out: it's not a structural pattern at all, it's behavioral. Strategy isn't about wrapping or translating — it's about choosing between different algorithm implementations at runtime. All Strategy implementations share the same interface. No translation happens. The difference:
AdapterStrategyStructural patternBehavioral patternSolves: interface mismatchSolves: algorithm selectionInvolves translation/mappingNo translation — same interfaceClient doesn't choose adapteeClient chooses which strategyExample: make PayWizard look like IPaymentProcessorExample: swap BubbleSort for QuickSort behind ISorter
A nuance worth noting: when you use Adapter to wrap multiple vendor implementations behind one interface (StripeAdapter, PayWizardAdapter, both implementing IPaymentProcessor), you often also use Strategy to select which adapter is active at runtime. The patterns compose well — Adapter handles the interface translation, Strategy handles the runtime selection. They are not alternatives in this scenario; they play different roles.
The one-sentence rule of thumb: If the wrapping changes the interface, it's Adapter. If the wrapping preserves the interface but adds behavior, it's Decorator. If the wrapping hides multiple objects behind one simpler one, it's Facade. If there's no wrapping at all and you're just choosing between implementations, it's Strategy.
Adapter changes the interface (A→B), Decorator preserves it with added behavior, Facade collapses many objects into one simpler interface, and Strategy selects between interchangeable algorithm implementations with no wrapping at all. The key differentiator is always: what happens to the interface shape?
Section 11
Modern Variations — Two-Way, Pluggable, and Async Adapters
Classic Object Adapter is the 95% case: one adaptee, one target interface, one-way translation. But real systems sometimes need more. Three variations come up regularly in production C# codebases: the Two-Way Adapter (bridge bidirectionally), the Pluggable Adapter (configure translation logic at runtime), and the Async Adapter (bridge sync ↔ async). Each solves a real problem and each comes with its own set of traps.
Two-Way Adapter — Implementing Both Interfaces Simultaneously
Sometimes two systems need to interop bidirectionally — System A needs to use System B's API, and System B also needs to use System A's API. The naive approach is two separate adapter classes, which works but creates an awkward situation: the object that bridges A and B needs to exist twice, and coordinating state between the two adapters can get messy. The Two-Way Adapter solves this by implementing both interfaces on a single class. It is simultaneously an A and a B.
// Two-Way Adapter — one class implements BOTH interfaces
// Scenario: new MEL-based code and old legacy code must share one logger
// New interface (Microsoft.Extensions.Logging)
public interface INewLogger
{
void LogInformation(string message, params object[] args);
void LogError(Exception ex, string message, params object[] args);
}
// Old legacy interface (can't change — it's in a third-party library)
public interface ILegacyLogger
{
void Write(string message, int severity); // severity: 0=info, 1=warn, 2=error
void WriteException(string message, Exception ex);
}
// Two-Way Adapter implements BOTH
public class TwoWayLoggerBridge(Serilog.ILogger serilog) : INewLogger, ILegacyLogger
{
// INewLogger side — new code calls this
public void LogInformation(string message, params object[] args)
=> serilog.Information(message, args);
public void LogError(Exception ex, string message, params object[] args)
=> serilog.Error(ex, message, args);
// ILegacyLogger side — old code calls this
public void Write(string message, int severity)
{
if (severity == 2) serilog.Error(message);
else if (severity == 1) serilog.Warning(message);
else serilog.Information(message);
}
public void WriteException(string message, Exception ex)
=> serilog.Error(ex, message);
}
// Usage: register once, works for both worlds
var bridge = new TwoWayLoggerBridge(Log.Logger);
INewLogger newSide = bridge; // new code sees INewLogger
ILegacyLogger legacySide = bridge; // old code sees ILegacyLogger
Notice that TwoWayLoggerBridge holds one underlying resource (serilog) and both interface implementations delegate to it. This is the key structural characteristic: the bridge object is a single identity that can play two roles. The practical advantage is that both sides share state — if one side enables debug mode, the other side automatically reflects that, because there's only one object.
Pluggable Adapter — Delegate-Based Translation at Construction Time
A classic adapter has the translation logic hardcoded in its methods. That works fine when you know exactly how to translate from A to B. But what if you need the same adapter shape with different translation rules depending on context — for example, different payment processors that all need wrapping but each with its own field mappings? The Pluggable Adapter accepts translation delegates at construction time, making the mapping data-driven.
// Pluggable Adapter — translation logic injected via Func delegates
// Useful when you have many adaptees with similar shapes but different mappings
public class PluggablePaymentAdapter : IPaymentProcessor
{
private readonly Func<decimal, string, Task<PaymentResult>> _chargeImpl;
private readonly Func<string, Task> _refundImpl;
// Translation logic is injected, not hardcoded
public PluggablePaymentAdapter(
Func<decimal, string, Task<PaymentResult>> chargeImpl,
Func<string, Task> refundImpl)
{
_chargeImpl = chargeImpl;
_refundImpl = refundImpl;
}
public Task<PaymentResult> Charge(decimal amount, string cardId)
=> _chargeImpl(amount, cardId);
public Task Refund(string transactionId)
=> _refundImpl(transactionId);
}
// Wiring: plug in PayWizard's translation
var payWizardClient = new PayWizardClient();
IPaymentProcessor payWizard = new PluggablePaymentAdapter(
chargeImpl: async (amount, cardId) =>
{
var resp = await payWizardClient.SubmitTransactionAsync(new PaymentRequest
{ Cents = (long)(amount * 100), CardToken = cardId, Currency = "USD" });
return new PaymentResult(resp.Approved, resp.TransactionId);
},
refundImpl: txId => payWizardClient.ReverseTransactionAsync(txId, "refund")
);
// Wiring: plug in Stripe's translation (different rules, same adapter class)
var stripeClient = new StripeClient(apiKey);
IPaymentProcessor stripe = new PluggablePaymentAdapter(
chargeImpl: async (amount, cardId) => { /* Stripe-specific translation */ ... },
refundImpl: txId => { /* Stripe refund logic */ ... }
);
The trade-off: Pluggable Adapter reduces class proliferation (one adapter class instead of N) but moves the translation logic into anonymous lambdas, which can be harder to test in isolation and harder to read when the translation is complex. Use it when translations are short (5-10 lines each) and the variations are many. When translations are complex, a dedicated class per vendor is clearer.
Async Adapter — Bridging Sync and Async Boundaries
Legacy code is often synchronous. Modern .NET APIs are often async. When you integrate a sync SDK into an async service, you need an Async Adapter. The pattern is straightforward conceptually — wrap the sync call in a Task. The execution is where things get dangerous. There are two ways to do this, and one of them will cause deadlocks in certain environments.
// Async Adapter — wrapping a sync SDK behind an async interface
// The adaptee: a synchronous legacy SDK
public class LegacyPaymentGateway
{
public PaymentResponse ProcessPayment(string cardNumber, decimal amount)
{
// Synchronous — makes network call, blocks the thread
Thread.Sleep(200); // simulates network latency
return new PaymentResponse { Success = true, TransactionId = Guid.NewGuid().ToString() };
}
}
// BAD: Don't do this — .Result can deadlock in ASP.NET classic, WinForms, WPF
public class DangerousAsyncAdapter : IAsyncPaymentProcessor
{
private readonly LegacyPaymentGateway _gateway;
public DangerousAsyncAdapter(LegacyPaymentGateway gateway) => _gateway = gateway;
public Task<PaymentResult> ChargeAsync(decimal amount, string cardId)
{
// .Result blocks the current thread — can deadlock on old ASP.NET sync context
var response = Task.Run(() => _gateway.ProcessPayment(cardId, amount)).Result; // âš ï¸
return Task.FromResult(new PaymentResult(response.Success, response.TransactionId));
}
}
// GOOD: Use Task.Run + await properly
public class SafeAsyncAdapter(LegacyPaymentGateway gateway) : IAsyncPaymentProcessor
{
public async Task<PaymentResult> ChargeAsync(decimal amount, string cardId)
{
// Task.Run moves sync work to the thread pool — safe to await
var response = await Task.Run(() => gateway.ProcessPayment(cardId, amount))
.ConfigureAwait(false); // avoid capturing sync context
return new PaymentResult(response.Success, response.TransactionId);
}
}
The rule: never call .Result or .Wait() on a Task inside an async adapter. Use await Task.Run(...).ConfigureAwait(false) instead. The ConfigureAwait(false) tells the awaiter not to capture the calling synchronization context, which prevents the deadlock in environments that have one (classic ASP.NET, WinForms, WPF). In ASP.NET Core there is no synchronization context, so you'd survive without it — but add it as a habit for library code that might run anywhere.
Async adapter performance note:Task.Run moves work to the thread-pool thread, which has overhead (context switch, scheduler overhead). For very fast sync operations (<1ms), this overhead can be larger than the work itself. Profile before wrapping tiny sync operations in Task.Run. If the operation truly must stay sync and is fast, consider exposing a synchronous adapter and letting callers decide whether to offload.
Default Adapter — Implementing a Wide Interface with Defaults
A wide interface is one with many methods, most of which a given implementer doesn't care about. In Java AWT, WindowAdapter was a classic example: the WindowListener interface had 7 methods, but most components only needed to respond to 1 or 2. Without a Default Adapter, you had to implement all 7 with empty bodies every time. With a Default Adapter base class, you extend it and only override what you need.
// C# version: wide interface with many events
// In C# this is typically solved with default interface methods (C# 8+) or abstract base classes
// Wide interface — most callers only care about a few methods
public interface IDocumentEventListener
{
void OnDocumentOpened(string path) { } // default: do nothing (C# 8 default impl)
void OnDocumentSaved(string path) { }
void OnDocumentClosed(string path) { }
void OnDocumentModified(string path){ }
void OnDocumentPrinted(string path) { }
void OnDocumentShared(string path) { }
}
// Caller only cares about save events — no empty method boilerplate needed
public class AutoBackupListener : IDocumentEventListener
{
// Only override what we care about — Default Adapter handles the rest
public void OnDocumentSaved(string path)
{
BackupService.CreateBackup(path);
}
// All other methods use the default empty implementation
}
// Without default interface methods (older approach): use abstract base class
public abstract class DocumentEventListenerBase : IDocumentEventListener
{
public virtual void OnDocumentOpened(string path) { }
public virtual void OnDocumentSaved(string path) { }
public virtual void OnDocumentClosed(string path) { }
public virtual void OnDocumentModified(string path) { }
public virtual void OnDocumentPrinted(string path) { }
public virtual void OnDocumentShared(string path) { }
}
In modern C# (8.0+) the Default Adapter pattern is largely superseded by default interface method implementations — the interface itself provides the empty defaults. But the abstract base class form is still common in older codebases and in framework APIs where the base class provides useful shared infrastructure beyond just empty methods.
Beyond the classic Object Adapter: Two-Way Adapter implements both interfaces on one class for bidirectional bridging; Pluggable Adapter accepts delegate-based translation at construction, reducing class proliferation when many adaptees share the same shape; Async Adapter bridges sync SDKs to async callers (always use Task.Run + await, never .Result); Default Adapter provides empty implementations of wide interfaces so subclasses only override what they need.
Section 12
Bug Studies — Production Adapter Failures
Adapters feel safe because they're just thin wrappers — what could go wrong? Plenty. The wrapper's job is to isolate the caller from the adaptee, but when the adapter is wrong, that isolation can hide errors, corrupt data, or crash the process in ways that are extremely hard to diagnose because the symptoms surface far from the root cause. These six incidents are realistic composites from common real-world failure modes.
Bug 1 — Adapter Swallowed Exception: Silent Payment FailureIncident: A payments service processed thousands of transactions with zero reported errors for 48 hours. An end-of-day reconciliation found that 12% of charges had silently failed — the payment processor rejected them, but the service logged "success." Revenue was miscounted. Refunds had to be manually issued.
What Went Wrong. The PayWizardAdapter caught the vendor SDK's PayWizardException to do error mapping, but the catch block returned a default PaymentResult object instead of re-throwing or returning a failure result. The default object had Success = false... but the caller checked result != null instead of result.Success. Every payment went through without error. The adapter was silently eating failures.
public class PayWizardAdapter : IPaymentProcessor
{
private readonly PayWizardClient _client;
public async Task<PaymentResult> Charge(decimal amount, string cardId)
{
try
{
var resp = await _client.SubmitTransactionAsync(new PaymentRequest
{ Cents = (long)(amount * 100), CardToken = cardId });
return new PaymentResult(resp.Approved, resp.TransactionId);
}
catch (PayWizardException ex)
{
_logger.LogError(ex, "PayWizard error");
return new PaymentResult(); // âš ï¸ default ctor: Success=false, Id=null
// caller checks result != null, not result.Success → proceeds as if charged
}
}
}
// Caller — the bug's accomplice
var result = await _paymentProcessor.Charge(order.Total, order.CardId);
if (result != null) // âš ï¸ wrong check
{
order.MarkAsPaid(result.TransactionId); // called even on failure!
}public class PayWizardAdapter : IPaymentProcessor
{
private readonly PayWizardClient _client;
public async Task<PaymentResult> Charge(decimal amount, string cardId)
{
try
{
var resp = await _client.SubmitTransactionAsync(new PaymentRequest
{ Cents = (long)(amount * 100), CardToken = cardId });
return new PaymentResult(resp.Approved, resp.TransactionId);
}
catch (PayWizardException ex)
{
_logger.LogError(ex, "PayWizard charge failed for card {CardId}", cardId);
// Return explicit failure — never swallow the error silently
return PaymentResult.Failure(ex.Message);
}
}
}
// PaymentResult has an explicit factory for failures
public record PaymentResult(bool Success, string? TransactionId, string? ErrorMessage = null)
{
public static PaymentResult Failure(string reason)
=> new(false, null, reason);
}
// Caller checks the right thing
var result = await _paymentProcessor.Charge(order.Total, order.CardId);
if (!result.Success)
{
throw new PaymentFailedException($"Payment declined: {result.ErrorMessage}");
}
order.MarkAsPaid(result.TransactionId!);Lesson: When an adapter catches an exception for translation purposes, it must communicate the failure to the caller — either by re-throwing a domain exception, or by returning a result type with an explicit failure state. A default-constructed result object is never a safe "nothing went wrong" signal.
How to Spot: Look for catch blocks in adapters that return new SomeResult() or default without setting failure fields. Search for callers that check result != null instead of result.Success or similar semantic checks.
Bug 2 — Non-Thread-Safe Adaptee Captured in Singleton AdapterIncident: An API service crashed intermittently under load with NullReferenceException inside a third-party SDK class. The stack trace pointed to an internal collection being enumerated while simultaneously modified. The bug was impossible to reproduce locally and only appeared under concurrent traffic.
What Went Wrong. The adapter was registered as a Singleton in the DI container. The underlying third-party SDK client (the adaptee) was not thread-safe — it maintained internal state that assumed single-threaded access. Under concurrent requests, multiple threads accessed the same SDK client instance through the same adapter, causing race conditions on its internal state.
// Program.cs — registered as Singleton
builder.Services.AddSingleton<IPaymentProcessor, VendorAdapter>(); // âš ï¸
public class VendorAdapter : IPaymentProcessor
{
// VendorSdkClient docs say: "not thread-safe, use one instance per request"
private readonly VendorSdkClient _client = new VendorSdkClient();
public async Task<PaymentResult> Charge(decimal amount, string cardId)
{
// _client.Charge internally uses mutable fields
// Multiple threads call this concurrently → NullReferenceException
return await _client.Charge(cardId, amount);
}
}// Option A: register as Scoped (one adapter per HTTP request)
builder.Services.AddScoped<IPaymentProcessor, VendorAdapter>(); // ✅
// Option B: if Singleton is required (e.g. expensive initialization),
// create a new adaptee per call inside the adapter
public class VendorAdapter : IPaymentProcessor
{
private readonly VendorSdkOptions _options; // thread-safe config, no mutable state
public VendorAdapter(VendorSdkOptions options) => _options = options;
public async Task<PaymentResult> Charge(decimal amount, string cardId)
{
// New client per call — stateless, thread-safe
using var client = new VendorSdkClient(_options);
return await client.Charge(cardId, amount);
}
}
// Option C: use a thread-safe pool if VendorSdkClient is expensive to create
// ObjectPool<VendorSdkClient> — borrow/return from poolLesson: The adapter's lifetime must be compatible with the adaptee's thread-safety guarantee. Always check the vendor SDK's documentation for thread-safety. If it says "use one instance per request," register the adapter as Scoped, not Singleton.
How to Spot: Search for AddSingleton<..., Adapter> registrations where the adapter holds a vendor SDK client. Cross-check the SDK's docs for thread-safety. If the SDK says "not thread-safe," the singleton registration is a time bomb.
Bug 3 — Adapter Holds IDisposable Adaptee Without Implementing IDisposableIncident: A microservice's memory consumption grew by ~50MB every hour under normal load. After three hours, the process was killed by the container orchestrator for exceeding its memory limit. Heap dumps showed thousands of undisposed SDK client objects holding open HTTP connection pools and file handles.
What Went Wrong. The adapter wrapped a vendor SDK client that implemented IDisposable (it held an HttpClient internally). The adapter class itself did not implement IDisposable. Because the DI container only disposes objects that implement IDisposable, it never called Dispose() on the adaptee. Each request created a new adapter (Scoped), and each adapter created a new SDK client, none of which were ever disposed. Connection pool exhaustion followed shortly after the memory leak.
// Adapter wraps an IDisposable adaptee but doesn't implement IDisposable itself
public class VendorAdapter : IPaymentProcessor // âš ï¸ missing : IDisposable
{
private readonly VendorSdkClient _client; // VendorSdkClient : IDisposable
public VendorAdapter(VendorSdkOptions options)
{
_client = new VendorSdkClient(options); // creates HttpClient, connection pool
}
public async Task<PaymentResult> Charge(decimal amount, string cardId)
=> await _client.ChargeAsync(cardId, amount);
// No Dispose() — _client.Dispose() is NEVER called
// Each Scoped adapter leaks one VendorSdkClient
}// Fix: implement IDisposable and pass disposal through
public class VendorAdapter : IPaymentProcessor, IDisposable // ✅ add IDisposable
{
private readonly VendorSdkClient _client;
private bool _disposed;
public VendorAdapter(VendorSdkOptions options)
{
_client = new VendorSdkClient(options);
}
public async Task<PaymentResult> Charge(decimal amount, string cardId)
{
ObjectDisposedException.ThrowIf(_disposed, this);
return await _client.ChargeAsync(cardId, amount);
}
public void Dispose()
{
if (_disposed) return;
_client.Dispose(); // propagate disposal to adaptee
_disposed = true;
GC.SuppressFinalize(this);
}
}
// DI now sees IDisposable, calls Dispose() at end of request scope ✅
builder.Services.AddScoped<IPaymentProcessor, VendorAdapter>();Lesson: If your adaptee implements IDisposable, your adapter must also implement IDisposable and call adaptee.Dispose() in its own Dispose(). This is a hard rule. The adapter wraps the adaptee's lifecycle, not just its interface.
How to Spot: In code review, search for adapter classes that hold a field of type X where X implements IDisposable, but the adapter itself does not implement IDisposable. Flag it immediately.
Bug 4 — Async Adapter Used .Result: Deadlock in ProductionIncident: An endpoint that had worked fine for months suddenly hung under specific traffic patterns. Requests to POST /orders would stall indefinitely, eventually timing out after 30 seconds. The issue was 100% reproducible in staging when called from a certain legacy integration partner but never in direct testing.
What Went Wrong. An async adapter was bridging a synchronous legacy SDK. The developer had used .Result to block on the async call. In some older Microsoft web stacks (and on desktop UI threads), .NET maintains an invisible scheduling rule that says "continue this work on the exact thread that started it." That invisible rule is the synchronization context. When .Result blocked the request thread, the awaited Task's continuation needed that same thread to run — and it couldn't, because the thread was already blocked waiting for the very task it needed to resume. Console apps and ASP.NET Core don't have this context, so the bug stays hidden until your code runs somewhere that does. Classic async deadlock.
public class AsyncAdapter : IPaymentProcessor
{
private readonly SyncLegacyGateway _gateway;
public Task<PaymentResult> Charge(decimal amount, string cardId)
{
// âš ï¸ .Result blocks the current thread and captures sync context
// Deadlocks in any environment with a SynchronizationContext
var result = Task.Run(
() => _gateway.ProcessPayment(cardId, amount)).Result; // 💣
return Task.FromResult(new PaymentResult(result.Success, result.TransactionId));
}
}public class AsyncAdapter : IPaymentProcessor
{
private readonly SyncLegacyGateway _gateway;
// Method is now truly async — await instead of .Result
public async Task<PaymentResult> Charge(decimal amount, string cardId)
{
// Task.Run offloads sync work to thread pool
// ConfigureAwait(false) prevents sync context capture — safe everywhere
var result = await Task.Run(
() => _gateway.ProcessPayment(cardId, amount))
.ConfigureAwait(false); // ✅ key fix
return new PaymentResult(result.Success, result.TransactionId);
}
}Lesson: Never use .Result or .Wait() to block on a Task inside an async method or request handler. Always await. Add .ConfigureAwait(false) in library/adapter code that may run under any sync context.
How to Spot: Search for .Result and .Wait() anywhere in adapter classes. Any occurrence in a context that could have a SynchronizationContext is a latent deadlock. Tools like Roslyn analyzers (CA2007, AsyncFixer) catch this at compile time.
Bug 5 — Two-Way Adapter Semantic Mismatch: Silent Data CorruptionIncident: A financial reporting service began producing reports with incorrect transaction amounts. The numbers were off by a factor of 100 in some rows. The error appeared in month-end reports but not in real-time dashboards, which read from a different source. Investigation took four days.
What Went Wrong. A Two-Way Adapter bridged a legacy accounting system (which measured amounts in cents as integers) and a new reporting system (which measured amounts in dollars as decimals). The adapter correctly translated dollars→cents in one direction. But in the reverse direction — when the legacy system wrote back through the adapter — the adapter forgot to convert cents→dollars. The method name was the same (RecordAmount), but the semantic meaning of the value was different depending on which interface "face" the caller was using. The adapter had a semantic mismatch baked in.
// Two-Way Adapter with semantic mismatch on the reverse direction
public class TwoWayAccountingAdapter : INewAccountingSystem, ILegacyAccountingSystem
{
private readonly LegacyAccountingClient _legacy;
// New system → Legacy: dollar to cents conversion ✅
void INewAccountingSystem.RecordAmount(decimal dollars)
=> _legacy.RecordAmount((long)(dollars * 100)); // correct
// Legacy system → report: âš ï¸ forgets to convert cents back to dollars
void ILegacyAccountingSystem.RecordAmount(long cents)
=> _reportingDb.Insert(new Transaction { Amount = cents }); // âš ï¸ cents stored as if dollars
// 1250 cents → stored as $1250 instead of $12.50
}public class TwoWayAccountingAdapter : INewAccountingSystem, ILegacyAccountingSystem
{
private readonly LegacyAccountingClient _legacy;
void INewAccountingSystem.RecordAmount(decimal dollars)
=> _legacy.RecordAmount((long)(dollars * 100)); // dollars → cents ✅
void ILegacyAccountingSystem.RecordAmount(long cents)
=> _reportingDb.Insert(new Transaction
{
Amount = cents / 100m // ✅ cents → dollars
});
}
// Tip: make unit explicit in type or method name to prevent future confusion
public record MoneyAmount(decimal Dollars)
{
public long ToCents() => (long)(Dollars * 100);
public static MoneyAmount FromCents(long cents) => new(cents / 100m);
}Lesson: In Two-Way Adapters, every method that exists on both sides must have its translation reviewed independently for each direction. The method name being the same does not mean the semantics are the same. Document units in type names or variable names, not just comments.
How to Spot: In code review of Two-Way Adapters, trace every method pair manually: "when called from side A, what conversion happens to the value before it reaches side B? And in reverse?" If any direction has no conversion on a value that has different units on each side, flag it.
Bug 6 — Adapter Caching a Result That Depended on Mutable Adaptee StateIncident: A product catalog service showed users stale prices for up to 10 minutes after a price update was published. The issue was intermittent — some users saw stale data, others saw current data. Price-sensitive promotions were misfiring, causing incorrectly discounted orders and compliance complaints.
What Went Wrong. An adapter wrapped a pricing SDK and added in-process caching to avoid calling the (slow) SDK on every request. The cache key was based only on the product ID. But the adapter didn't know that the underlying adaptee's pricing rules could change due to external events (promotion engine, time-of-day pricing, A/B tests). The adaptee's state was mutable — prices changed without the adapter knowing — but the adapter held a stale cache hit for up to 10 minutes. The adapter's caching assumption (product ID uniquely identifies a price) was invalid.
public class PricingAdapter : IPricingService
{
private readonly PricingSdkClient _sdk;
private readonly IMemoryCache _cache;
public async Task<decimal> GetPrice(string productId)
{
// Cache key = productId only
// âš ï¸ Assumes price only changes by productId — ignores time-of-day, promo state
if (_cache.TryGetValue(productId, out decimal cached))
return cached; // returns stale price if promo changed in last 10 min
var price = await _sdk.FetchCurrentPriceAsync(productId);
_cache.Set(productId, price, TimeSpan.FromMinutes(10)); // 10 min = too long
return price;
}
}public class PricingAdapter : IPricingService
{
private readonly PricingSdkClient _sdk;
private readonly IMemoryCache _cache;
public async Task<decimal> GetPrice(string productId)
{
// Option A: drastically reduce TTL for price-sensitive data
if (_cache.TryGetValue(productId, out decimal cached))
return cached;
var price = await _sdk.FetchCurrentPriceAsync(productId);
_cache.Set(productId, price, TimeSpan.FromSeconds(15)); // short TTL ✅
return price;
}
// Option B: implement cache invalidation when SDK publishes change events
// Subscribe to the SDK's OnPriceChanged event and bust cache
public void OnAdapteeInitialized()
{
_sdk.OnPriceChanged += (productId, _) =>
_cache.Remove(productId); // invalidate on change event ✅
}
// Option C: don't cache in the adapter at all
// Let the caller decide caching strategy for their use case
// Adapter's job is translation, not caching
}Lesson: An adapter should generally not cache results that depend on adaptee state it cannot observe. The adapter's job is translation, not caching. If caching is needed, add a separate caching layer above the adapter (a Decorator or a cache service), and make the cache key include all dimensions that can change the result (including time, user context, promotion state).
How to Spot: Look for adapters that hold an IMemoryCache or Dictionary field and cache results. Ask: "Can the adaptee's answer for the same inputs change between calls?" If yes, the cache key must capture all changing dimensions, or caching must be removed from the adapter entirely.
Six production failure modes: swallowing exceptions returns a false-success result; registering a non-thread-safe adaptee as Singleton causes race conditions; not implementing IDisposable when the adaptee is IDisposable causes resource leaks; using .Result in async adapters deadlocks under synchronization contexts; Two-Way Adapters can silently corrupt data when method semantics differ by direction; caching in adapters produces stale results when adaptee state changes externally. Each failure is invisible in normal tests and only surfaces under realistic production conditions.
Section 13
Pitfalls & Anti-Patterns — 6 Mistakes That Bite in Production
The Adapter pattern looks simple — wrap a class, translate methods, done. But most production bugs happen not because the idea was wrong, but because the wrapper was written carelessly. Here are the six most common mistakes, each with a real example of what goes wrong and the exact fix.
The Mistake: The adapter catches exceptions from the adaptee and converts them to null returns, boolean flags, or empty objects — to "simplify the API." The caller never finds out that something went wrong.
That diagram shows the damage: the adapter catches the TimeoutException from PayWizard and returns null. The caller eventually blows up with a NullReferenceException three method calls later — with no stack trace pointing back to PayWizard. Debugging this can take hours.
Why It's Bad: The adapter is a boundary, not a black hole. Consumers need to know what went wrong so they can retry, alert, or show the right error message to the user. Swallowing exceptions turns every error into a mystery.
The Fix: Translate exceptions into your domain's typed exceptions. If PayWizard times out, throw a PaymentGatewayTimeoutException. If the card is declined, throw CardDeclinedException. The caller can catch these domain-specific types and react correctly.
public async Task<PaymentResult> Charge(decimal amount, string cardId)
{
try
{
var resp = await _client.SubmitTransactionAsync(new PaymentRequest
{
Cents = (int)(amount * 100),
CardToken = cardId
});
return resp != null ? new PaymentResult(resp.TxId) : null; // ← null-on-error
}
catch (Exception)
{
return null; // ← swallowed! Caller has no idea what happened
}
}public async Task<PaymentResult> Charge(decimal amount, string cardId)
{
try
{
var resp = await _client.SubmitTransactionAsync(new PaymentRequest
{
Cents = (int)(amount * 100),
CardToken = cardId
});
return new PaymentResult(resp.TxId);
}
catch (PayWizardTimeoutException ex)
{
throw new PaymentGatewayTimeoutException("PayWizard timed out", ex);
}
catch (PayWizardDeclineException ex)
{
throw new CardDeclinedException(ex.DeclineCode, ex);
}
// Let unexpected exceptions propagate — don't swallow what you don't understand
}
The good version translates each known PayWizard exception type into a domain exception your application already understands. Unknown exceptions propagate naturally so you get a real stack trace. The caller can now catch (CardDeclinedException) and show the user a helpful message instead of silently failing.
The Mistake: The adapter's return types or exception messages contain vendor-specific vocabulary — PayWizard status codes, AWS error strings, Stripe idempotency keys — leaking through to callers that are supposed to know nothing about the vendor.
When OrderService starts checking if (result.Code == 4201), it has secretly coupled itself to PayWizard's internal status code vocabulary. If you switch to Stripe next year, every one of those checks breaks — and they're scattered across your entire codebase.
Why It's Bad: The whole point of the adapter is that the caller knows nothing about the vendor. The moment vendor details escape, the adapter has failed its only job. Switching vendors now requires touching every caller.
The Fix: Define your domain's result vocabulary — PaymentStatus.Declined, PaymentStatus.Approved, PaymentStatus.InsufficientFunds — and translate in the adapter. The caller speaks your domain; the adapter speaks PayWizard.
// Leaky: returns vendor-specific status integer
public record PaymentResult(string TxId, int VendorStatusCode);
// Now caller writes:
if (result.VendorStatusCode == 4201) { /* PayWizard-specific magic number */ }// Domain vocabulary — no vendor terms
public enum PaymentStatus { Approved, Declined, InsufficientFunds, GatewayError }
public record PaymentResult(string TxId, PaymentStatus Status);
// In adapter: translate PayWizard codes → domain enum
var status = resp.StatusCode switch
{
4201 => PaymentStatus.Declined,
4202 => PaymentStatus.InsufficientFunds,
200 => PaymentStatus.Approved,
_ => PaymentStatus.GatewayError
};
return new PaymentResult(resp.TxId, status);
Now OrderService checks if (result.Status == PaymentStatus.Declined). When you switch to Stripe, only the adapter changes — the status codes get re-mapped, and OrderService keeps working untouched.
The Mistake: Every time two systems need to talk, you write a dedicated adapter just for that pair. With 5 systems, you need 20 adapters (every A→B combination). With 10 systems, you need 90. The codebase becomes a maze of point-to-point translation classes.
The right side shows the fix: introduce one canonical intermediate model (a Domain Event or shared DTO). Each system writes exactly one adapter: System → Canonical. Now adding a new system costs exactly one adapter, not N adapters for every existing system.
The Fix in Code: Instead of OrdersToPaymentsAdapter, OrdersToInventoryAdapter, etc., define a DomainEvent and write OrdersDomainEventAdapter + PaymentsDomainEventAdapter. When Orders publishes, it publishes a DomainEvent. When Payments subscribes, it reads a DomainEvent. Neither knows the other exists.
The Mistake: You register the adapter as AddSingleton<IPaymentProcessor, PayWizardAdapter>(). But the adaptee (PayWizardClient) was designed to be created per-request. It holds per-connection state, a non-thread-safe internal buffer, or a single HTTP connection. Multiple threads now race through the same adaptee instance.
All three concurrent HTTP requests share the samePayWizardClient instance. If PayWizard's client buffers request data in an instance field before sending, two requests can corrupt each other's data. The symptom is random, hard-to-reproduce bugs under load — the worst kind.
Why It's Bad: DI lifetime mismatches are silent until you're under load. Unit tests always pass (single-threaded), so the bug only shows up in staging or production.
The Fix: Match the adapter's DI lifetime to what the adaptee actually supports. If the adaptee must be per-request, register the adapter as AddScoped. If thread-safe (like HttpClient), AddSingleton is fine. When in doubt, read the vendor's threading docs — they always document this.
// services.cs — lifetime mismatch
services.AddSingleton<IPaymentProcessor, PayWizardAdapter>();
// PayWizardClient internally buffers per-request state — not thread-safe!// Option A: Scoped — one adapter (and one PayWizardClient) per HTTP request
services.AddScoped<IPaymentProcessor, PayWizardAdapter>();
// Option B: if PayWizardClient is thread-safe (e.g., wraps HttpClient with no instance state)
services.AddSingleton<IPaymentProcessor, PayWizardAdapter>(); // only if vendor docs confirm this
// Option C: if unsure, add locking in the adapter itself
private readonly SemaphoreSlim _lock = new(1, 1);
public async Task<PaymentResult> Charge(decimal amount, string cardId)
{
await _lock.WaitAsync();
try { /* call adaptee */ }
finally { _lock.Release(); }
}
Option A (Scoped) is the safest default. Option B is fine only if the vendor explicitly documents thread safety. Option C is a last resort — the locking serializes all payments requests, which hurts throughput. Always prefer the right lifetime over bolting on locks.
The Mistake: The target interface is synchronous but the adaptee is async. The developer bridges the gap with .Result or .Wait() — which blocks a thread while waiting for the async operation to complete. In ASP.NET Core, this can deadlock because the synchronization context is captured and the continuation can never resume.
Thread A calls .Result and blocks while holding the synchronization context. The async continuation, when ready, tries to resume on the same synchronization context — but it's blocked. Neither can proceed. The request hangs until it times out.
The Fix: Make the interface async (Task<PaymentResult> ChargeAsync(...)) end-to-end. If you genuinely must expose a sync API (e.g., implementing an old interface you can't change), use ConfigureAwait(false) to avoid capturing the context, or better, provide an explicitly synchronous implementation that does not use async internally.
// Synchronous target interface — adapter uses .Result to bridge: DANGER
public PaymentResult Charge(decimal amount, string cardId)
{
// This blocks the thread AND captures the sync context → deadlock in ASP.NET Core
return _client.SubmitTransactionAsync(new PaymentRequest { Cents = (int)(amount * 100) })
.Result;
}// Option A: Make the interface async (best)
public async Task<PaymentResult> ChargeAsync(decimal amount, string cardId)
{
var resp = await _client.SubmitTransactionAsync(new PaymentRequest { Cents = (int)(amount * 100) })
.ConfigureAwait(false);
return new PaymentResult(resp.TxId);
}
// Option B: if you absolutely must keep sync API, don't use async inside — go fully sync
public PaymentResult Charge(decimal amount, string cardId)
{
// Use the vendor's synchronous overload if it has one
var resp = _client.SubmitTransactionSync(new PaymentRequest { Cents = (int)(amount * 100) });
return new PaymentResult(resp.TxId);
}
The golden rule: never mix sync and async within the same call chain. Go async all the way, or go sync all the way. Mixing them — even with ConfigureAwait(false) — is a trap for the next developer who removes it thinking it's unnecessary.
The Mistake: The adapter's constructor grows to accept 8 services: the adaptee, a logger, a cache, a retry policy, a circuit breaker, a metrics client, a feature flag service, and a config provider. At this point, the adapter is doing far more than translating an interface.
When you see a constructor with 5+ parameters, ask yourself: "What is the core responsibility of this class?" If the answer is "translating PayWizard's API," then caching, retry logic, and circuit breaking don't belong here — they're cross-cutting concerns that should live in a decorator or a pipeline behavior.
Why It's Bad: This violates the Single Responsibility Principle. The adapter is now a god class. Testing it requires mocking 8 dependencies. Changing the retry strategy forces you to touch the adapter.
The Fix: Extract cross-cutting concerns into decorators or Polly policies. Keep the adapter's constructor to (a) the adaptee and (b) at most a logger. Everything else belongs outside.
public class PayWizardAdapter : IPaymentProcessor
{
public PayWizardAdapter(
IPayWizardClient client,
ILogger<PayWizardAdapter> logger,
IMemoryCache cache,
IRetryPolicy retry,
ICircuitBreaker breaker,
IMetricsClient metrics,
IFeatureFlagService flags,
IConfiguration config) { ... } // ← 8 params: this is NOT an adapter anymore
}// Adapter: ONLY translation responsibility
public class PayWizardAdapter : IPaymentProcessor
{
private readonly IPayWizardClient _client;
private readonly ILogger<PayWizardAdapter> _logger;
public PayWizardAdapter(IPayWizardClient client, ILogger<PayWizardAdapter> logger)
{
_client = client;
_logger = logger;
}
public async Task<PaymentResult> ChargeAsync(decimal amount, string cardId) { ... }
}
// Retry + circuit breaking: Polly policy in DI registration, NOT in the adapter
services.AddHttpClient<IPayWizardClient, PayWizardHttpClient>()
.AddTransientHttpErrorPolicy(p => p.RetryAsync(3))
.AddCircuitBreakerPolicy(...);
The good version has a two-parameter adapter that does one thing: translate. Retry and circuit breaking are handled by Polly at the HttpClient layer — no adapter involvement needed. If you add a new retry strategy, you touch Polly configuration, not the adapter.
Six production pitfalls: swallowing exceptions (translate them instead), leaking vendor vocabulary (translate to domain types), N×M adapter sprawl (introduce a canonical model), DI lifetime mismatches (match adapter lifetime to adaptee thread-safety), async-over-sync deadlocks (go async end-to-end), and constructor over-injection (keep adapters focused on translation only).
Section 14
Testing Strategies — 4 Approaches for Adapter Code
Testing an adapter is not the same as testing business logic. The adapter is a boundary — it sits between your domain and external code you don't own. Good adapter tests verify the translation, not the vendor. Here are the four testing strategies you need, and when to use each.
The base of the pyramid is wide — you want lots of fast unit tests that mock the adaptee and verify translations. Integration tests (using a real or fake adaptee) sit in the middle. Full end-to-end tests are rare and slow; you rely on them only to confirm the live vendor integration works.
Strategy 1 — Mock the Adaptee, Test the Translation
When to use it: Always. This is your primary test strategy. You mock the adaptee (PayWizardClient) and verify that (a) the adapter calls the adaptee with the correctly translated inputs, and (b) the adapter returns the correctly translated output. You are testing the translation logic, not the vendor.
// Using NSubstitute
public class PayWizardAdapterTests
{
private readonly IPayWizardClient _mockClient = Substitute.For<IPayWizardClient>();
private readonly ILogger<PayWizardAdapter> _logger = Substitute.For<ILogger<PayWizardAdapter>>();
[Fact]
public async Task Charge_TranslatesDecimalToCents_AndReturnsTransactionId()
{
// Arrange: set up mock to return a canned response
_mockClient.SubmitTransactionAsync(Arg.Any<PaymentRequest>())
.Returns(new TransactionResponse { TxId = "pw-123", StatusCode = 200 });
var adapter = new PayWizardAdapter(_mockClient, _logger);
// Act
var result = await adapter.ChargeAsync(19.99m, "card-abc");
// Assert: verify INPUT translation (decimal → cents)
await _mockClient.Received(1).SubmitTransactionAsync(
Arg.Is<PaymentRequest>(r => r.Cents == 1999 && r.CardToken == "card-abc"));
// Assert: verify OUTPUT translation (TxId is preserved)
Assert.Equal("pw-123", result.TxId);
Assert.Equal(PaymentStatus.Approved, result.Status);
}
[Fact]
public async Task Charge_WhenPayWizardTimesOut_ThrowsPaymentGatewayTimeoutException()
{
_mockClient.SubmitTransactionAsync(Arg.Any<PaymentRequest>())
.ThrowsAsync(new PayWizardTimeoutException());
var adapter = new PayWizardAdapter(_mockClient, _logger);
// The adapter must translate the vendor exception — not swallow it, not rethrow raw
await Assert.ThrowsAsync<PaymentGatewayTimeoutException>(
() => adapter.ChargeAsync(10m, "card-xyz"));
}
}
Two tests, two things verified: that the adapter translates 19.99 into 1999 cents before calling the vendor (input translation), and that a PayWizardTimeoutException becomes a PaymentGatewayTimeoutException (exception translation). These run in milliseconds with no network access.
Strategy 2 — Integration Test Against a Fake Adaptee
When to use it: When the adaptee is your own infrastructure (a database wrapper, a Redis client, a local file system client) and you want to verify that the adapter actually works with real I/O — not just mocked behavior. Use a test container, an in-memory fake, or a local stub server.
// Using Testcontainers to spin up a real Redis for integration testing
public class RedisAdapterIntegrationTests : IAsyncLifetime
{
private RedisContainer _container = new RedisBuilder().Build();
private IConnectionMultiplexer _redis;
public async Task InitializeAsync()
{
await _container.StartAsync();
_redis = await ConnectionMultiplexer.ConnectAsync(_container.GetConnectionString());
}
[Fact]
public async Task GetAsync_AfterSetAsync_ReturnsStoredValue()
{
var adapter = new RedisCacheAdapter(_redis); // your adapter wrapping StackExchange.Redis
await adapter.SetAsync("key1", "hello", TimeSpan.FromMinutes(5));
var value = await adapter.GetAsync("key1");
Assert.Equal("hello", value);
}
public async Task DisposeAsync() => await _container.DisposeAsync();
}
This test spins up a real Redis container for the test run. It verifies that the adapter works with real Redis semantics — serialization, TTL expiry, key naming — not just mock behavior. Integration tests are slower but they catch real connection string bugs, serialization issues, and API contract surprises that mocks would hide.
Strategy 3 — Contract Tests: One Test Suite, Multiple Adapters
When to use it: When you have multiple adapters that all implement the same target interface — e.g., SendGridEmailAdapter, SesEmailAdapter, SmtpEmailAdapter all implementing IEmailSender. You write the contract test once and run it against every adapter to guarantee they all behave identically from the consumer's point of view.
// Abstract contract test — same assertions run for every adapter
public abstract class EmailSenderContractTests
{
protected abstract IEmailSender CreateSender(); // each subclass provides its adapter
[Theory]
[InlineData("user@example.com", "Hello", "World")]
[InlineData("other@test.org", "Subject with special chars <&>", "Body text")]
public async Task SendAsync_WithValidEmail_DoesNotThrow(string to, string subject, string body)
{
var sender = CreateSender();
// Should not throw — contract: valid inputs always accepted
await sender.SendAsync(new EmailMessage(to, subject, body));
}
[Fact]
public async Task SendAsync_WithNullRecipient_ThrowsArgumentException()
{
var sender = CreateSender();
await Assert.ThrowsAsync<ArgumentException>(
() => sender.SendAsync(new EmailMessage(null!, "Subject", "Body")));
}
}
// One subclass per adapter
public class SendGridEmailAdapterContractTests : EmailSenderContractTests
{
protected override IEmailSender CreateSender() =>
new SendGridEmailAdapter(new FakeSendGridClient());
}
public class SesEmailAdapterContractTests : EmailSenderContractTests
{
protected override IEmailSender CreateSender() =>
new SesEmailAdapter(new FakeSesClient());
}
The base class holds the contract — what every IEmailSender must do. Each subclass just wires up its adapter. When you add a fourth email provider, you create one subclass and you immediately know whether the new adapter honors the contract. If a new adapter fails a contract test, you know before it ships.
Strategy 4 — Boundary Tests: What Happens When the Adaptee Misbehaves
When to use it: Always, alongside Strategy 1. The adapter is the boundary between your safe domain code and the dangerous external world. It must handle ugly inputs gracefully: null responses, unexpected exception types, malformed data, timeouts, HTTP 429s, and empty collections. These boundary tests verify that ugliness never escapes the adapter.
public class PayWizardAdapterBoundaryTests
{
private readonly IPayWizardClient _mockClient = Substitute.For<IPayWizardClient>();
[Fact]
public async Task Charge_WhenAdapteeReturnsNull_ThrowsInvalidOperationException()
{
_mockClient.SubmitTransactionAsync(Arg.Any<PaymentRequest>())
.Returns((TransactionResponse)null!); // adaptee returns null — should not propagate
var adapter = new PayWizardAdapter(_mockClient, NullLogger<PayWizardAdapter>.Instance);
await Assert.ThrowsAsync<InvalidOperationException>(
() => adapter.ChargeAsync(10m, "card-abc"));
}
[Fact]
public async Task Charge_WhenAdapteeReturnsMalformedTxId_ThrowsDomainException()
{
_mockClient.SubmitTransactionAsync(Arg.Any<PaymentRequest>())
.Returns(new TransactionResponse { TxId = "", StatusCode = 200 }); // empty TxId
var adapter = new PayWizardAdapter(_mockClient, NullLogger<PayWizardAdapter>.Instance);
await Assert.ThrowsAsync<PaymentGatewayException>(
() => adapter.ChargeAsync(10m, "card-abc"));
}
[Fact]
public async Task Charge_WhenAdapteeThrowsUnknownException_PropagatesAsGatewayException()
{
_mockClient.SubmitTransactionAsync(Arg.Any<PaymentRequest>())
.ThrowsAsync(new HttpRequestException("502 Bad Gateway")); // unexpected
var adapter = new PayWizardAdapter(_mockClient, NullLogger<PayWizardAdapter>.Instance);
await Assert.ThrowsAsync<PaymentGatewayException>(
() => adapter.ChargeAsync(10m, "card-abc"));
}
}
Three boundary tests, three ugly scenarios: null response, malformed data, and a totally unexpected HTTP exception. All three should produce a domain exception — never a NullReferenceException flying out of the adapter three method calls later. The boundary test suite is your guarantee that the adapter stays resilient no matter what the vendor throws at it.
Four adapter testing strategies: mock-the-adaptee unit tests verify translation correctness; integration tests against fake/containerized adaptees catch real I/O surprises; contract tests run identical assertions across multiple adapter implementations; boundary tests verify the adapter stays resilient when the adaptee returns null, malformed data, or unexpected exceptions.
Section 15
Tooling & Libraries — When Not to Write an Adapter by Hand
Writing an adapter by hand is the right choice when the translation logic is complex or domain-specific. But for common patterns — mapping DTOs to domain objects, wrapping REST APIs, routing requests — libraries handle the boilerplate for you. Here are the four tools that cover 80% of the cases where engineers write adapters from scratch unnecessarily.
Use this tree as a quick filter. If your adapter is essentially "convert type A to type B," a source generator will do it faster and more correctly than hand-written code. If it's "expose a REST API behind a C# interface," Refit writes the boilerplate. Only reach for a hand-written adapter when the translation logic is genuinely domain-specific.
Mapperly — Source-Generated Type Mapping (Zero Reflection)
Imagine a tiny helper that watches your project as you build it, reads the mapping methods you declared, and writes the boring "copy property A to property B" code for you — saving you from typing it by hand. That kind of build-time helper is called a Roslyn source generator, and Mapperly is one of them. The result is regular C# code — as fast as if you wrote it by hand — with zero reflection at runtime. This makes it ideal for hot paths (e.g., mapping thousands of database rows to DTOs per request).
// 1. Install: dotnet add package Riok.Mapperly
// 2. Define a mapper — Mapperly generates the implementation
[Mapper]
public partial class OrderMapper
{
public partial OrderDto ToDto(Order order); // Mapperly generates this body
public partial Order ToDomain(OrderDto dto); // and this one
}
// Usage — no reflection, no convention magic, just fast generated code
var mapper = new OrderMapper();
OrderDto dto = mapper.ToDto(domainOrder);
// If property names differ, use [MapProperty]
[Mapper]
public partial class PaymentMapper
{
[MapProperty(nameof(PayWizardResponse.TxId), nameof(PaymentResult.TransactionId))]
public partial PaymentResult ToResult(PayWizardResponse response);
}
At compile time, Mapperly generates a ToDto method that copies each matching property by name. If names differ, you annotate with [MapProperty]. If it can't figure out a mapping, it fails at compile time — which is vastly better than AutoMapper's runtime failures.
Use Mapperly as your default. It gives you compile-time safety, zero runtime overhead, and generated code you can read and debug. Only switch to AutoMapper if you need runtime-dynamic mapping configuration (rare).
AutoMapper — Convention-Based Runtime Mapping (Popular, but Reflection-Heavy)
Where Mapperly writes the mapping code at build time, AutoMapper figures it out while your app is running. It inspects your types live, matches up property names, and builds the mapping plan on the fly. That live type-inspection technique is called reflection. AutoMapper has been the de-facto mapping library in .NET for over a decade, so you'll encounter it in older codebases. Note: as of 2025, AutoMapper has moved to a dual-license commercial model (existing MIT-licensed versions remain free) — factor that in when starting new projects. The key trade-off: more flexible dynamic configuration, but runtime errors (a missing property mapping fails at runtime, not compile time) and measurable overhead on very high-throughput paths.
// 1. Install: dotnet add package AutoMapper
// 2. Define a profile
public class OrderProfile : Profile
{
public OrderProfile()
{
CreateMap<Order, OrderDto>()
.ForMember(dto => dto.CustomerName, opt => opt.MapFrom(o => o.Customer.FullName));
CreateMap<OrderDto, Order>().ReverseMap();
}
}
// 3. Register in DI
services.AddAutoMapper(typeof(OrderProfile));
// 4. Use
public class OrderService(IMapper mapper)
{
public OrderDto GetDto(Order order) => mapper.Map<OrderDto>(order);
}
AutoMapper works fine for most applications. The performance concern only matters at very high throughput (tens of thousands of mappings per second). For typical API endpoints mapping a few hundred objects per request, the difference is negligible. The bigger risk is silent runtime misconfiguration — always call mapper.ConfigurationProvider.AssertConfigurationIsValid() in tests to catch unmapped properties early.
Refit turns a REST API into a C# interface by having you declare the endpoints with attributes. At runtime (or compile time with Refit's source generator mode), it generates an HttpClient-based implementation. This is exactly an Adapter — it adapts an HTTP API to a typed C# interface — but you didn't write the translation code.
// 1. Install: dotnet add package Refit
// 2. Declare the target interface with HTTP attributes
public interface IPayWizardApi
{
[Post("/v2/transactions")]
Task<TransactionResponse> SubmitTransactionAsync([Body] PaymentRequest request);
[Delete("/v2/transactions/{txId}")]
Task ReverseTransactionAsync(string txId, [Query] string reason);
}
// 3. Register — Refit generates the implementation
services.AddRefitClient<IPayWizardApi>()
.ConfigureHttpClient(c => c.BaseAddress = new Uri("https://api.paywizard.com"));
// 4. Wrap in your domain adapter
public class PayWizardAdapter(IPayWizardApi api) : IPaymentProcessor
{
public async Task<PaymentResult> ChargeAsync(decimal amount, string cardId)
{
var resp = await api.SubmitTransactionAsync(new PaymentRequest
{
Cents = (int)(amount * 100), CardToken = cardId
});
return new PaymentResult(resp.TxId, PaymentStatus.Approved);
}
}
Refit handles serialization, deserialization, headers, base URLs, and error handling automatically. Your PayWizardAdapter still exists — it handles the domain translation — but it no longer needs to deal with raw HttpClient plumbing. This keeps your adapter focused on business translation, not HTTP mechanics.
MediatR — When Handlers Replace Explicit Adapters
Sometimes you don't want service A calling service B directly — instead you want a little post office in the middle that receives a request and figures out which handler should pick it up. That "request goes through a central dispatcher" idea is called the Mediator pattern, and the most popular .NET implementation of it is a library called MediatR. Handlers are essentially adapters: a ChargePaymentHandler receives a ChargePaymentCommand and calls whatever payment service is wired up. When you're building a CQRS or command-based architecture, MediatR often eliminates the need for explicit adapter classes entirely, because the handler IS the translation layer. Note: MediatR (alongside AutoMapper) moved to a dual-license commercial model in 2025 — existing MIT-licensed versions remain free, but new commercial use of newer versions requires a paid license; consider that when choosing it for new projects.
// 1. Install: dotnet add package MediatR
// 2. Define a command
public record ChargePaymentCommand(decimal Amount, string CardId) : IRequest<PaymentResult>;
// 3. Handler — this IS the adapter (command → PayWizard call)
public class ChargePaymentHandler(IPayWizardClient client) : IRequestHandler<ChargePaymentCommand, PaymentResult>
{
public async Task<PaymentResult> Handle(ChargePaymentCommand cmd, CancellationToken ct)
{
var resp = await client.SubmitTransactionAsync(new PaymentRequest
{
Cents = (int)(cmd.Amount * 100),
CardToken = cmd.CardId
});
return new PaymentResult(resp.TxId, PaymentStatus.Approved);
}
}
// 4. Caller sends the command — no direct dependency on the handler
var result = await mediator.Send(new ChargePaymentCommand(19.99m, "card-abc"));
The caller knows only about ChargePaymentCommand. The handler knows only about IPayWizardClient. MediatR is the glue. This achieves the same decoupling as an explicit Adapter interface, but with less ceremony — you don't need to define IPaymentProcessor, register an implementation, or inject it. The trade-off: MediatR's magic can make call flows harder to trace. Use it when command routing across many handlers is the actual problem; use explicit Adapters when you're wrapping one specific external dependency.
Four tools reduce hand-written adapter boilerplate: Mapperly (compile-time source-generated type mapping, zero reflection), AutoMapper (convention-based runtime mapping, good for legacy codebases), Refit (generates HttpClient adapters from interface declarations), and MediatR (handler-based routing that replaces explicit adapter classes in CQRS architectures). Each fits a specific scenario — the decision tree above shows when to reach for each.
Section 16
Q&A — Junior Track: 5 Beginner Questions Answered
These are the questions every developer has when they first encounter Adapter. The answers build up the mental model you need before the advanced questions in the Senior Track.
Q1 — What's the difference between Adapter and Decorator?
This is the most common confusion, and it's understandable — both patterns wrap one class inside another. The difference is why they wrap.
Adapter wraps because the interfaces don't match. You have a class with interface A, but you need interface B. The adapter is purely a translator. It changes the shape of the API — nothing else.
Decorator wraps because you want to add behaviour. The interfaces do match — the decorator implements the same interface as the class it wraps. It calls through to the inner object and adds something before or after (logging, caching, retry).
Concrete test: does the class implement the same interface as the class it wraps? If yes, Decorator. Does it implement a different interface? Adapter.
// Adapter: different interfaces
public class PayWizardAdapter : IPaymentProcessor // IPaymentProcessor ≠ PayWizardClient
{
private readonly PayWizardClient _client;
}
// Decorator: same interface
public class LoggingPaymentProcessor : IPaymentProcessor // same interface!
{
private readonly IPaymentProcessor _inner;
public async Task<PaymentResult> ChargeAsync(decimal amount, string cardId)
{
_logger.LogInformation("Charging {Amount}", amount);
var result = await _inner.ChargeAsync(amount, cardId);
_logger.LogInformation("Charged {TxId}", result.TxId);
return result;
}
}
Q2 — When should I use Adapter instead of just changing the interface?
Adapter is the right choice exactly when you can't change one or both of the interfaces. You can't change PayWizard's SDK — it's a NuGet package from a vendor. You could change your own IPaymentProcessor, but that means updating 47 call sites across 12 services — weeks of work and regression risk.
The rule: if you own both sides and the change is small, just change the interface. If one side is owned by a vendor, a framework, a legacy system, or another team with a slow release cycle — write an Adapter. The asymmetry of cost drives the choice.
A useful smell test: if changing the interface requires touching more than 3–5 files, the cost is high enough that an Adapter is worth the indirection.
Q3 — Why do I need an Adapter when I could just use a static helper method?
A static helper method like PayWizardHelper.Convert(PaymentRequest req) handles one direction of the translation — but it doesn't give you a type that implements your target interface. You can't inject a static method into a constructor, you can't replace it with a fake in tests, and you can't swap it for a different vendor at runtime.
The Adapter is an object. It implements an interface. That means your DI container can wire it up, your test suite can swap it for a mock, and you can decorate it with logging or retry logic. A static helper can't do any of that.
// Static helper: no polymorphism, no testability
var result = PayWizardHelper.Charge(19.99m, "card-abc"); // hard-coded to PayWizard forever
// Adapter: polymorphic, injectable, swappable
public class OrderService(IPaymentProcessor processor) { ... }
// In tests: inject MockPaymentProcessor
// In production: inject PayWizardAdapter
// Next year: inject StripeAdapter — OrderService is untouched
Short version: static helpers convert data. Adapters translate behaviour behind a swappable interface.
Q4 — How is Adapter different from a Facade?
Both patterns wrap something messy behind something cleaner. The difference is what problem they solve and how many things they wrap.
Adapter: Wraps one class to make its interface compatible with what the caller expects. The goal is interface compatibility. The adaptee's full functionality is still accessible; it's just reshaped. One-to-one wrapping.
Facade: Wraps a whole subsystem (multiple classes, services, API calls) behind one simpler front door. The goal is simplicity — hiding complexity from callers who don't need the full power of the subsystem. One-to-many wrapping.
If you are wrapping a single class to change its interface shape → Adapter. If you are wrapping a whole subsystem to give callers a simpler entry point → Facade. A Facade might use several Adapters internally to normalize the subsystems it wraps.
Q5 — Can I use Adapter for adapting data types (e.g., int → string)?
Technically yes — the Adapter pattern is just "translate shape A to shape B," and that applies to data types too. But in practice, when people talk about "Adapter," they mean wrapping a class to change its method signatures, not converting primitive values.
For simple data type conversion (int → string, DateTime → string), you don't need the Adapter pattern — just call .ToString() or write a converter function. The Adapter pattern is worth the ceremony of a full wrapper class when you're bridging entire APIs or class hierarchies, not single values.
Where this gets interesting: for mapping full objects (DTO ↔ domain model), you're doing type adaptation at scale, and that's exactly what Mapperly and AutoMapper are: automated Adapter generators for data shapes. So yes, type-level adaptation is real — you just don't usually write it by hand as a GoF Adapter class.
Five beginner questions answered: Adapter changes interface shape while Decorator adds behaviour to the same interface; use Adapter when changing the interface costs too much; Adapters beat static helpers because they're injectable and swappable; Adapter wraps one class for compatibility while Facade wraps a whole subsystem for simplicity; data-type adaptation is real but GoF Adapter class is for API-level bridging, not primitive conversions.
Section 17
Q&A — Senior Track: 5 Advanced Questions
Senior-level adapter questions go beyond "what is it" — they probe how you evolve adapters as systems grow, version them safely, test them correctly, and recognize when an adapter crosses over into architectural territory like Anti-Corruption Layers and Hexagonal Architecture.
Q1 — How do I keep my Adapter from becoming a god class as the adaptee grows?
This is a real problem. PayWizard starts with SubmitTransaction and ReverseTransaction. A year later, it has 30 methods: disputes, refunds, partial captures, 3D Secure flows, recurring billing, webhooks. If you wrap the whole thing in one adapter, you end up with a class that's thousands of lines long.
The fix is to split adapters along CQRS or responsibility lines. Instead of one PayWizardAdapter, write:
// Split by responsibility axis
public class PayWizardChargeAdapter : IChargeProcessor { ... } // submit + capture
public class PayWizardRefundAdapter : IRefundProcessor { ... } // refunds + reversals
public class PayWizardDisputeAdapter : IDisputeManager { ... } // dispute handling
public class PayWizardRecurringAdapter : IRecurringBilling { ... } // subscriptions
// Each wraps the same PayWizardClient but only delegates its own methods
// DI wires them up independently — callers depend only on the interface they need
Now OrderService injects IChargeProcessor. CustomerSupportService injects IRefundProcessor. Neither knows about the other's adapter. Each adapter is small, focused, and testable in isolation.
The rule of thumb: if your adapter implements an interface with more than 4–5 methods, ask whether those methods belong to different use cases. If yes, split the adapter. Same adaptee, multiple small adapters — each serving one client.
Q2 — How do I version an Adapter when the adaptee's API changes?
Vendors evolve their APIs. PayWizard v2 removes SubmitTransaction and replaces it with CreatePaymentIntent. You need to migrate, but your 12 services can't all update at once. This is where adapter versioning comes in.
The strategy: keep your domain's target interface (IPaymentProcessor) stable. Write a new adapter for the new vendor API. Introduce an adapter factory that routes to the old or new adapter based on a feature flag or configuration.
// Both adapters implement the same interface
public class PayWizardV1Adapter : IPaymentProcessor { ... } // wraps old SDK
public class PayWizardV2Adapter : IPaymentProcessor { ... } // wraps new SDK
// Factory delegates to the right version
public class PayWizardAdapterFactory(
PayWizardV1Adapter v1,
PayWizardV2Adapter v2,
IFeatureFlags flags) : IPaymentProcessor
{
public Task<PaymentResult> ChargeAsync(decimal amount, string cardId) =>
flags.IsEnabled("PayWizardV2") ? v2.ChargeAsync(amount, cardId)
: v1.ChargeAsync(amount, cardId);
}
// DI: register the factory as the implementation
services.AddSingleton<IPaymentProcessor, PayWizardAdapterFactory>();
The factory is itself an Adapter wrapping two adapters. Callers see no change. You flip the feature flag to gradually migrate traffic, monitor error rates, and roll back instantly if something goes wrong. Once V2 is stable and V1 is fully off, you delete PayWizardV1Adapter and collapse the factory. Clean migration with zero call-site changes.
Q3 — How do I unit-test an Adapter without testing the adaptee itself?
The confusion arises because the adapter delegates to the adaptee — so doesn't testing the adapter implicitly test the adaptee? No, and this distinction matters a lot for test speed and reliability.
Your adapter unit tests should mock the adaptee interface (IPayWizardClient). You control exactly what the mock returns. You test three things: (1) that the adapter calls the adaptee with correctly translated inputs; (2) that the adapter returns correctly translated outputs; (3) that the adapter handles adaptee errors as defined in its contract. You are testing the translation logic, not the vendor.
Separately, integration tests (Strategy 2 from S14) test the adapter against the real adaptee. Those are slower, need network/infrastructure, and run less frequently. Keeping these separate means your unit tests run in 5 ms with no flakiness, and integration tests run in CI only.
Think of the adapter as a boundary with two sides. Unit tests verify the boundary from the inside (does my translation logic work?). Integration tests verify the boundary from the outside (does the translation work with the real vendor?). You need both, but they have different cadences.
Q4 — When does an Adapter cross over to becoming an Anti-Corruption Layer (DDD)?
When two systems use the same word ("Order", "Customer", "Account") but actually mean different things, letting one system's view of the world bleed into the other can quietly poison your design. A protective translation layer at the boundary stops that pollution at the gate. Eric Evans introduced this idea in Domain-Driven Design and named it the Anti-Corruption Layer (ACL). Each "world" with its own consistent vocabulary is a bounded context, and the ACL sits between them. Conceptually an ACL is the same idea as an Adapter — wraps something external, translates into your domain's vocabulary — but it operates at a larger scale and carries more responsibility.
The difference is scope and intent:
GoF Adapter: Wraps a single class. Solves an interface mismatch. Lives at the code level. You reach for it when two APIs don't match.
ACL: Wraps an entire external bounded context — a legacy ERP, a third-party platform, a system with a fundamentally different domain model. The ACL may contain many classes: translators, assemblers, facades, gateways. It's an architectural layer, not a single class. You reach for it when the external system's conceptual model would pollute your domain model if you let it in directly.
In practice: if you write a PayWizardAdapter and it stays small (translate Charge() → SubmitTransaction()), it's a GoF Adapter. If you find yourself translating PayWizard's entire concept of "Transaction State Machine" into your domain's concept of "Order Lifecycle," and this requires 8 classes to do correctly — that's an ACL. The name doesn't change what you code, but the scope does.
Q5 — How does Hexagonal Architecture / Ports & Adapters relate to GoF Adapter?
Picture your application core sitting in the middle, with the database, the web framework, queues, and external APIs arranged around the outside. The core defines the interfaces it needs — and small classes on the outside plug into those interfaces to talk to the real systems. That whole layout has a name: Ports & Adapters, also known as Hexagonal Architecture. Alistair Cockburn introduced it in 2005 and deliberately borrowed the word "Adapter" from GoF, but the architectural pattern is much broader than the GoF design pattern.
GoF Adapter: A structural design pattern — a single class that translates one interface to another. No architecture implied. Can be used anywhere.
Ports & Adapters: An entire application architecture where the application core defines ports (interfaces it needs: IPaymentProcessor, IOrderRepository) and the outer ring provides adapters (concrete implementations: PayWizardAdapter, PostgresOrderRepository). The core never depends on adapters — only on ports. The adapters depend on the core's port interfaces.
// Hexagonal: the core defines ports (interfaces it needs)
// Application Core — depends on NOTHING outside itself
public interface IPaymentProcessor { Task<PaymentResult> ChargeAsync(decimal amount, string cardId); }
public interface IOrderRepository { Task SaveAsync(Order order); }
public class OrderService(IPaymentProcessor payments, IOrderRepository orders)
{
// pure business logic — no vendor imports, no EF imports, no HTTP imports
}
// Outer ring — adapters implement the ports
public class PayWizardAdapter : IPaymentProcessor { ... } // adapts PayWizard SDK
public class EfOrderRepository : IOrderRepository { ... } // adapts Entity Framework
// DI wires outer ring to core — core is untouched when you swap adapters
services.AddScoped<IPaymentProcessor, PayWizardAdapter>();
services.AddScoped<IOrderRepository, EfOrderRepository>();
The GoF Adapter pattern is one of the tools you use to implement the Ports & Adapters architecture. Every concrete class in the outer ring of a Hexagonal Architecture is an Adapter in the GoF sense. But the architecture as a whole — the rule that the core must not depend on anything outside — is Ports & Adapters. Understanding both levels is what separates a senior engineer from a junior one.
Five senior-track answers: split growing adapters along CQRS/responsibility lines; use adapter factories with feature flags for safe versioning; unit tests mock the adaptee to test translation logic while integration tests verify live compatibility; an Adapter becomes an Anti-Corruption Layer when it wraps an entire external bounded context rather than one class; GoF Adapter is a single-class design pattern while Ports & Adapters is an architecture that uses GoF Adapters as its outer-ring implementation mechanism.
Section 18
Practice Exercises — 5 Hands-On Drills
These five exercises are designed to move understanding from "I get it" to "I can actually build it." Each covers a different real-world Adapter scenario. Do them in order — they build on each other.
You inherit a codebase that uses a homegrown LegacyFileLogger with a single method: void Log(string message). Your new services are all wired to use Microsoft.Extensions.Logging.ILogger. Write a LegacyFileLoggerAdapter that implements ILogger and delegates to LegacyFileLogger.
Constraints: ILogger has many methods — Log<TState>(), IsEnabled(), BeginScope<TState>(). You only need to make the core Log<TState> work; the others can return safe no-ops.
ILogger<T> is the generic variant; ILogger (non-generic) is simpler and just as useful for this exercise. Look at how ILogger.Log<TState> is declared — the TState formatter gives you the final string.For IsEnabled(), return true for all log levels (keeps it simple). For BeginScope<TState>(), return NullScope.Instance or just return null — the MEL infrastructure handles null gracefully.public class LegacyFileLoggerAdapter : ILogger
{
private readonly LegacyFileLogger _legacy;
public LegacyFileLoggerAdapter(LegacyFileLogger legacy) => _legacy = legacy;
public void Log<TState>(
LogLevel logLevel,
EventId eventId,
TState state,
Exception? exception,
Func<TState, Exception?, string> formatter)
{
// Translate: use the formatter to get the final string, then call the legacy API
var message = formatter(state, exception);
_legacy.Log($"[{logLevel}] {message}");
if (exception != null)
_legacy.Log($"Exception: {exception}");
}
public bool IsEnabled(LogLevel logLevel) => true; // legacy logger doesn't filter
public IDisposable? BeginScope<TState>(TState state) where TState : notnull => null;
}
// Registration — now all services using ILogger get the legacy file logger
services.AddSingleton<LegacyFileLogger>();
services.AddSingleton<ILogger, LegacyFileLoggerAdapter>();
Key insight: the adapter calls formatter(state, exception) to get the final formatted string before calling _legacy.Log(). That's the translation layer — the legacy API wants a plain string, but ILogger.Log delivers structured state with a formatter. The adapter bridges them.
You have an existing synchronous interface: IBlobStorage with byte[] Read(string key) and void Write(string key, byte[] data). Your new code expects IAsyncBlobStorage with Task<byte[]> ReadAsync(string key) and Task WriteAsync(string key, byte[] data).
Write an AsyncBlobStorageAdapter that adapts the sync interface to async. Then explain the trade-offs of using Task.Run() vs exposing a true async implementation. When is each appropriate?
Task.Run() offloads the sync call to a ThreadPool thread, making it non-blocking for the caller — but it still uses a thread for the duration. For I/O-bound operations, this wastes a thread. For CPU-bound operations, it's correct.The "right" fix is to replace the sync implementation with a truly async one (e.g., using Azure.Storage.Blobs async API). Task.Run is a bridge, not a solution. Document that clearly in the adapter./// <summary>
/// BRIDGE ADAPTER — adapts a sync IBlobStorage to IAsyncBlobStorage using Task.Run().
/// CAUTION: This is a temporary bridge. Task.Run() uses a ThreadPool thread for the duration
/// of the I/O call. For production use, replace IBlobStorage with a truly async implementation.
/// </summary>
public class AsyncBlobStorageAdapter : IAsyncBlobStorage
{
private readonly IBlobStorage _sync;
public AsyncBlobStorageAdapter(IBlobStorage sync) => _sync = sync;
public Task<byte[]> ReadAsync(string key) =>
Task.Run(() => _sync.Read(key));
public Task WriteAsync(string key, byte[] data) =>
Task.Run(() => _sync.Write(key, data));
}
// --- Trade-off table ---
// Task.Run(sync work):
// ✓ Non-blocking for caller (caller's thread is freed while Task.Run runs on ThreadPool)
// ✗ Still uses a ThreadPool thread for the duration (bad for high concurrency)
// ✗ Not truly async — hides a blocking call behind a Task facade
// ✓ Safe when: sync implementation is fast (in-memory), or you need quick migration
// Truly async:
// ✓ Uses I/O completion ports — no threads blocked during network/disk I/O
// ✓ Scales to thousands of concurrent operations
// ✗ Requires rewriting the storage implementation
// → Use this for any real production I/O-bound storage
A colleague submitted this code. The PayWizardClient documentation says: "Not thread-safe. Create one instance per operation." Find the bug and fix it.
// Program.cs
services.AddSingleton<PayWizardClient>();
services.AddSingleton<IPaymentProcessor, PayWizardAdapter>();
// PayWizardAdapter.cs
public class PayWizardAdapter : IPaymentProcessor
{
private readonly PayWizardClient _client;
public PayWizardAdapter(PayWizardClient client) => _client = client;
public async Task<PaymentResult> ChargeAsync(decimal amount, string cardId)
{
var resp = await _client.SubmitTransactionAsync(...);
return new PaymentResult(resp.TxId);
}
}The vendor docs say "not thread-safe, create one per operation." What DI lifetime creates one instance per request?
The bug: Both PayWizardClient and PayWizardAdapter are registered as Singletons. A Singleton is shared across all requests — all threads. But the vendor says "not thread-safe, create one per operation." Under load, multiple requests race through the same PayWizardClient instance, corrupting each other's state.
// Fix: change lifetime to Transient (one per injection) or Scoped (one per HTTP request)
services.AddTransient<PayWizardClient>();
services.AddTransient<IPaymentProcessor, PayWizardAdapter>();
// OR if Scoped is sufficient (one per HTTP request, thread-safe within a request):
services.AddScoped<PayWizardClient>();
services.AddScoped<IPaymentProcessor, PayWizardAdapter>();
Transient creates a new instance every time one is injected — safest for non-thread-safe objects. Scoped creates one per HTTP request — correct if the adapter is only called once per request lifecycle. Match the lifetime to the vendor's threading contract.
You have three email adapters: SendGridEmailAdapter, SesEmailAdapter, and SmtpEmailAdapter, all implementing IEmailSender with one method: Task SendAsync(EmailMessage message).
Design a contract test suite that runs the same assertions against all three adapters. The contract should verify: valid messages are accepted without exception, null recipient throws ArgumentException, and the adapter does not swallow exceptions from the underlying client.
Use an abstract base class with [Theory] or [Fact] tests. Each concrete subclass provides one adapter via an abstract factory method.For "does not swallow exceptions" — configure the mock underlying client to throw, then assert the exception propagates (or is translated to a domain type). The key is that it must NOT return silently.// Contract base class — shared assertions for every IEmailSender
public abstract class EmailSenderContractTests
{
// Subclass provides the adapter under test
protected abstract IEmailSender CreateSender(IEmailClient mockClient);
protected abstract IEmailClient CreateMockClient();
[Fact]
public async Task SendAsync_ValidMessage_DoesNotThrow()
{
var mock = CreateMockClient();
var sender = CreateSender(mock);
// Should not throw — valid messages must be accepted
await sender.SendAsync(new EmailMessage("user@example.com", "Hello", "World"));
}
[Theory]
[InlineData(null, "Subject", "Body")]
[InlineData("", "Subject", "Body")]
public async Task SendAsync_NullOrEmptyRecipient_ThrowsArgumentException(
string? to, string subject, string body)
{
var sender = CreateSender(CreateMockClient());
await Assert.ThrowsAsync<ArgumentException>(
() => sender.SendAsync(new EmailMessage(to!, subject, body)));
}
[Fact]
public async Task SendAsync_WhenClientThrows_DoesNotSwallowException()
{
var mock = CreateMockClient();
// Configure mock to throw — the adapter must not silently swallow it
// (either rethrow or translate to a domain exception, but NEVER return void on failure)
ConfigureMockToThrow(mock);
var sender = CreateSender(mock);
await Assert.ThrowsAnyAsync<Exception>(
() => sender.SendAsync(new EmailMessage("user@example.com", "Subject", "Body")));
}
protected abstract void ConfigureMockToThrow(IEmailClient mock);
}
// One subclass per adapter — each gets all three contract tests for free
public class SendGridAdapterContractTests : EmailSenderContractTests
{
protected override IEmailClient CreateMockClient() =>
Substitute.For<ISendGridClient>(); // NSubstitute mock
protected override IEmailSender CreateSender(IEmailClient client) =>
new SendGridEmailAdapter((ISendGridClient)client);
protected override void ConfigureMockToThrow(IEmailClient mock) =>
((ISendGridClient)mock).SendEmailAsync(Arg.Any<SendGridMessage>())
.ThrowsAsync(new HttpRequestException("503"));
}
// SesAdapterContractTests and SmtpAdapterContractTests follow the same pattern
The abstract base class defines the contract. Each subclass wires up one adapter. If a new adapter ever fails a contract test, you know before it ships. This is especially valuable when a team owns each adapter — the contract test is the shared specification they all agree to uphold.
The following PayWizardAdapter has grown to 12 methods spanning charge operations, refunds, dispute management, and recurring billing. Refactor it into 2–3 smaller adapters that each have a single responsibility.
public class PayWizardAdapter : IPaymentProcessor
{
// Charge operations
Task<PaymentResult> ChargeAsync(decimal amount, string cardId);
Task<PaymentResult> CaptureAsync(string authId, decimal amount);
Task<PaymentResult> AuthorizeAsync(decimal amount, string cardId);
// Refund operations
Task<RefundResult> RefundAsync(string txId, decimal amount);
Task<RefundResult> VoidAsync(string authId);
Task<RefundResult> PartialRefundAsync(string txId, decimal amount);
// Dispute management
Task SubmitEvidenceAsync(string txId, DisputeEvidence evidence);
Task AcceptDisputeAsync(string txId);
Task<DisputeStatus> GetDisputeStatusAsync(string txId);
// Recurring billing
Task<SubscriptionResult> CreateSubscriptionAsync(SubscriptionPlan plan, string cardId);
Task CancelSubscriptionAsync(string subId);
Task<SubscriptionResult> UpdateSubscriptionAsync(string subId, SubscriptionPlan plan);
}Group methods by the domain use case they serve. Who calls them? OrderService uses charges. FinanceService uses refunds. CustomerSupportService uses disputes. SubscriptionService uses recurring billing.Each smaller adapter wraps the same PayWizardClient but only exposes and delegates the methods in its responsibility domain. Define a matching focused interface for each adapter.// 3 focused interfaces — each caller depends on only what it needs
public interface IChargeProcessor
{
Task<PaymentResult> ChargeAsync(decimal amount, string cardId);
Task<PaymentResult> CaptureAsync(string authId, decimal amount);
Task<PaymentResult> AuthorizeAsync(decimal amount, string cardId);
}
public interface IRefundProcessor
{
Task<RefundResult> RefundAsync(string txId, decimal amount);
Task<RefundResult> VoidAsync(string authId);
Task<RefundResult> PartialRefundAsync(string txId, decimal amount);
}
public interface IDisputeManager
{
Task SubmitEvidenceAsync(string txId, DisputeEvidence evidence);
Task AcceptDisputeAsync(string txId);
Task<DisputeStatus> GetDisputeStatusAsync(string txId);
}
// Note: recurring billing warrants its own ISubscriptionService — left as reader exercise
// 3 focused adapters — each wraps the same PayWizardClient
public class PayWizardChargeAdapter(PayWizardClient client) : IChargeProcessor
{
public Task<PaymentResult> ChargeAsync(decimal amount, string cardId) =>
TranslateAsync(() => client.SubmitTransactionAsync(new PaymentRequest
{
Cents = (int)(amount * 100), CardToken = cardId
}));
// ... other charge methods
private static async Task<PaymentResult> TranslateAsync(Func<Task<TransactionResponse>> fn)
{
var resp = await fn();
return new PaymentResult(resp.TxId, PaymentStatus.Approved);
}
}
public class PayWizardRefundAdapter(PayWizardClient client) : IRefundProcessor
{
public Task<RefundResult> RefundAsync(string txId, decimal amount) => ...;
// ...
}
public class PayWizardDisputeAdapter(PayWizardClient client) : IDisputeManager
{
public Task SubmitEvidenceAsync(string txId, DisputeEvidence evidence) => ...;
// ...
}
// DI: register all three
services.AddScoped<IChargeProcessor, PayWizardChargeAdapter>();
services.AddScoped<IRefundProcessor, PayWizardRefundAdapter>();
services.AddScoped<IDisputeManager, PayWizardDisputeAdapter>();
Now OrderService injects only IChargeProcessor. FinanceService injects only IRefundProcessor. Each adapter has 3 methods instead of 12. Testing is focused: PayWizardChargeAdapterTests only needs to mock PayWizardClient's transaction methods, not all 12. When PayWizard releases a new refunds API, you change PayWizardRefundAdapter alone — the charge and dispute adapters are untouched.
Five hands-on exercises progressing from Easy to Hard: adapting a legacy logger to ILogger; bridging sync and async blob storage with a trade-off analysis of Task.Run vs truly async; spotting and fixing a Singleton lifetime bug; designing contract tests that run against multiple adapters from one base class; and refactoring a 12-method god adapter into three focused single-responsibility adapters.
Section 19
Cheat Sheet — The 30-Second Recap
You’ve absorbed a lot. This section is your compressed, take-anywhere reference — nine key insights distilled down to a glance. Bookmark it, screenshot it, paste it into your notes. The prose behind each item lives in the sections above.
The SVG above shows the adapter’s position between a client and an incompatible adaptee, plus six quick-fire rules you’ll want to remember for code reviews and interviews.
A translator class that wraps an incompatible interface and exposes the one your code already expects — neither side needs to change.Uses composition — holds a private field referencing the adaptee and delegates calls. This is the C# default because C# has no multiple-class inheritance.Adapter changes the interface shape. Decorator preserves it and adds behaviour. This distinction comes up in every panel interview.The adaptee is unchangeable (vendor SDK, legacy code, sealed class) and you need it to satisfy an interface it was never designed for.You own both sides — just align the interfaces directly. Adding an Adapter on code you control means two places to change instead of one.Always map vendor exceptions to your domain exceptions inside the adapter. Never let a PayWizardException leak through the IPaymentProcessor boundary.Unit-test the adapter with a mocked adaptee. Integration-test the real adaptee separately. Never mix the two — you'll spend 20 minutes debugging network calls in what should be a 5ms unit test.For DTO-mapping adapters on hot paths, prefer Mapperly (source-generated, zero reflection) over AutoMapper (reflection-based, has startup cost). Use AutoMapper only when convention-based mapping is genuinely simpler.The “Ports & Adapters” architectural pattern uses the Adapter name, but it is a broader, architectural-layer concept — the whole boundary between your domain and infrastructure. The GoF Adapter is a single class. Don’t conflate them in interviews.Nine compressed rules cover everything you need to recall about Adapter in a code review or interview. The most common traps: confusing Adapter with Decorator, and forgetting to translate exceptions at the adapter boundary.
Section 20
Real-World Mini-Project — Build an EmailSender Adapter
Reading about patterns is useful. Building one from scratch is where the understanding gets locked in. This section walks you through a complete, production-flavoured project: a unified email-sending interface with two interchangeable adapters — one for SendGrid, one for SMTP — plus error translation, a retry decorator, and tests that prove it all works.
The Problem Statement
Your application sends emails. You’re using System.Net.Mail.SmtpClient in development and staging, and SendGrid’s SDK in production. Both can send email, but their APIs look nothing alike. You want your application code — the UserService, the PasswordResetService, the InvoiceService — to be completely ignorant of which provider is active. Swap the provider by changing one DI registration, zero application changes.
The architecture is clean: three application services talk only to IEmailSender. Two adapters — SendGridEmailSenderAdapter and SmtpEmailSenderAdapter — each implement that interface and wrap their respective vendor SDK. A RetryEmailSender decorator wraps whichever adapter is registered, adding retry logic without touching either adapter. The DI container decides which adapter to use at startup.
The Code, File by File
// IEmailSender.cs — the Target interface your application code knows
// Completely independent of SendGrid, SMTP, or any vendor.
namespace YourApp.Email;
/// <summary>
/// Represents the ability to send an email message.
/// This is the Target interface — adapters must implement it.
/// </summary>
public interface IEmailSender
{
/// <summary>Sends a single email message asynchronously.</summary>
/// <exception cref="EmailDeliveryException">
/// Thrown when the underlying provider fails in a non-retryable way.
/// Adapter implementations are responsible for translating provider-
/// specific exceptions into this domain exception.
/// </exception>
Task SendAsync(EmailMessage message, CancellationToken ct = default);
}
/// <summary>A simple value object carrying the email payload.</summary>
public sealed record EmailMessage(
string To,
string Subject,
string HtmlBody,
string? PlainTextBody = null,
string From = "noreply@yourapp.com"
);
/// <summary>
/// Domain-specific exception that all adapters must translate to.
/// Keeps vendor exception types out of application code.
/// </summary>
public sealed class EmailDeliveryException : Exception
{
public string Provider { get; }
public EmailDeliveryException(string provider, string message, Exception? inner = null)
: base($"[{provider}] {message}", inner)
{
Provider = provider;
}
}// SendGridEmailSenderAdapter.cs — wraps the SendGrid SDK
// The Adaptee's API: SendGridClient.SendEmailAsync(SendGridMessage)
// Our Target API: IEmailSender.SendAsync(EmailMessage)
using SendGrid;
using SendGrid.Helpers.Mail;
using YourApp.Email;
namespace YourApp.Email.Adapters;
public sealed class SendGridEmailSenderAdapter : IEmailSender
{
private readonly ISendGridClient _client; // the adaptee (vendor)
private readonly ILogger<SendGridEmailSenderAdapter> _logger;
// Inject the vendor client via constructor — easy to mock in tests
public SendGridEmailSenderAdapter(ISendGridClient client,
ILogger<SendGridEmailSenderAdapter> logger)
{
_client = client;
_logger = logger;
}
public async Task SendAsync(EmailMessage message, CancellationToken ct = default)
{
// --- TRANSLATION LAYER ---
// Adapter's job: convert our EmailMessage into a SendGridMessage
var sgMessage = new SendGridMessage();
sgMessage.SetFrom(message.From);
sgMessage.AddTo(message.To);
sgMessage.SetSubject(message.Subject);
sgMessage.AddContent(MimeType.Html, message.HtmlBody);
if (message.PlainTextBody is not null)
sgMessage.AddContent(MimeType.Text, message.PlainTextBody);
// --- CALL THE ADAPTEE ---
Response response;
try
{
response = await _client.SendEmailAsync(sgMessage, ct);
}
catch (Exception ex)
{
// --- EXCEPTION TRANSLATION ---
// Never let SendGrid-specific exceptions escape the adapter boundary.
// Callers know nothing about SendGrid; they should catch EmailDeliveryException.
_logger.LogError(ex, "SendGrid threw an unexpected exception");
throw new EmailDeliveryException("SendGrid",
"Unexpected error contacting the SendGrid API", ex);
}
// SendGrid returns HTTP-style status codes inside the response body
if (!response.IsSuccessStatusCode)
{
var body = await response.Body.ReadAsStringAsync(ct);
_logger.LogWarning("SendGrid rejected the message. StatusCode={Code} Body={Body}",
response.StatusCode, body);
throw new EmailDeliveryException("SendGrid",
$"Delivery rejected with status {(int)response.StatusCode}: {body}");
}
}
}// SmtpEmailSenderAdapter.cs — wraps System.Net.Mail.SmtpClient
// The Adaptee's API: SmtpClient.SendMailAsync(MailMessage)
// Our Target API: IEmailSender.SendAsync(EmailMessage)
using System.Net.Mail;
using YourApp.Email;
namespace YourApp.Email.Adapters;
public sealed class SmtpEmailSenderAdapter : IEmailSender, IDisposable
{
private readonly SmtpClient _smtp; // the adaptee
private readonly ILogger<SmtpEmailSenderAdapter> _logger;
public SmtpEmailSenderAdapter(SmtpClient smtp,
ILogger<SmtpEmailSenderAdapter> logger)
{
_smtp = smtp;
_logger = logger;
}
public async Task SendAsync(EmailMessage message, CancellationToken ct = default)
{
// --- TRANSLATION LAYER ---
// SmtpClient uses MailMessage, not our EmailMessage record
using var mail = new MailMessage(
from: message.From,
to: message.To,
subject: message.Subject,
body: message.HtmlBody // default view
)
{
IsBodyHtml = true
};
// Add plain-text alternate view if provided
if (message.PlainTextBody is not null)
{
mail.AlternateViews.Add(
AlternateView.CreateAlternateViewFromString(
message.PlainTextBody,
null,
"text/plain"
)
);
}
// --- CALL THE ADAPTEE ---
try
{
await _smtp.SendMailAsync(mail, ct);
}
catch (SmtpException ex)
{
// --- EXCEPTION TRANSLATION ---
// SmtpException stays inside this adapter — callers get EmailDeliveryException
_logger.LogError(ex, "SMTP delivery failed. StatusCode={Code}", ex.StatusCode);
throw new EmailDeliveryException("SMTP",
$"SMTP delivery failed (code: {ex.StatusCode})", ex);
}
catch (OperationCanceledException)
{
// Let cancellation propagate naturally — it's not a delivery failure
throw;
}
}
public void Dispose() => _smtp.Dispose();
}// RetryEmailSender.cs — Decorator that wraps ANY IEmailSender with retry logic
// This is the Adapter + Decorator composition story:
// Adapter makes the vendor fit IEmailSender.
// Decorator adds behaviour on top of IEmailSender.
// Both use the same interface, so they compose cleanly.
using Polly;
using Polly.Retry;
using YourApp.Email;
namespace YourApp.Email;
public sealed class RetryEmailSender : IEmailSender
{
private readonly IEmailSender _inner; // the wrapped adapter (or another decorator)
private readonly AsyncRetryPolicy _policy;
public RetryEmailSender(IEmailSender inner, ILogger<RetryEmailSender> logger)
{
_inner = inner;
// Retry up to 3 times on EmailDeliveryException, with exponential back-off.
// Do NOT retry on OperationCanceledException — that's intentional.
_policy = Policy
.Handle<EmailDeliveryException>()
.WaitAndRetryAsync(
retryCount: 3,
sleepDurationProvider: attempt => TimeSpan.FromSeconds(Math.Pow(2, attempt)),
onRetry: (ex, delay, attempt, _) =>
logger.LogWarning(ex,
"Email delivery attempt {Attempt} failed; retrying in {Delay:g}", attempt, delay)
);
}
public Task SendAsync(EmailMessage message, CancellationToken ct = default)
=> _policy.ExecuteAsync(() => _inner.SendAsync(message, ct));
}
// --- DI Registration example (Program.cs / Startup.cs) ---
//
// builder.Services.AddScoped<ISendGridClient>(sp =>
// new SendGridClient(builder.Configuration["SendGrid:ApiKey"]));
//
// builder.Services.AddScoped<IEmailSender>(sp =>
// {
// var inner = sp.GetRequiredService<SendGridEmailSenderAdapter>();
// var logger = sp.GetRequiredService<ILogger<RetryEmailSender>>();
// return new RetryEmailSender(inner, logger);
// });// EmailSenderTests.cs — xUnit + NSubstitute tests
// Strategy: mock the vendor (ISendGridClient), test the adapter logic in isolation.
using NSubstitute;
using SendGrid;
using SendGrid.Helpers.Mail;
using System.Net;
using Xunit;
using YourApp.Email;
using YourApp.Email.Adapters;
public class SendGridAdapterTests
{
private static EmailMessage SampleMessage() => new(
To: "alice@example.com",
Subject: "Hello from tests",
HtmlBody: "<p>Hi</p>"
);
[Fact]
public async Task SendAsync_SuccessfulResponse_DoesNotThrow()
{
// Arrange
var client = Substitute.For<ISendGridClient>();
var logger = Substitute.For<ILogger<SendGridEmailSenderAdapter>>();
var sut = new SendGridEmailSenderAdapter(client, logger);
// Simulate a successful SendGrid HTTP 202 response
client.SendEmailAsync(Arg.Any<SendGridMessage>(), Arg.Any<CancellationToken>())
.Returns(new Response(HttpStatusCode.Accepted, null, null));
// Act & Assert — no exception means success
await sut.SendAsync(SampleMessage());
}
[Fact]
public async Task SendAsync_RejectResponse_ThrowsEmailDeliveryException()
{
// Arrange
var client = Substitute.For<ISendGridClient>();
var logger = Substitute.For<ILogger<SendGridEmailSenderAdapter>>();
var sut = new SendGridEmailSenderAdapter(client, logger);
// Simulate a 403 Forbidden — bad API key etc.
client.SendEmailAsync(Arg.Any<SendGridMessage>(), Arg.Any<CancellationToken>())
.Returns(new Response(HttpStatusCode.Forbidden,
new StringContent("Unauthorized"), null));
// Act & Assert — adapter must throw our domain exception, not a SendGrid type
var ex = await Assert.ThrowsAsync<EmailDeliveryException>(
() => sut.SendAsync(SampleMessage())
);
Assert.Equal("SendGrid", ex.Provider);
}
[Fact]
public async Task SendAsync_VendorThrows_WrapsInEmailDeliveryException()
{
// Arrange
var client = Substitute.For<ISendGridClient>();
var logger = Substitute.For<ILogger<SendGridEmailSenderAdapter>>();
var sut = new SendGridEmailSenderAdapter(client, logger);
client.SendEmailAsync(Arg.Any<SendGridMessage>(), Arg.Any<CancellationToken>())
.ThrowsAsync(new HttpRequestException("network timeout"));
// Act
var ex = await Assert.ThrowsAsync<EmailDeliveryException>(
() => sut.SendAsync(SampleMessage())
);
// The original exception should be preserved as InnerException — key for debugging
Assert.IsType<HttpRequestException>(ex.InnerException);
}
}
Walking Through What Matters
IEmailSender.cs defines three things: the target interface (IEmailSender), the data carried across the boundary (EmailMessage), and the domain exception (EmailDeliveryException). Notice that none of these mention SendGrid or SMTP anywhere — the interface is entirely vendor-agnostic. That’s the whole point.
SendGridAdapter.cs is where adaptation actually happens. The constructor takes ISendGridClient — not SendGridClient — making it mockable. Inside SendAsync, there are three clearly labelled zones: translation (build the SendGrid-shaped message), call (hand it to the vendor), and exception translation (never let HttpRequestException or vendor-specific types escape). The third zone is the one developers most often omit, and it always bites them when a vendor changes their exception types.
SmtpAdapter.cs shows a subtly different challenge: SmtpClient is IDisposable, so the adapter must be too. Notice the OperationCanceledException re-throw — cancellation is intentional user behaviour, not a delivery failure, and it must propagate cleanly so the caller can handle it.
The composition diagram illustrates a key insight: because both RetryEmailSender (Decorator) and the adapters (Adapter) all implement IEmailSender, they stack without friction. The Decorator doesn’t know whether it’s wrapping SendGrid or SMTP — it just sees IEmailSender. This is the power of both patterns working together: Adapter gets the vendor to speak your language; Decorator adds cross-cutting concerns without touching the adapter at all.
The EmailSender mini-project demonstrates Adapter in full production form: target interface, two vendor-specific adapters with complete exception translation, a Polly-based retry Decorator, and three unit tests. The key lesson: the exception-translation layer inside the adapter is what keeps vendor implementation details from leaking into your application code.
Section 21
Common Misconceptions — Mental Model Fixes
These aren’t code bugs — those are in the Pitfalls section. These are wrong mental models: beliefs that feel right but lead you to misapply, misname, or mis-explain the pattern. Fix these now and they won’t trip you up mid-interview.
This is the most common interview slip. Both patterns wrap a class — so students assume they’re variants of the same idea. They’re not. The single clearest line: Adapter changes the interface. Decorator preserves it.
If you wrap LegacyLogger (which has WriteEntry(string)) to expose ILogger (which has Log(LogLevel, string)) — that’s an Adapter. The method signatures are different before and after. If you wrap an ILogger to add timestamps to every log call while still exposing exactly ILogger — that’s a Decorator. The signatures are identical; you’re just adding behaviour. One translates shapes; the other enriches behaviour. Completely different purposes.
Vendor SDKs are the most obvious use case — they’re sealed, they’re external, you can’t change them. So students naturally think “Adapter = vendor wrapper.”
But wrapping your own legacy code is just as common, and arguably more impactful. Imagine a 5-year-old UserRepository with synchronous methods scattered across 80 call sites. You want to introduce async everywhere. You can’t refactor all 80 sites at once — that’s a multi-week regression risk. So you extract an IUserRepository interface with async methods, write an AsyncUserRepositoryAdapter that wraps the old synchronous repository (using Task.FromResult for now), and migrate call sites one at a time. The Adapter insulates new code from old code — no third party required.
The GoF book describes a class-based pattern, so students think “adapter” always means a full class with a constructor and fields. In C# you often don’t need that much ceremony.
A delegate or lambda can adapt one callback shape to another. An extension method can adapt a third-party class to an interface it doesn’t implement. A static extension on OldApiResponse that converts it to NewApiResponse is a lightweight adapter. Use the full class when you need state (holding the adaptee reference, configuring options); use a lambda or extension when the translation is a one-liner.
Alistair Cockburn named his architectural style “Ports and Adapters” — and the word choice confuses a lot of developers who know GoF.
In Hexagonal Architecture, a Port is an interface at the boundary of your domain (equivalent to GoF’s Target interface). An Adapter is the entire infrastructure layer that connects your domain to the outside world — HTTP controllers, database repositories, message consumers. It’s an architectural zone, not a single class. The GoF Adapter is one class that bridges two specific incompatible interfaces. The relationship: every GoF Adapter you write for your infrastructure layer is one piece of a Hexagonal Architecture Adapter layer. Related concepts, different scopes. Saying “I’m using Hexagonal Architecture, so I’m using the Adapter pattern” is like saying “I’m using a kitchen, so I’m using a knife.” True, but incomplete.
Method renaming is a trivially light use of Adapter, and students sometimes over-generalise: “Oh, you just rename SubmitTransaction to Charge? That’s a Facade, not an Adapter.”
A Facade simplifies a complex subsystem. Adapter translates an interface shape. When an Adapter only renames a method, the interface shape is still incompatible — Charge(decimal, string) vs SubmitTransaction(PaymentRequest) — and the adapter bridges it. If the Adapter also aggregates multiple subsystem calls, initialises objects, handles errors, and returns simplified results, the distinction from Facade blurs — which is fine. In real code patterns often overlap. In an interview, the line is: if the primary goal is bridging an interface mismatch, it’s an Adapter. If the primary goal is simplifying complexity, it’s a Facade.
AutoMapper is convenient — configure once, map everywhere using reflection. For low-volume APIs, the overhead is negligible. So developers default to it everywhere.
The problem surfaces on hot paths. AutoMapper’s reflection overhead adds up. An API endpoint mapping 1,000 database rows per request, called 500 times per second, sees real latency from all that reflection. Mapperly generates C# source code at build time — the resulting mapping is a plain compiled method call, zero reflection overhead. Published benchmarks consistently show Mapperly outperforming AutoMapper — typically in the ~1.5–2x range for collection mapping, and noticeably more for single-object mappings — with lower allocations as well. Rule of thumb: use AutoMapper for admin screens, background jobs, low-frequency endpoints; use Mapperly for high-throughput data pipelines and hot API paths.
Adapter is the right tool when one side is fixed. But students sometimes reach for Adapter reflexively, even when they own both sides of the mismatch.
If you control both the calling code and the called code, the right answer is usually to align the interfaces directly — eliminate the mismatch at the source. An Adapter in this scenario means two places to update instead of one: the adapter AND the underlying type. The Adapter’s value is specifically that it allows adaptation without touching either end. If you can touch one end, you probably should, and avoid the indirection entirely.
Seven mental-model corrections covering the Adapter vs Decorator distinction, the scope of legitimate use cases, lightweight adaptation via lambdas, the difference between GoF Adapter and Hexagonal Architecture, the Adapter vs Facade boundary, AutoMapper vs Mapperly trade-offs, and when NOT to use Adapter at all.
Section 22
Migration Playbook — Refactoring Legacy Code with Adapter
Adapter isn’t just a design-time pattern. Its most powerful use in real teams is as a migration tool — a way to incrementally replace legacy code without a big-bang rewrite. Here’s how a senior team uses it to swap out a payment provider in a live production codebase, risk-free, one step at a time.
The diagram shows the four stages laid out horizontally. The crucial observation: each step is its own pull request and its own deployment. After Step 2, production still runs identically — the legacy code is running, just wrapped. After Step 3, a feature flag or a DI config change can switch providers in production with zero code changes. Step 4 is cleanup, done when you’re confident.
The Scenario
Your team inherited a codebase with a LegacyPaymentService class that is called directly from 47 places. No interface, no abstraction — just raw new LegacyPaymentService().ProcessPayment(...) calls everywhere. The business has signed a contract with a new payment provider. You have 6 weeks. Your manager asks if you’re “just going to do a big rewrite.” You say no — and here’s why:
Why a big-bang rewrite is dangerous: Rewriting all 47 call sites simultaneously means all 47 are under test at once. Any regression in week 5 could be in any of those 47 places. The Adapter approach means each call site migrates one at a time, with the old code still running as a safety net. Maximum blast radius at any step: one feature, one service, one test failure.
Step 1 — Extract the Target Interface
Look at how the 47 call sites currently use LegacyPaymentService. What methods do they actually call? In our case: ProcessPayment(string cardId, decimal amount) and RefundPayment(string transactionId). Define an interface that exactly matches this usage shape — don’t add methods nobody uses yet.
// Step 1: Extract the interface your consumers already implicitly need
// Don't design a "ideal" interface — design the interface that matches
// the ACTUAL usage pattern in your 47 call sites.
namespace Payments;
public interface IPaymentProcessor
{
// matches: legacyService.ProcessPayment(cardId, amount)
Task<PaymentResult> ProcessPaymentAsync(string cardId, decimal amount);
// matches: legacyService.RefundPayment(transactionId)
Task RefundPaymentAsync(string transactionId);
}
// Risk assessment: ZERO risk. This step adds a new interface.
// Nothing in the codebase changes. No call sites updated yet.
Step 2 — Wrap Legacy Code with an Adapter
Write a LegacyPaymentAdapter that implements your new interface and delegates every call to the original LegacyPaymentService. Now update the 47 call sites — not to change behaviour, just to inject IPaymentProcessor via constructor instead of newing up LegacyPaymentService directly. Register the adapter in DI as IPaymentProcessor. Production behaviour is identical — you’re just routing through the adapter now.
// LegacyPaymentAdapter.cs
// Wraps the existing LegacyPaymentService so it satisfies IPaymentProcessor.
// This is your safety net: all 47 call sites use the NEW interface,
// but the OLD code is still running underneath.
public sealed class LegacyPaymentAdapter : IPaymentProcessor
{
private readonly LegacyPaymentService _legacy; // the adaptee
public LegacyPaymentAdapter(LegacyPaymentService legacy)
=> _legacy = legacy;
public Task<PaymentResult> ProcessPaymentAsync(string cardId, decimal amount)
{
// LegacyPaymentService is synchronous — wrap in Task for the interface contract
var result = _legacy.ProcessPayment(cardId, amount);
return Task.FromResult(new PaymentResult(result.Success, result.TransactionId));
}
public Task RefundPaymentAsync(string transactionId)
{
_legacy.RefundPayment(transactionId);
return Task.CompletedTask;
}
}
// DI registration (Step 2 — legacy still under the hood)
// builder.Services.AddScoped<LegacyPaymentService>();
// builder.Services.AddScoped<IPaymentProcessor, LegacyPaymentAdapter>();
// Risk assessment: LOW. 47 call sites updated to inject IPaymentProcessor.
// Behaviour unchanged — adapter delegates to legacy code.
// Rollback: change DI registration back. One-line fix.
Step 3 — Build the New Provider Adapter
Write NewPaymentAdapter that implements IPaymentProcessor and wraps the new vendor’s SDK. This adapter exists in parallel with the legacy adapter — production still uses LegacyPaymentAdapter. You can test NewPaymentAdapter thoroughly with unit tests and integration tests against the new provider’s sandbox environment, without touching production at all. When confident, swap the DI registration.
// NewPaymentAdapter.cs
// Wraps the new payment provider SDK.
// Identical interface as LegacyPaymentAdapter — swap is one DI line.
public sealed class NewPaymentAdapter : IPaymentProcessor
{
private readonly INewPayClient _client; // new vendor's SDK
private readonly ILogger<NewPaymentAdapter> _logger;
public NewPaymentAdapter(INewPayClient client, ILogger<NewPaymentAdapter> logger)
{
_client = client;
_logger = logger;
}
public async Task<PaymentResult> ProcessPaymentAsync(string cardId, decimal amount)
{
try
{
var response = await _client.ChargeAsync(new ChargeRequest
{
CardToken = cardId,
AmountCents = (int)(amount * 100) // API uses cents — translation!
});
return new PaymentResult(response.Approved, response.ConfirmationCode);
}
catch (NewPayApiException ex)
{
_logger.LogError(ex, "NewPay charge failed for card {CardId}", cardId);
throw new PaymentProcessingException("NewPay", ex.Message, ex);
}
}
public async Task RefundPaymentAsync(string transactionId)
{
await _client.VoidChargeAsync(transactionId);
}
}
// DI registration swap (Step 3 — new provider now active)
// builder.Services.AddScoped<INewPayClient, NewPayClient>();
// builder.Services.AddScoped<IPaymentProcessor, NewPaymentAdapter>(); ← single line change!
// Risk assessment: LOW. 47 call sites untouched. Only DI config changed.
// Rollback: revert one line of DI config. Done in under a minute.
Step 4 — Retire the Legacy Code
After the new provider has been running in production for a confidence window (a sprint, a month — your team decides), you delete LegacyPaymentService and LegacyPaymentAdapter. The 47 call sites don’t care — they’re talking to IPaymentProcessor and have been since Step 2. This is just cleanup. The codebase is now clean: one interface, one adapter, one provider.
The before/after diagram makes the payoff visible. Before: every service has a direct dependency on a concrete legacy class — any change to that class is a change that could break all 47 of its consumers. After: every service depends on an interface. The concrete adapter lives in one place. Swapping the payment provider is a DI configuration change — not a codebase change.
The four-step migration playbook — Extract Interface, Wrap Legacy, Build New Adapter, Retire Legacy — converts a fragile 47-site concrete dependency into a clean interface-based system with zero big-bang risk. Each step is independently deployable and independently rollback-able.
Section 23
Glossary — Adapter Terms
All the technical vocabulary from this page, defined in plain English. Use this as a lookup when reading other resources that use these terms.
Adapter
The wrapper class itself. It implements the Target interface and internally calls the Adaptee. Its job is purely translation — converting one API shape into another without adding business logic.
Target Interface
The interface your existing code already expects. Everything in your application calls methods on this. The Adapter must implement this interface exactly — it’s the "shape" your codebase speaks.
Adaptee
The existing class with the incompatible interface. Often a vendor library or legacy component you cannot (or should not) modify. The Adapter wraps the Adaptee.
Client
The code that uses the Target interface. The client never sees the Adaptee. From its perspective, it’s just calling the interface it always knew — the Adapter is invisible.
Object Adapter
The composition-based variant. The Adapter holds a private reference (a field) to an instance of the Adaptee and calls its methods. This is the standard approach in C# because it works even with sealed classes.
Class Adapter
The inheritance-based variant. The Adapter class inherits from both the Target and the Adaptee simultaneously using multiple inheritance. Not possible in C# (which only supports single class inheritance), but usable in Java or C++.
Two-Way Adapter
An Adapter that can translate in both directions — making A look like B AND making B look like A simultaneously. Rare in practice, but useful in bidirectional integration scenarios like protocol bridges.
Pluggable Adapter
An Adapter that is flexible enough to adapt multiple Adaptees, typically by accepting a delegate or lambda for the translation logic. Useful when the translation rule varies per-use-case but the overall structure stays the same.
Anti-Corruption Layer (ACL)
A DDD (Domain-Driven Design) concept for a translation boundary that prevents an external system’s model from bleeding into your domain model. In practice, an ACL is often implemented using Adapters. The Adapter handles method-level translation; the ACL handles model-level translation.
Hexagonal Architecture / Ports & Adapters
An architectural style by Alistair Cockburn that separates domain logic from infrastructure via "Ports" (interfaces) and "Adapters" (concrete implementations that connect to the outside world). The "Adapter" in this context is an architectural layer, not a single class. Related to GoF Adapter but bigger in scope.
Facade
A GoF Structural pattern that provides a simplified interface to a complex subsystem. Where Adapter bridges interface incompatibility, Facade hides complexity. Key interview distinction.
Decorator
A GoF Structural pattern that adds behaviour to an object while preserving its interface. Where Adapter changes the interface, Decorator keeps it the same and adds onto it. Both use composition (wrapping), but for different purposes.
COM Interop
The .NET mechanism for calling Windows COM (Component Object Model) components from managed code. Implemented under the hood using two types of adapter wrappers: Runtime Callable Wrappers (RCW) and COM Callable Wrappers (CCW).
RCW (Runtime Callable Wrapper)
An adapter generated by the .NET runtime that wraps a COM object and presents it as a managed .NET object. Makes unmanaged COM interfaces callable from C# as if they were normal .NET classes.
CCW (COM Callable Wrapper)
The reverse of RCW. An adapter that wraps a managed .NET object and exposes it as a COM interface so that unmanaged COM code can call it. The translation goes in the opposite direction.
Fifteen Adapter-related terms defined in plain English, from the core GoF roles (Adapter, Target, Adaptee, Client) through C# variants (Object vs Class Adapter), advanced forms (Two-Way, Pluggable), broader patterns (ACL, Hexagonal Architecture), and .NET-specific adapters (COM Interop, RCW, CCW).
Section 24
Related Topics — What to Study Next
Adapter doesn’t live alone. Understanding it fully means understanding the patterns it composes with, competes with, and depends on. Here’s the road map for what to study next — ordered by how directly each topic connects to what you just learned.
Your suggested study sequence:
Decorator — master the Adapter/Decorator distinction, then compose them (like RetryEmailSender above)
Facade — nail the Adapter vs Facade interview question once and for all
Dependency Inversion Principle — understand WHY the interface-first thinking that makes Adapter powerful
Strategy — see how Adapter + Strategy combine for runtime-swappable implementations
Hexagonal Architecture — scale everything you learned to the architectural level
Six related topics link Adapter into the broader pattern and architecture landscape. Study Decorator next — it’s the most common composition partner. The Dependency Inversion Principle is the theoretical foundation that makes Adapter easy to apply. Hexagonal Architecture is where GoF Adapter thinking becomes a full architectural strategy.