Skip to content
Commitwise
Menu
Design Patterns

Adapter

Make a vendor SDK, a legacy API or a callback-based library fit the interface your code expects, convert units, errors and callbacks to async/await at the boundary, and keep outside models from leaking into your app, in Swift, Kotlin, Java and TypeScript.

beginner

The core idea with one simple example.Step by step, with more examples and exercises.A cheat sheet: key APIs, gotchas and trade-offs.Internals, trade-offs and production pitfalls.

Your checkout talks to a PaymentGateway that takes an amount in cents. The new payment provider’s SDK wants a decimal amount in major units, a currency code in uppercase, a callback instead of async, and reports errors as numeric codes. You could sprinkle those details across the app, or you could write one small class that makes the SDK look exactly like the interface you already have. That class is an Adapter.

New to some of these? Open what you need, or skip ahead.

Basics:What is an interface (or protocol)?

An interface (Kotlin, Java) or protocol (Swift) is a list of things a type promises it can do, without saying how. For example, “anything that is a PaymentMethod has a pay(amount) function”.

Different classes can implement the same interface in different ways (card, PayPal, cash), and code that only knows the interface can use any of them. That’s what lets you swap one implementation for another, for example a fake one in tests.

Basics:What are lambdas and closures?

A lambda (Kotlin, Java) or closure (Swift) is a small function without a name that you can store in a variable or pass to another function:

Swift:   numbers.filter { $0 > 10 }           // $0 is the first parameter
Kotlin:  numbers.filter { it > 10 }           // it is the single parameter
Java:    numbers.stream().filter(n -> n > 10)

The code inside the braces (or after ->) runs later, when the function you passed it to decides to call it: once per number here, or when a button is tapped in UI code. A closure can also use variables from the code around it.

Basics:What is async/await?

Some work takes a while: a network request, reading a file. async/await lets you write that code top to bottom while the app keeps responding: let user = try await api.loadUser() waits for the result without blocking the screen. Older APIs use callbacks instead: you pass a function that the library calls when the result is ready. The Concurrency tutorial covers it in all four languages.

Part Not started

Making it fit

A travel adapter lets a European plug fit a British socket. Neither the plug nor the socket changes; the adapter in between translates one shape into the other, and the lamp works as if nothing were different.

In code, an adapter is a small class that implements the interface your code expects (the socket) and, inside, calls the class you actually have (the plug): a vendor SDK, an old library, another team’s API. The rest of the app keeps using its own interface and never learns what’s behind it.

An Adapter converts the interface of an existing class (the adaptee) into the interface clients expect (the target), typically by composition (an object adapter wrapping the adaptee). Its real job at system boundaries is translation of semantics, not just method names: units, currencies, encodings, time zones, nullability, error models, threading and callback styles. That’s also where adapters fail, so they deserve focused tests with real adaptee behavior (recorded responses, the vendor’s sandbox).

Before: The vendor's SDK spread through checkout

// In CheckoutViewModel, RefundsScreen, SubscriptionService…
let amount = Decimal(cart.totalCents) / 100
let result = try await payFast.pay(amount: amount,
                                   currencyCode: cart.currency.uppercased(),
                                   cardToken: token)
if result.statusCode == 402 { showDeclined() }
// In CheckoutViewModel, RefundsScreen, SubscriptionService…
val amount = BigDecimal(cart.totalCents).movePointLeft(2)
val result = payFast.pay(amount, cart.currency.uppercase(), token)
if (result.statusCode == 402) showDeclined()
// In CheckoutViewModel, RefundsScreen, SubscriptionService…
BigDecimal amount = BigDecimal.valueOf(cart.totalCents(), 2);
PayFastResult result = payFast.pay(amount, cart.currency().toUpperCase(), token);
if (result.statusCode() == 402) showDeclined();
// In CheckoutViewModel, RefundsScreen, SubscriptionService…
const amount = (cart.totalCents / 100).toFixed(2);
const result = await payFast.pay(amount, cart.currency.toUpperCase(), token);
if (result.statusCode === 402) showDeclined();

After: One adapter; the app keeps its own interface

protocol PaymentGateway {                                // what the app wants
    func charge(cents: Int, currency: String, token: String) async throws -> String
}

struct PayFastAdapter: PaymentGateway {                  // the only place that knows PayFast
    let client: PayFastClient

