Skip to main content
ANVISoftware Solutions
Lesson 11 of 19Advanced18 min

Adapter Pattern

By the end of this lesson

Wrap an external interface so it does not leak through your code.

An adapter is one class that translates between an interface you own and an interface somebody else owns. Your application asks to charge an invoice in its own words; the adapter turns that into whatever the payment provider's library expects, and turns the answer back into something your code can reason about.

The problem it solves is spread. A third-party library's types, status strings, units and exceptions travel wherever it is called from, and each of those places quietly becomes a file that knows about the provider. Replacing the provider then means opening all of them, and the provider's vocabulary has become your vocabulary in the meantime.

A handler talking to a provider library directly
C#
public sealed class ChargeInvoiceHandler(
    PaymentGatewayClient gateway,
    IInvoiceRepository invoices)
{
    public async Task<string> HandleAsync(int invoiceId, CancellationToken token)
    {
        Invoice invoice = await invoices.FindAsync(invoiceId, token)
            ?? throw new InvoiceNotFoundException(invoiceId);

        var request = new GatewayChargeRequest
        {
            AmountMinorUnits = (long)(invoice.Total * 100),
            CurrencyIso = "GBP",
            IdempotencyToken = invoice.Number,
        };

        GatewayChargeResponse response = await gateway.ChargeAsync(request, token);

        if (response.StatusCode == "AUTH_OK")
        {
            invoice.MarkPaid(response.ChargeId);
            await invoices.SaveChangesAsync(token);
            return response.ChargeId;
        }

        if (response.StatusCode == "AUTH_SOFT_DECLINE")
            throw new PaymentDeclinedException("Try again later.");

        throw new PaymentDeclinedException(response.Message);
    }
}
  • Two of the provider's types appear in the application layer, which means the application project now references the provider's package. The dependency rule the earlier lessons set up has a hole in it.
  • The provider's status strings are being compared in your business flow. Nothing in the language stops a typo, and when the provider adds AUTH_REVIEW next year this code treats it as a hard decline.
  • Converting pounds to minor units happens here. It will be written again in the refund handler, and one of the two will get the rounding wrong.
  • There are usually four or five handlers like this. Changing provider means editing every one, under time pressure, with the old provider already switched off.
  • Testing this handler needs the provider's client type. If it is sealed or has no accessible constructor, you are either running tests against a sandbox account or not testing the handler at all.
Your interface, your outcomes, one file that knows the provider
C#
// Application layer — no provider types, no provider strings
public interface IPaymentGateway
{
    Task<PaymentResult> ChargeAsync(PaymentRequest request, CancellationToken token);
}

public sealed record PaymentRequest(string Reference, decimal Amount, string CurrencyCode);

public abstract record PaymentResult
{
    public sealed record Captured(string ProviderReference) : PaymentResult;
    public sealed record Declined(string Reason, bool WorthRetrying) : PaymentResult;
    public sealed record Unavailable(string Detail) : PaymentResult;
}

// Infrastructure — the only place the provider exists
internal sealed class NorthPayGateway(PaymentGatewayClient client) : IPaymentGateway
{
    public async Task<PaymentResult> ChargeAsync(PaymentRequest request, CancellationToken token)
    {
        try
        {
            GatewayChargeResponse response = await client.ChargeAsync(
                new GatewayChargeRequest
                {
                    AmountMinorUnits = (long)decimal.Round(request.Amount * 100m, 0,
                        MidpointRounding.AwayFromZero),
                    CurrencyIso = request.CurrencyCode,
                    IdempotencyToken = request.Reference,
                },
                token);

            return response.StatusCode switch
            {
                "AUTH_OK" => new PaymentResult.Captured(response.ChargeId),
                "AUTH_SOFT_DECLINE" => new PaymentResult.Declined(response.Message, WorthRetrying: true),
                "AUTH_HARD_DECLINE" => new PaymentResult.Declined(response.Message, WorthRetrying: false),
                _ => new PaymentResult.Unavailable(
                    $"Unrecognised gateway status {response.StatusCode}."),
            };
        }
        catch (GatewayTransportException ex)
        {
            return new PaymentResult.Unavailable(ex.Message);
        }
    }
}
  • The request speaks in decimals and a currency code, because that is what an invoicing application has. Minor-unit conversion happens once, in the class that needs it.
  • The result is three outcomes your code can act on. Captured means update the invoice, Declined with WorthRetrying means try later, Unavailable means the provider could not answer. That last distinction matters: a refusal and an outage need different handling, and a single boolean cannot express it.
  • An unrecognised status becomes Unavailable rather than a decline. When the provider adds a status you have never seen, failing visibly is safer than guessing that it meant no.
  • The provider's exception type is caught here and does not escape. Otherwise every caller has to catch a type from a package the application layer should not reference, and a missed catch becomes a 500.
  • A second provider is a second class implementing the same interface, plus one registration change. No handler is edited, and the two can run side by side while you move traffic across.

