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.
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;
}
}Hint: The app passes 1999 for a 19.99 EUR order. What amount does PayFast receive, given that its SDK expects major units?
The adapter passes cents where the SDK expects euros: a 19.99 EUR order is charged 1,999.00 EUR. The parameter names look fine on both sides (cents in, amount out), which is exactly why unit bugs survive code review. It’s the adapter’s whole job to translate them:
let amount = Decimal(cents) / 100val amount = BigDecimal.valueOf(cents, 2) // unscaled value 1999, scale 2 → 19.99BigDecimal amount = BigDecimal.valueOf(cents, 2);const amount = (cents / 100).toFixed(2);Better still, make units part of the type (Money(cents:currency:)), and give every adapter a test with a known amount (“1999 cents is sent as 19.99”). Also check the currency: some currencies have no minor unit (JPY) or three decimals (KWD), so “divide by 100” is itself a rule worth one test per currency you support.
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)));5 / 9 divides two Ints: 0. So (212 − 32) × 0 = 0. Write Double(fahrenheit - 32) * 5 / 9, or * 5.0 / 9.0, so the division is a decimal one.
5 / 9 divides two Ints: 0, and everything times 0 is 0. Use (fahrenheit - 32) * 5.0 / 9.0.
5 / 9 is integer division: 0, so the result is 0 for every temperature. Use (fahrenheit - 32) * 5.0 / 9.0.
TypeScript has one number type, so 5 / 9 is 0.555…, and the result is 100. The same formula gives 0 in Swift, Kotlin and Java, which matters when an adapter is ported between platforms.
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
What does an adapter do?
The app passes 1999 (cents) and the SDK expects euros. What must the adapter send?
Why prefer an object adapter over
extension VendorClient: PaymentGateway?