    func charge(cents: Int, currency: String, token: String) async throws -> String {
        let amount = Decimal(cents) / 100                // 1999 cents → 19.99
        let result = try await client.pay(amount: amount,
                                          currencyCode: currency.uppercased(),
                                          cardToken: token)
        guard result.statusCode == 200 else { throw PaymentError(code: result.statusCode) }
        return result.transactionId
    }
}
interface PaymentGateway {                               // what the app wants
    suspend fun charge(cents: Long, currency: String, token: String): String
}

class PayFastAdapter(private val client: PayFastClient) : PaymentGateway {   // the only place that knows PayFast
    override suspend fun charge(cents: Long, currency: String, token: String): String {
        val amount = BigDecimal.valueOf(cents, 2)        // 1999 cents → 19.99
        val result = client.pay(amount, currency.uppercase(), token)
        if (result.statusCode != 200) throw PaymentException(result.statusCode)
        return result.transactionId
    }
}
interface PaymentGateway {                               // what the app wants
    String charge(long cents, String currency, String token) throws PaymentException;
}

final class PayFastAdapter implements PaymentGateway {   // the only place that knows PayFast
    private final PayFastClient client;
    PayFastAdapter(PayFastClient client) { this.client = client; }

    @Override public String charge(long cents, String currency, String token) throws PaymentException {
        BigDecimal amount = BigDecimal.valueOf(cents, 2);   // 1999 cents → 19.99
        PayFastResult result = client.pay(amount, currency.toUpperCase(), token);
        if (result.statusCode() != 200) throw new PaymentException(result.statusCode());
        return result.transactionId();
    }
}
interface PaymentGateway {                               // what the app wants
  charge(cents: number, currency: string, token: string): Promise<string>;
}

class PayFastAdapter implements PaymentGateway {         // the only place that knows PayFast
  constructor(private readonly client: PayFastClient) {}

  async charge(cents: number, currency: string, token: string): Promise<string> {
    const amount = (cents / 100).toFixed(2);             // 1999 cents → "19.99"
    const result = await this.client.pay(amount, currency.toUpperCase(), token);
    if (result.statusCode !== 200) throw new PaymentError(result.statusCode);
    return result.transactionId;
  }
}

Everything PayFast-specific (decimal amounts, uppercase currency codes, status codes) now lives in one class. Checkout still calls charge(cents:currency:token:). Switching providers later means writing another adapter; tests use a fake PaymentGateway and never touch the SDK.

Spot the bugCustomers charged 100 times too much

Tap the line with the bug.

struct PayFastAdapter: PaymentGateway {
    let client: PayFastClient

    func charge(cents: Int, currency: String, token: String) async throws -> String {
        let result = try await client.pay(amount: Decimal(cents),
                                          currencyCode: currency.uppercased(),
                                          cardToken: token)
        return result.transactionId
    }
}
class PayFastAdapter(private val client: PayFastClient) : PaymentGateway {
    override suspend fun charge(cents: Long, currency: String, token: String): String {
        val result = client.pay(BigDecimal.valueOf(cents), currency.uppercase(), token)
        return result.transactionId
    }
}
final class PayFastAdapter implements PaymentGateway {
    private final PayFastClient client;
    PayFastAdapter(PayFastClient client) { this.client = client; }

    public String charge(long cents, String currency, String token) {
        PayFastResult result = client.pay(BigDecimal.valueOf(cents), currency.toUpperCase(), token);
        return result.transactionId();
    }
}
class PayFastAdapter implements PaymentGateway {
  constructor(private readonly client: PayFastClient) {}

  async charge(cents: number, currency: string, token: string): Promise<string> {
    const result = await this.client.pay(String(cents), currency.toUpperCase(), token);
    return result.transactionId;
  }
}

Predict the output

A thermometer library reports Fahrenheit; the app's adapter converts to Celsius. For 212 °F, what does it return?

func celsius(fromFahrenheit fahrenheit: Int) -> Int {
    (fahrenheit - 32) * (5 / 9)
}
print(celsius(fromFahrenheit: 212))
fun celsius(fahrenheit: Int): Int = (fahrenheit - 32) * (5 / 9)
println(celsius(212))
static int celsius(int fahrenheit) { return (fahrenheit - 32) * (5 / 9); }
System.out.println(celsius(212));
const celsius = (fahrenheit: number): number => (fahrenheit - 32) * (5 / 9);
console.log(Math.round(celsius(212)));

Object adapter vs class adapter