What an adapter must not let through, because each of these is a way the provider becomes your model:

  • Their types in your signatures. An adapter returning GatewayChargeResponse has renamed the problem.
  • Their exceptions. Catch them at the boundary and return an outcome, or throw an exception of your own.
  • Their status codes and magic strings. Translate them into a closed set of outcomes your code can exhaust.
  • Their units and formats. Minor units, their date format, their country codes — convert once, here.
  • Their nulls and their sentinel values. An empty string meaning "unknown" should become an explicit case before it leaves this class.
  • Their retry and timeout behaviour. Decide your own policy at this boundary rather than inheriting theirs by default.

Calling a provider library directly, or through an adapter:

 Called directlyBehind an adapter
Where provider types appearEvery calling file, and its project referencesOne infrastructure class
Changing providerEdit every callerAdd a class, change one registration
Unit and format conversionRepeated per call siteOnce
Testing the callerNeeds the provider's client typeA fake of your own interface
Testing the mappingImplicit, and usually untestedOne class to test against sandbox or recorded responses
Files to maintainNone extraInterface, result types, adapter, registration

Summary

  • An adapter translates between an interface you own and one somebody else owns
  • Keep the provider's types, exceptions, status codes and units inside it
  • Design the port from what the caller needs, not from the shape of the library
  • A second provider becomes a second class rather than an edit to every caller
  • Skip it for stable, generic libraries used in one place, and cover the mapping with tests against the real provider

Practice

Attempt each one before opening the solution. Getting it wrong first is how the idea sticks.

Try it yourself

Write the port before you read the SDK

A credit-check provider's library exposes CreditBureauClient.LookupAsync(BureauQuery) returning a BureauReport with about forty properties, including a RiskBand string from A to E and a free-text note.

Your application needs two things: whether a customer may be offered 30-day payment terms, and a reason a credit controller can read. Write the interface and result types your application layer should depend on — without opening the library's documentation again.

Show solution

Three types are enough: a port with one method, a request carrying the customer identifier and the amount of credit being considered, and a result with a decision and a reason.

Writing it from the caller's need is the whole exercise. An interface derived from the report's forty properties is the SDK with a new namespace, and the next developer will still be reading about risk bands in the application layer.

There is a real design question buried here, and it is worth stopping on: who decides that band C means manual review? If that threshold is your commercial policy, it does not belong in the adapter — the adapter should surface the band and a domain policy should interpret it. If the provider defines it contractually, mapping it here is right. Both answers are defensible; drifting between them is not.

Keep the free-text note. Abstractions that discard the reason force the credit controller to log into the provider's portal, and then your interface has not replaced the dependency at all.

C#
public interface ICreditAssessment
{
    Task<CreditDecision> AssessAsync(CreditEnquiry enquiry, CancellationToken token);
}

public sealed record CreditEnquiry(int CustomerId, decimal CreditRequested);

public sealed record CreditDecision(
    CreditOutcome Outcome,
    string Reason,
    decimal? RecommendedLimit);

public enum CreditOutcome
{
    Approved,
    ManualReview,
    Refused,
    NotAvailable,
}

Think about it

Which of these four deserves an adapter?

Decide for each: the JSON serialiser used throughout the codebase; a payment provider library called from one handler; an internal HR API that the owning team reshapes about twice a year; the logging library.

Show solution

JSON serialiser: no. Its concepts are the language's concepts, replacing it is a mechanical change, and a wrapper gives your team a second API to learn for no containment.

Payment provider: yes, despite being called from one place. Its status strings would otherwise become decisions in your application layer, the commercial relationship can change, and the mapping is worth testing on its own.

HR API: yes, and the change rate is the entire argument. Twice a year, the adapter absorbs a reshaped contract and no other file moves. This is the strongest case of the four.

Logging: no. ILogger is already a port with several implementations behind it. A wrapper around it adds a name.

The test that separates them is two questions. How likely is this to change, and would its vocabulary otherwise become ours? Two yeses make an adapter worth the files. One yes is a judgement call, and the number of call sites is part of it.

Saved in this browser only.