GoF describes two forms: the object adapter (wraps an adaptee instance, composition) and the class adapter (inherits from the adaptee and implements the target). Use the object adapter: it works with final classes, hides the adaptee’s API completely, and can adapt any subtype. Swift and Kotlin add a lightweight third option, retroactive conformance: extension PayFastClient: PaymentGateway adapts a type in place, which is fine when the mapping is trivial, and a leak when it isn’t (every PayFastClient everywhere now claims to be a gateway, and the vendor’s methods stay visible next to yours).

  • Object adapter (composition) implements your target interface and translates calls to the adaptee; prefer it over class adapters and broad retroactive conformances.
  • The value is semantic translation: units and scales (cents ↔ major units, per-currency exponents), casing, time zones, nullability, error models.
  • Test each adapter against real adaptee behavior (sandbox or recorded responses) with known values.
struct Money { let minorUnits: Int; let currency: String }   // never a bare Int
@JvmInline value class Cents(val value: Long)
record Money(long minorUnits, Currency currency) {}
type Money = { readonly minorUnits: number; readonly currency: string };

Check this part

  1. What does an adapter do?

  2. The app passes 1999 (cents) and the SDK expects euros. What must the adapter send?

  3. Why prefer an object adapter over extension VendorClient: PaymentGateway?

An adapter implements the interface your code expects by translating every call to the class you have: names, parameters, units, formats. The rest of the app never sees the other class. Unit and format conversions are where adapters break.

Part Not started

From callbacks to async

A common adapter turns a callback-based API into one you can await. Each language has a tool that bridges the two:

extension LegacyLocationProvider {
    func currentLocation() async throws -> Location {
        try await withCheckedThrowingContinuation { continuation in
            requestLocation { location, error in
                if let error {
                    continuation.resume(throwing: error)
                } else if let location {
                    continuation.resume(returning: location)
                } else {
                    continuation.resume(throwing: LocationError.unavailable)
                }
            }
        }
    }
}

let here = try await provider.currentLocation()
suspend fun LegacyLocationProvider.currentLocation(): Location =
    suspendCancellableCoroutine { cont ->
        val request = requestLocation(object : LocationCallback {
            override fun onLocation(location: Location) = cont.resume(location)
            override fun onError(error: Throwable) = cont.resumeWithException(error)
        })
        cont.invokeOnCancellation { request.cancel() }     // stop the work if the caller gives up
    }

val here = provider.currentLocation()
static CompletableFuture<Location> currentLocation(LegacyLocationProvider provider) {
    CompletableFuture<Location> future = new CompletableFuture<>();
    LocationRequest request = provider.requestLocation(new LocationCallback() {
        @Override public void onLocation(Location location) { future.complete(location); }
        @Override public void onError(Throwable error) { future.completeExceptionally(error); }
    });
    future.whenComplete((r, e) -> { if (future.isCancelled()) request.cancel(); });
    return future;
}

Location here = currentLocation(provider).get(10, TimeUnit.SECONDS);
function currentLocation(provider: LegacyLocationProvider, signal?: AbortSignal): Promise<Location> {
  return new Promise((resolve, reject) => {
    const request = provider.requestLocation((error, location) => {
      if (error) reject(error);
      else if (location) resolve(location);
      else reject(new Error("Location unavailable"));
    });
    signal?.addEventListener("abort", () => { request.cancel(); reject(signal.reason); });
  });
}

const here = await currentLocation(provider);

The pattern is the same in all four: create a “waiting box” (a continuation, a future, a promise), start the old callback-based call, and fill the box from inside the callback, either with the result or with the error. The code that awaits the box gets a normal async result. Write this once, in an adapter, and the rest of the app never sees a callback again.

Contract of every bridge: complete exactly once, on every path (success, error, “neither”, cancellation), and propagate cancellation back to the underlying work. Swift’s checked continuations log or trap on double resume and warn on leaks (never resumed); Kotlin throws IllegalStateException on a second resume and suspendCancellableCoroutine exposes cancellation; Java’s CompletableFuture ignores later completions silently; JavaScript promises ignore later resolve/reject silently. The silent ones hide bugs; the strict ones crash on them.

Exerciseeasy

Adapt an old weather client

The app expects WeatherProvider with currentWeather(city) async throws -> Weather, where Weather has celsius: Double and summary: String. The only library available is OldWeatherClient.fetch(city, completion), whose completion receives a WeatherDTO? (with tempF: Int and desc: String) and an errorCode: Int (0 means success). Write the adapter.

›

Three translations: callback → async, errorCode → a thrown error, Fahrenheit (an Int) → Celsius (a Double; watch the integer division).

SolutionTry it yourself first
struct OldWeatherAdapter: WeatherProvider {
    let client: OldWeatherClient

    func currentWeather(city: String) async throws -> Weather {
        try await withCheckedThrowingContinuation { continuation in
            client.fetch(city) { dto, errorCode in
                if errorCode != 0 {
                    continuation.resume(throwing: WeatherError.service(code: errorCode))
                } else if let dto {
                    continuation.resume(returning: Weather(celsius: Double(dto.tempF - 32) * 5 / 9,
                                                           summary: dto.desc))
                } else {
                    continuation.resume(throwing: WeatherError.noData)
                }
            }
        }
    }
}
class OldWeatherAdapter(private val client: OldWeatherClient) : WeatherProvider {
    override suspend fun currentWeather(city: String): Weather =
        suspendCancellableCoroutine { cont ->
            client.fetch(city) { dto, errorCode ->
                when {
                    errorCode != 0 -> cont.resumeWithException(WeatherException("code $errorCode"))
                    dto != null -> cont.resume(Weather((dto.tempF - 32) * 5.0 / 9.0, dto.desc))
                    else -> cont.resumeWithException(WeatherException("no data"))
                }
            }
        }
}
final class OldWeatherAdapter implements WeatherProvider {
    private final OldWeatherClient client;
    OldWeatherAdapter(OldWeatherClient client) { this.client = client; }

    @Override public CompletableFuture<Weather> currentWeather(String city) {
        CompletableFuture<Weather> future = new CompletableFuture<>();
        client.fetch(city, (dto, errorCode) -> {
            if (errorCode != 0) future.completeExceptionally(new WeatherException("code " + errorCode));
            else if (dto != null) future.complete(new Weather((dto.tempF() - 32) * 5.0 / 9.0, dto.desc()));
            else future.completeExceptionally(new WeatherException("no data"));
        });
        return future;
    }
}
class OldWeatherAdapter implements WeatherProvider {
  constructor(private readonly client: OldWeatherClient) {}

  currentWeather(city: string): Promise<Weather> {
    return new Promise((resolve, reject) => {
      this.client.fetch(city, (dto, errorCode) => {
        if (errorCode !== 0) reject(new Error(`Weather service error ${errorCode}`));
        else if (dto) resolve({ celsius: ((dto.tempF - 32) * 5) / 9, summary: dto.desc });
        else reject(new Error("No weather data"));
      });
    });
  }
}

Every path ends in exactly one resume/complete/resolve/reject, including the odd case of “success but no data”. The rest of the app sees only WeatherProvider, in Celsius, with real errors; when a better weather API comes along, it’s one new adapter.

Production puzzles

Spot the bugA location adapter that crashes or lies

Tap the line with the bug.

func currentLocation() async throws -> Location {
    try await withCheckedThrowingContinuation { continuation in
        sdk.startUpdates { update in
            continuation.resume(returning: update.location)
        }
    }
}
suspend fun currentLocation(): Location = suspendCancellableCoroutine { cont ->
    sdk.startUpdates { update ->
        cont.resume(update.location)
    }
}
CompletableFuture<Location> currentLocation() {
    CompletableFuture<Location> future = new CompletableFuture<>();
    sdk.startUpdates(update -> future.complete(update.location()));
    return future;
}
function currentLocation(): Promise<Location> {
  return new Promise((resolve) => {
    sdk.startUpdates((update) => resolve(update.location));
  });
}

  • Bridges: Swift withChecked(Throwing)Continuation (traps on double resume, warns on leaks), Kotlin suspendCancellableCoroutine (throws on double resume; invokeOnCancellation), Java CompletableFuture and JS Promise (silently ignore extra completions).
  • Complete exactly once on every path; map “no result, no error”; propagate cancellation to the source.
  • One-shot over a stream: pick the completing event (freshness), guard, stop the source. Streams adapt to AsyncStream/callbackFlow/reactive types instead.

Check this part

  1. How do you turn a callback-based API into something you can await?

  2. The callback can report success, an error, or neither. What must the adapter do?

  3. A callback fires twice and both times calls continuation.resume. What happens?A callback fires twice and both times calls cont.resume. What happens?A callback fires twice and both times calls future.complete. What happens?A callback fires twice and both times calls resolve. What happens?

Old APIs report results through callbacks; modern code wants async/await. Adapt them once with continuations (Swift), suspendCancellableCoroutine (Kotlin), CompletableFuture (Java) or a Promise (TypeScript), resuming exactly once and mapping errors and cancellation.

Part Not started

Adapters at the boundaries

An embassy translates between two countries: documents from outside are checked and translated into the local language before anyone inside uses them. The rest of the country never has to learn the foreign forms.

Put adapters at the edges of your app the same way: where your code meets a vendor SDK, a partner’s API or an old system. Outside data is translated into your own types right there, and nothing inside depends on the outside format.

Domain-driven design calls a set of adapters protecting a model from an external one an anti-corruption layer; hexagonal architecture calls the interfaces “ports” and the adapters “adapters”. The discipline is the same: outside DTOs, enums, error codes and identifiers stop at the adapter; inside, everything uses your own types with your own invariants. When the vendor changes a field, adds a status or gets replaced, the blast radius is one class plus its tests.

PatternWraps something to…Interface it exposes
AdapterMake it fit an interface you needA different interface (yours)
DecoratorAdd behavior (logging, retries, caching)The same interface
ProxyControl access (lazy loading, permissions, remote calls)The same interface
FacadeSimplify a whole subsystemA new, simpler interface over many classes

Practice: where adapters already are

  1. Android’s RecyclerView.Adapter adapts your list of data to the interface the list view needs (getItemCount, onBindViewHolder).
  2. InputStreamReader in Java adapts a byte stream to a character Reader.
  3. Retrofit converters and Swift Decodable models adapt JSON into your types (that’s where to keep server-specific field names).
  4. Kotlin’s asFlow(), Swift’s AsyncStream and RxJS from() adapt one asynchronous style into another.

Recognizing them helps: each one is a place where two worlds meet, and where translation bugs (wrong units, wrong time zones, unknown values) are most likely.

Exercisemedium

An anti-corruption layer for a CRM

Your app integrates a third-party CRM. Its API returns contacts with fields like ContactStatus__c (values "A", "I", "P", sometimes lowercase, and new ones appear without notice), dates as MM/dd/yyyy strings in the vendor’s US time zone, IDs that change when records are merged, and errors as HTTP 200 with { "success": false, "errorCode": "…" }. Three features need contacts today. Design the boundary.

›

Decide what your app’s Customer looks like first, independently of the CRM. Then make one component responsible for every translation, and decide what happens with values it doesn’t recognize.

SolutionTry it yourself first
  • Your model first: Customer(id: CustomerId, status: CustomerStatus, since: Instant, …) with CustomerStatus { active, inactive, pending, unknown(raw) }. Features depend only on this and on a CustomerDirectory port (find(id), search(query)).
  • One adapter (CrmCustomerDirectory) owns: the HTTP client and DTOs, status mapping (case-insensitive, unknown values to unknown(raw) plus a metric, never a crash), date parsing with the vendor’s zone into Instant, and error translation (HTTP 200 with success: false becomes a typed CustomerDirectoryError, rate limits become retryable errors).
  • Stable identity: store your own CustomerId and map to CRM ids in your database; handle merge events by updating the mapping, so your foreign keys never point at a CRM id that disappeared.
  • Contract tests: record real CRM responses (including the weird ones) and test the adapter against them; run them against the CRM sandbox nightly so vendor changes are caught before production.
  • Fallbacks: the adapter decides behavior for outages (cached last-known data with a staleness flag), so features don’t each invent one.
  • Adapters at the boundary form an anti-corruption layer (ports and adapters): outside DTOs, codes, enums, ids and time formats stop there.
  • Map unknown external values to an explicit unknown(raw) with metrics, never crash or default silently; translate error models (including “200 with success: false”).
  • Adapter changes the interface; Decorator and Proxy keep it; Facade simplifies a subsystem.
  • Contract tests on recorded vendor responses, plus sandbox runs.

Check this part

  1. Where should adapters usually live?

  2. What's the difference between an Adapter and a Decorator?

  3. A vendor adds a new status value without notice. What should the adapter do?

Adapters belong at the edges of your system: they translate outside models, codes and errors into your own types, so a vendor change touches one class. Adapter, Facade, Decorator and Proxy all wrap something; the difference is what they change about its interface.

Key takeaways

  • An adapter makes a class with a different interface usable through the interface your code expects.
  • Put every vendor-specific detail (units, names, codes) in the adapter; the rest of the app never sees it.
  • Unit and format conversions are where adapters break: test them with known values.
  • Adapting callbacks to async means completing a continuation, future or promise exactly once, on every path.
  • Prefer object adapters; translate semantics (units, zones, errors, unknown values), not just names.
  • Callback bridges must complete exactly once, propagate cancellation and stop streaming sources.
  • Adapters at the edges form an anti-corruption layer; your own types and ids inside.
  • Contract-test adapters against real vendor behavior.

Finished this page?Nice work, this page is complete!

Mark it as done to track your progress. It's saved in this browser, no account needed.

Its cards are now in your Bites review ⚡
Esc

Type to search the whole site.