Skip to content

Low Level DesignDesign patterns

Adapter and facade patterns

The adapter and facade patterns through three real examples, from a payment SDK to a checkout that undoes failed steps to a legacy carrier behind an anti-corruption layer, with diagrams, live demos and code in five languages.

Mid-level25 min read

Your code is written against interfaces you chose. But the things it has to use were written by someone else. Think of a vendor SDK that wants dollars as text. Or four internal services that must be called in the right order. Or a carrier API from 2009. Two patterns put a front on those things. Then the rest of your code doesn’t have to know what’s behind it.

An adapter changes one object’s interface into the one your code expects. It does the same job as the object it wraps. Only the shape changes. A facade puts one simple front on a whole subsystem. It wraps many objects, and exposes less than they can do, because most callers only need one or two things done.

This page builds in three steps. First a payment adapter. Then a checkout facade that cleans up after a failure. Then an adapter at a system boundary that translates a whole model.

The idea in one picture
Adapter Checkout expects PaymentGateway charge(1250, "USD") PayWaveAdapter cents → "12.50", USD → usd createPayment("12.50", "usd") PayWave SDK their shape, can't change it one object, same job, new interface Facade Checkout wants one call placeOrder(cart) CheckoutFacade knows the order and the undo Inventory Payments Shipping Email many objects, one simple front
Scroll or pinch to zoom, drag to pan
An adapter wraps one object and changes its interface to the one the caller expects. A facade wraps many objects and gives callers one simple entry point.

The two patterns have different parts.

Adapter:

  • The target is the interface your code expects, for example PaymentGateway.
  • The adaptee is the existing class with the wrong interface, for example a vendor’s PayWaveClient. You can’t or shouldn’t change it.
  • The adapter implements the target and holds the adaptee. Each method translates and delegates.

Facade:

  • The subsystem is several classes that work together, each with its own API.
  • The facade offers a few high-level methods and calls the subsystem classes to carry them out. The subsystem classes don’t know the facade exists.
The general shape: adapter (left) and facade (right)

uses

translates to

one call

Client

«interface»

Target

+request()

Adapter

-adaptee: Adaptee

+request()

Adaptee

+specificRequest()

Caller

Facade

+doTheUsualThing()

SubsystemA

SubsystemB

SubsystemC

uses

translates to

one call

Client

«interface»

Target

+request()

Adapter

-adaptee: Adaptee

+request()

Adaptee

+specificRequest()

Caller

Facade

+doTheUsualThing()

SubsystemA

SubsystemB

SubsystemC

Scroll or pinch to zoom, drag to pan
  • Color key
  • Facade
  • Entity
  • Value object
  • Enum
  • Interface
  • Implementation

The adapter has an arrow up to an interface it doesn’t own and one arrow down to a single object. The facade has no interface above it, and several arrows down. That is most of the difference:

Question Adapter Facade
How many objects does it wrap? One Several, usually a whole subsystem
Whose interface does it present? One the caller already defined A new one, designed for convenience
Capability The same as the wrapped object Less: the common paths only
Why it exists The object doesn’t fit where it’s needed The subsystem is too much work to use directly
Can callers still reach what’s behind it? Usually not, by design Usually yes; the facade is optional
Easy

A payment SDK

  • The basic adapter
  • Translating units, names and errors
  • Object adapter or class adapter

An online shop’s checkout charges cards through a PaymentGateway interface. It takes an amount in integer cents and an ISO currency code. It returns a ChargeResult, or throws PaymentDeclined.

Finance signs a contract with a new vendor, PayWave. Its SDK is different in every way. It takes the amount as a decimal string in dollars (“12.50”). It wants the currency in lowercase. It returns a receipt with its own field names. And it throws exceptions with codes like PW_INSUFFICIENT.

Call the SDK from wherever a payment happens:

Checkout.javasubscriptions.pyCheckout.cssubscriptions.gosubscriptions.ts
// The vendor SDK, as before: dollars as a decimal string, lowercase currency, PW_ error codes.
class PayWaveReceipt {
final String paymentRef, amountCaptured;
PayWaveReceipt(String paymentRef, String amountCaptured) {
this.paymentRef = paymentRef;
this.amountCaptured = amountCaptured;
}
}
class PayWaveException extends RuntimeException {
final String errorCode;
PayWaveException(String errorCode) {
super(errorCode);
this.errorCode = errorCode;
}
}
class PayWaveClient {
private int next = 1001;
PayWaveReceipt createPayment(String amount, String currencyCode) {
System.out.println("PayWave <- createPayment(\"" + amount + "\", \"" + currencyCode + "\")");
if (Double.parseDouble(amount) > 500) throw new PayWaveException("PW_INSUFFICIENT");
return new PayWaveReceipt("pw_" + next++, amount);
}
}
// Each caller talks to the SDK directly, so each one repeats the conversions and knows the
// vendor's error codes. Switching vendors means editing all of them.
class Checkout {
private final PayWaveClient payWave;
Checkout(PayWaveClient payWave) {
this.payWave = payWave;
}
void pay(String orderId, long cents) {
String amount = (cents / 100) + "." + String.format("%02d", cents % 100);
try {
PayWaveReceipt r = payWave.createPayment(amount, "usd");
System.out.println(orderId + ": paid " + r.amountCaptured + " as " + r.paymentRef);
} catch (PayWaveException e) {
System.out.println(orderId + ": declined (" + e.errorCode + ")");
}
}
}
// A second caller, written later by someone else. It passes cents where the SDK wants
// dollars. Both are strings, so nothing catches it until a customer is charged.
class Subscriptions {
private final PayWaveClient payWave;
Subscriptions(PayWaveClient payWave) {
this.payWave = payWave;
}
void renew(String subId, long cents) {
try {
PayWaveReceipt r = payWave.createPayment(String.valueOf(cents), "usd");
System.out.println(subId + ": renewed as " + r.paymentRef);
} catch (PayWaveException e) {
System.out.println(subId + ": renewal failed (" + e.errorCode + ")");
}
}
}
class Main {
public static void main(String[] args) {
PayWaveClient payWave = new PayWaveClient();
new Checkout(payWave).pay("A-1", 1250);
// PayWave <- createPayment("12.50", "usd")
// A-1: paid 12.50 as pw_1001
// A $19.99 plan, sent as $1999.00.
new Subscriptions(payWave).renew("S-9", 1999);
// PayWave <- createPayment("1999", "usd")
// S-9: renewal failed (PW_INSUFFICIENT)
}
}

Every caller converts cents to dollars and knows the vendor’s error codes. The second caller passed cents where the SDK wanted dollars. Both are strings, so nothing catches it: a $19.99 renewal is sent as $1999.00. Switching vendors later means finding every one of these calls, and tests can’t run without the vendor’s SDK.

Keep checkout on PaymentGateway. Write one class that implements it on top of the SDK:

Easy: one adapter between checkout and the SDK

uses

translates to

returns

throws

Checkout

+pay(orderId, cents, currency)

«interface»

PaymentGateway

+charge(amountCents, currency) : ChargeResult

PayWaveAdapter

-client: PayWaveClient

-REASONS: Map

+charge(amountCents, currency) : ChargeResult

PayWaveClient

+createPayment(amount, currencyCode) : PayWaveReceipt

ChargeResult

+chargeId

+amountCents

+currency

PaymentDeclined

+reason

uses

translates to

returns

throws

Checkout

+pay(orderId, cents, currency)

«interface»

PaymentGateway

+charge(amountCents, currency) : ChargeResult

PayWaveAdapter

-client: PayWaveClient

-REASONS: Map

+charge(amountCents, currency) : ChargeResult

PayWaveClient

+createPayment(amount, currencyCode) : PayWaveReceipt

ChargeResult

+chargeId

+amountCents

+currency

PaymentDeclined

+reason

Scroll or pinch to zoom, drag to pan
  • Color key
  • Facade
  • Entity
  • Value object
  • Enum
  • Interface
  • Implementation

Pick an amount, a currency and what PayWave answers, and follow each field across the adapter. The fake vendor declines anything over $500, as in the code:

amountCents
currency
PayWave answers with
  1. Checkout calls
  2. Adapter, on the way in
  3. PayWave receives
  4. PayWave answers
  5. Adapter, on the way out
  6. Checkout gets

The adapter's error table

PayWave error_codePaymentDeclined reason
PW_INSUFFICIENTinsufficient_funds
PW_DO_NOT_HONORcard_declined
PW_EXPIREDexpired_card
anything elseprocessing_error
PayWaveAdapter.javapay_wave_adapter.pyPayWaveAdapter.cspay_wave_adapter.gopay-wave-client.ts
import java.util.Map;
// ---- Our side: the interface checkout was written against. ----
// Money is integer cents (never floating point) and currency is an ISO code like "USD".
interface PaymentGateway {
ChargeResult charge(long amountCents, String currency) throws PaymentDeclined;
}
record ChargeResult(String chargeId, long amountCents, String currency) {}
// Our own error, in our own words. Checkout catches this, never a vendor exception.
class PaymentDeclined extends Exception {
final String reason;
PaymentDeclined(String reason) {
super("declined: " + reason);
this.reason = reason;
}
}
// ---- Their side: a vendor SDK we can't change. ----
// Dollars as a decimal string, lowercase currency, and its own result and error shapes.
class PayWaveReceipt {
final String paymentRef, status, amountCaptured;
PayWaveReceipt(String paymentRef, String status, String amountCaptured) {
this.paymentRef = paymentRef;
this.status = status;
this.amountCaptured = amountCaptured;
}
}
class PayWaveException extends RuntimeException {
final String errorCode;
PayWaveException(String errorCode) {
super(errorCode);
this.errorCode = errorCode;
}
}
// A fake of the vendor's client: it prints what it received and declines anything over $500.
class PayWaveClient {
private int next = 1001;
PayWaveReceipt createPayment(String amount, String currencyCode) {
System.out.println("PayWave <- createPayment(\"" + amount + "\", \"" + currencyCode + "\")");
if (Double.parseDouble(amount) > 500) throw new PayWaveException("PW_INSUFFICIENT");
return new PayWaveReceipt("pw_" + next++, "succeeded", amount);
}
}
// The adapter IS our interface and HAS their client (an object adapter). Every translation
// lives here: units, names, result shape and errors. Nothing else imports PayWave.
class PayWaveAdapter implements PaymentGateway {
// Vendor error codes in our vocabulary. An unknown code gets a safe generic reason
// instead of leaking the vendor's string into our logs and UI.
private static final Map<String, String> REASONS = Map.of(
"PW_INSUFFICIENT", "insufficient_funds",
"PW_DO_NOT_HONOR", "card_declined",
"PW_EXPIRED", "expired_card");
private final PayWaveClient client;
PayWaveAdapter(PayWaveClient client) {
this.client = client;
}
public ChargeResult charge(long amountCents, String currency) throws PaymentDeclined {
PayWaveReceipt receipt;
try {
receipt = client.createPayment(toDecimal(amountCents), currency.toLowerCase());
} catch (PayWaveException e) {
throw new PaymentDeclined(REASONS.getOrDefault(e.errorCode, "processing_error"));
}
// Their result shape becomes ours: a status string to check, a text amount to parse.
if (!receipt.status.equals("succeeded")) throw new PaymentDeclined("processing_error");
return new ChargeResult(receipt.paymentRef, toCents(receipt.amountCaptured), currency);
}
// Integer arithmetic only: 1250 -> "12.50", 5 -> "0.05". Assumes two decimal places,
// which holds for USD and EUR but not for JPY (none) or KWD (three).
static String toDecimal(long cents) {
return (cents / 100) + "." + String.format("%02d", cents % 100);
}
// "12.50" -> 1250, parsed as text for the same reason.
static long toCents(String decimal) {
String[] parts = decimal.split("\\.");
return Long.parseLong(parts[0]) * 100 + Long.parseLong(parts[1]);
}
}
// Business code. It knows PaymentGateway and nothing about any vendor.
class Checkout {
private final PaymentGateway payments;
Checkout(PaymentGateway payments) {
this.payments = payments;
}
void pay(String orderId, long amountCents, String currency) {
try {
ChargeResult r = payments.charge(amountCents, currency);
System.out.println(orderId + ": paid " + r.amountCents() + " " + r.currency() + " as " + r.chargeId());
} catch (PaymentDeclined e) {
System.out.println(orderId + ": declined (" + e.reason + ")");
}
}
}
class Main {
public static void main(String[] args) {
// The only line that names the vendor. Switching vendors changes this line and adds
// one adapter; Checkout is untouched.
Checkout checkout = new Checkout(new PayWaveAdapter(new PayWaveClient()));
checkout.pay("A-1", 1250, "USD");
// PayWave <- createPayment("12.50", "usd")
// A-1: paid 1250 USD as pw_1001
checkout.pay("A-2", 5, "EUR");
// PayWave <- createPayment("0.05", "eur")
// A-2: paid 5 EUR as pw_1002
checkout.pay("A-3", 60000, "USD");
// PayWave <- createPayment("600.00", "usd")
// A-3: declined (insufficient_funds)
}
}
  • Your interface comes first. PaymentGateway was written for checkout, in checkout’s terms. The adapter bends to it, not the other way round. This is the dependency inversion principle: business code owns the interface, and the vendor’s code is plugged in from outside.
  • Translate everything, not just the method name. That means units (cents to “12.50”), formats (“USD” to “usd”), result shape (payment_ref to chargeId, a text amount back to cents), and errors (PayWaveException to PaymentDeclined). If any vendor type escapes the adapter, callers start depending on it again.
  • Money stays in integers. The conversion is integer division and a zero-padded remainder, never cents / 100.0. It assumes two decimal places, which holds for USD and EUR. JPY has none and KWD has three, so a real adapter looks up each currency’s minor unit.
  • Unknown errors get a safe default. A code the table doesn’t know becomes processing_error. So a new vendor code never shows up raw in a log line or a customer message. Logging the raw code inside the adapter is still worth doing.
  • One line names the vendor. The composition root builds new Checkout(new PayWaveAdapter(new PayWaveClient())). A second vendor is a second adapter and a change to that line. A test passes a fake PaymentGateway and never loads the SDK.

The adapter above holds the vendor client: an object adapter. The Gang of Four also describe a class adapter, which inherits from the adaptee and implements the target:

Main.javapay_wave_class_adapter.pyProgram.cspay_wave_class_adapter.gopay-wave-class-adapter.ts
interface PaymentGateway {
ChargeResult charge(long amountCents, String currency);
}
record ChargeResult(String chargeId, long amountCents, String currency) {}
// The vendor's client, trimmed to what this example needs. It must not be final, or a
// class adapter can't extend it.
class PayWaveClient {
private int next = 1001;
public String createPayment(String amount, String currencyCode) {
System.out.println("PayWave <- createPayment(\"" + amount + "\", \"" + currencyCode + "\")");
return "pw_" + next++;
}
}
// Class adapter: it IS a PayWaveClient (by inheritance) and IS a PaymentGateway. There is no
// field to hold and no object to pass in, so there's one object instead of two.
class PayWaveClassAdapter extends PayWaveClient implements PaymentGateway {
public ChargeResult charge(long amountCents, String currency) {
String amount = (amountCents / 100) + "." + String.format("%02d", amountCents % 100);
// Calls the inherited method directly. It could also override vendor methods, which
// an object adapter can't.
String ref = createPayment(amount, currency.toLowerCase());
return new ChargeResult(ref, amountCents, currency);
}
}
class Main {
public static void main(String[] args) {
PaymentGateway gateway = new PayWaveClassAdapter();
ChargeResult r = gateway.charge(1250, "USD");
// PayWave <- createPayment("12.50", "usd")
System.out.println("paid " + r.amountCents() + " " + r.currency() + " as " + r.chargeId());
// paid 1250 USD as pw_1001
// The catch: the vendor's methods are public on the adapter too. Code that holds the
// concrete type can skip every translation, and nothing stops it.
PayWaveClassAdapter concrete = new PayWaveClassAdapter();
String ref = concrete.createPayment("1250", "USD");
// PayWave <- createPayment("1250", "USD")
System.out.println("bypassed the adapter: " + ref);
// bypassed the adapter: pw_1001
}
}
Interviewer

We’re adding PayWave next to our current card processor. Its SDK takes dollars as strings and has its own error codes. How do you plug it in?

What they are testing

Whether you keep checkout on its own PaymentGateway interface, and write one adapter that converts units, names, results and errors, with vendor types never leaving it. The red flag is calling the SDK from checkout. Or a PaymentGateway whose methods mirror the SDK’s (dollars as strings, vendor error codes).

Say this out loud

Checkout already has the interface it wants, PaymentGateway, in cents with our own errors. I’ll write a PayWaveAdapter that implements it, and holds the SDK client. It converts cents to the vendor’s dollar string, and maps the receipt to ChargeResult. It maps vendor error codes to our decline reasons, with a safe default for codes it doesn’t know.

Medium

Placing an order

  • A facade over a subsystem
  • Hiding the order of calls
  • Undoing completed steps on failure
  • God object or service layer

Placing an order touches four services. Inventory reserves the items, payments charges the card, shipping buys a label, and email sends a confirmation. Each has its own API, and the order matters: don’t charge before stock is reserved, don’t buy a label before the charge succeeds. When a step fails, the steps before it must be undone. The web checkout, the mobile API and an admin “re-place order” button all place orders.

Each caller runs the sequence itself:

WebOrderController.javaweb_order_controller.pyWebOrderController.csweb_order_controller.goweb-order-controller.ts
class StepFailed extends RuntimeException {
StepFailed(String message) {
super(message);
}
}
// The same subsystems as before, trimmed. Shipping is set to fail.
class Inventory {
String reserve(String sku, int qty) {
System.out.println("inventory: reserved res-1 (" + sku + " x" + qty + ")");
return "res-1";
}
void release(String id) {
System.out.println("inventory: released " + id);
}
}
class Payments {
String charge(long cents) {
System.out.println("payments: charged ch-1 " + cents + " cents");
return "ch-1";
}
void refund(String id) {
System.out.println("payments: refunded " + id);
}
}
class Shipping {
String createLabel(String orderId) {
System.out.println("shipping: carrier unavailable");
throw new StepFailed("carrier unavailable");
}
}
// The web controller runs the whole sequence itself. So does the mobile API, and so does the
// admin tool's "re-place order" button: three copies of the order and of the undo rules.
class WebOrderController {
private final Inventory inventory = new Inventory();
private final Payments payments = new Payments();
private final Shipping shipping = new Shipping();
String placeOrder(String orderId) {
String reservation = inventory.reserve("SKU-42", 2);
String charge;
try {
charge = payments.charge(4998);
} catch (StepFailed e) {
inventory.release(reservation);
return orderId + ": failed, " + e.getMessage();
}
try {
shipping.createLabel(orderId);
} catch (StepFailed e) {
// Written when there was no shipping step; the refund was never added here.
inventory.release(reservation);
return orderId + ": failed, " + e.getMessage();
}
return orderId + ": placed";
}
}
class Main {
public static void main(String[] args) {
System.out.println(new WebOrderController().placeOrder("A-3"));
// inventory: reserved res-1 (SKU-42 x2)
// payments: charged ch-1 4998 cents
// shipping: carrier unavailable
// inventory: released res-1
// A-3: failed, carrier unavailable
// (ch-1 is never refunded: the customer paid for an order that won't ship.)
}
}

The web controller releases the stock when shipping fails, but never refunds the charge. That’s because the shipping step was added after the undo code was written. The mobile API and the admin tool have their own copies of the same sequence, each with its own gaps. Nothing is wrong with any subsystem. The problem is that the knowledge of how to use them together is spread across three places.

Put that knowledge in one class. CheckoutFacade.placeOrder(cart) calls the subsystems in order, records how to undo each step that succeeded, and undoes them if a later step fails:

Medium: one front for four subsystems

1

2

3

4

takes

returns

WebController

MobileApi

CheckoutFacade

+placeOrder(cart) : OrderResult

Inventory

+reserve(sku, qty) : reservationId

+release(reservationId)

Payments

+charge(cents) : chargeId

+refund(chargeId)

Shipping

+createLabel(orderId, address) : tracking

Email

+sendConfirmation(to, orderId, tracking)

Cart

+orderId

+sku

+qty

+totalCents

OrderResult

+placed

+detail

1

2

3

4

takes

returns

WebController

MobileApi

CheckoutFacade

+placeOrder(cart) : OrderResult

Inventory

+reserve(sku, qty) : reservationId

+release(reservationId)

Payments

+charge(cents) : chargeId

+refund(chargeId)

Shipping

+createLabel(orderId, address) : tracking

Email

+sendConfirmation(to, orderId, tracking)

Cart

+orderId

+sku

+qty

+totalCents

OrderResult

+placed

+detail

Scroll or pinch to zoom, drag to pan
  • Color key
  • Facade
  • Entity
  • Value object
  • Enum
  • Interface
  • Implementation

Choose a subsystem to fail and place an order. Ids keep counting across orders, as they do in the code:

Make one fail:
web, mobile, admincheckout.placeOrder(cart)
  1. Inventoryreserve(SKU-42, 2)waitingfails with: out of stock
  2. Paymentscharge(4998)waitingfails with: card declined
  3. ShippingcreateLabel(...)waitingfails with: carrier unavailable
  4. EmailsendConfirmation(...)waitingfails with: smtp timeout

What happened, in order

    Pick a subsystem to fail (or none), then place an order.

    Main.javacheckout_facade.pyCheckoutFacade.cscheckout_facade.gocheckout-facade.ts
    import java.util.ArrayDeque;
    import java.util.ArrayList;
    import java.util.Deque;
    import java.util.List;
    // One failure type for every subsystem keeps the facade's error handling in one place.
    class StepFailed extends RuntimeException {
    StepFailed(String message) {
    super(message);
    }
    }
    // ---- Four subsystems, each with its own API. ----
    // Each fake prints what it did. failNext makes its next call fail, so the demo can break any step.
    class Inventory {
    String failNext;
    private int next = 1;
    String reserve(String sku, int qty) {
    if (failNext != null) {
    String why = failNext;
    failNext = null;
    System.out.println("inventory: " + why);
    throw new StepFailed(why);
    }
    String id = "res-" + next++;
    System.out.println("inventory: reserved " + id + " (" + sku + " x" + qty + ")");
    return id;
    }
    void release(String reservationId) {
    System.out.println("inventory: released " + reservationId);
    }
    }
    class Payments {
    String failNext;
    private int next = 1;
    String charge(long cents) {
    if (failNext != null) {
    String why = failNext;
    failNext = null;
    System.out.println("payments: " + why);
    throw new StepFailed(why);
    }
    String id = "ch-" + next++;
    System.out.println("payments: charged " + id + " " + cents + " cents");
    return id;
    }
    void refund(String chargeId) {
    System.out.println("payments: refunded " + chargeId);
    }
    }
    class Shipping {
    String failNext;
    private int next = 1;
    String createLabel(String orderId, String address) {
    if (failNext != null) {
    String why = failNext;
    failNext = null;
    System.out.println("shipping: " + why);
    throw new StepFailed(why);
    }
    String tracking = "TRK-" + next++;
    System.out.println("shipping: label " + tracking + " for " + orderId);
    return tracking;
    }
    }
    class Email {
    String failNext;
    void sendConfirmation(String to, String orderId, String tracking) {
    if (failNext != null) {
    String why = failNext;
    failNext = null;
    System.out.println("email: " + why);
    throw new StepFailed(why);
    }
    System.out.println("email: confirmation for " + orderId + " sent to " + to);
    }
    }
    record Cart(String orderId, String sku, int qty, long totalCents, String email, String address) {}
    record OrderResult(String orderId, boolean placed, String detail) {}
    // The facade: one simple method in front of four subsystems. Callers (web, mobile, admin)
    // no longer know the order of the calls, or what to undo when one fails.
    class CheckoutFacade {
    private final Inventory inventory;
    private final Payments payments;
    private final Shipping shipping;
    private final Email email;
    final List<String> emailRetries = new ArrayList<>();
    CheckoutFacade(Inventory inventory, Payments payments, Shipping shipping, Email email) {
    this.inventory = inventory;
    this.payments = payments;
    this.shipping = shipping;
    this.email = email;
    }
    OrderResult placeOrder(Cart cart) {
    // Each step that succeeds pushes its own undo. On failure they run newest
    // first, the reverse of the order they ran, so nothing is undone while a later
    // step still depends on it.
    Deque<Runnable> undo = new ArrayDeque<>();
    try {
    String reservation = inventory.reserve(cart.sku(), cart.qty());
    undo.push(() -> inventory.release(reservation));
    String charge = payments.charge(cart.totalCents());
    undo.push(() -> payments.refund(charge));
    String tracking = shipping.createLabel(cart.orderId(), cart.address());
    // The order is placed once it has a label. A failed email doesn't undo a paid,
    // shippable order: it goes on a retry queue instead.
    try {
    email.sendConfirmation(cart.email(), cart.orderId(), tracking);
    } catch (StepFailed e) {
    emailRetries.add(cart.orderId());
    System.out.println("checkout: confirmation for " + cart.orderId() + " queued for retry");
    }
    return new OrderResult(cart.orderId(), true, "tracking " + tracking);
    } catch (StepFailed e) {
    while (!undo.isEmpty()) undo.pop().run();
    return new OrderResult(cart.orderId(), false, e.getMessage());
    }
    }
    }
    class Main {
    static void show(OrderResult r) {
    System.out.println(r.orderId() + ": " + (r.placed() ? "placed, " : "failed, ") + r.detail());
    }
    static Cart cart(String orderId) {
    return new Cart(orderId, "SKU-42", 2, 4998, "ana@example.com", "1 Main St");
    }
    public static void main(String[] args) {
    Inventory inventory = new Inventory();
    Payments payments = new Payments();
    Shipping shipping = new Shipping();
    Email email = new Email();
    CheckoutFacade checkout = new CheckoutFacade(inventory, payments, shipping, email);
    show(checkout.placeOrder(cart("A-1")));
    // inventory: reserved res-1 (SKU-42 x2)
    // payments: charged ch-1 4998 cents
    // shipping: label TRK-1 for A-1
    // email: confirmation for A-1 sent to ana@example.com
    // A-1: placed, tracking TRK-1
    payments.failNext = "card declined";
    show(checkout.placeOrder(cart("A-2")));
    // inventory: reserved res-2 (SKU-42 x2)
    // payments: card declined
    // inventory: released res-2
    // A-2: failed, card declined
    shipping.failNext = "carrier unavailable";
    show(checkout.placeOrder(cart("A-3")));
    // inventory: reserved res-3 (SKU-42 x2)
    // payments: charged ch-2 4998 cents
    // shipping: carrier unavailable
    // payments: refunded ch-2
    // inventory: released res-3
    // A-3: failed, carrier unavailable
    email.failNext = "smtp timeout";
    show(checkout.placeOrder(cart("A-4")));
    // inventory: reserved res-4 (SKU-42 x2)
    // payments: charged ch-3 4998 cents
    // shipping: label TRK-2 for A-4
    // email: smtp timeout
    // checkout: confirmation for A-4 queued for retry
    // A-4: placed, tracking TRK-2
    }
    }
    • Callers lose a lot of knowledge. The web controller, the mobile API and the admin tool call one method. They don’t know there are four services, which order they go in, or what to undo. A fifth step (fraud check, loyalty points) is added in one place.
    • Undo runs newest first. Each step that succeeds pushes its own undo onto a stack. So the undo code sits next to the step it reverses. On failure, the stack unwinds in reverse: refund, then release. Later steps can depend on earlier ones. So undoing in reverse never leaves a step in place whose prerequisite is already gone.
    • Not every failure is undone. Once the order has a label, it is placed. A failed email goes on a retry queue. Refunding a paid, shippable order because a confirmation bounced would be worse than a late email. Deciding which steps are critical is a business decision. The facade is where it is written down.
    • The facade adds no business rules of its own. It sequences calls and handles failure. Pricing, stock rules and fraud checks stay in the subsystems. That keeps it thin enough to read in one screen.
    • The subsystems are still reachable. A warehouse tool can still call Inventory directly. A facade simplifies the common path. It isn’t a wall. If you need a wall, that’s what the Hard level builds.
    • Undo in memory isn’t durable. Say the process crashes between the charge and the refund. The stack is gone, and the charge stays. Across real services, each step needs an idempotency key, and the progress must be stored. Then a restart can finish the undo. That is the saga pattern. See distributed transactions.

    A facade is a convenient place to put things, and that is its risk. CheckoutFacade grows cancelOrder, changeAddress, applyCoupon, exportForAccounting. Soon every change in the shop touches it. Watch for these signs:

    • It has more than a handful of methods.
    • It holds state of its own.
    • Its methods call each other.
    • It contains rules (“orders over $1000 need review”), not just sequencing.

    The fix is one facade per use case, or per caller group (CheckoutFacade, ReturnsFacade). Each one stays thin, and delegates the rules back to the subsystems that own them.

    A service layer (Martin Fowler’s term) is an architectural layer. It defines everything the application can do. It owns transactions, authorization, and the boundary with the UI. A facade is a structural pattern: one simpler interface over a set of classes.

    A service layer is often built from facade-like classes. So CheckoutService in a service layer and CheckoutFacade here can be the same code. The difference is the responsibility you give it. A facade promises “this is easier”. A service layer promises “every use of the application goes through here”.

    Interviewer

    Our web checkout and mobile app both place orders, and finance found customers who were charged for orders that never shipped. How would you restructure this?

    What they are testing

    Whether you pull the sequence into one facade used by both. Whether each step registers its undo, and you unwind newest first on failure. And whether you say which failures shouldn’t undo the order (a confirmation email). The red flag is patching the one missing refund in the web controller. Or a facade that also absorbs pricing and fraud rules.

    Hard

    A legacy carrier

    • An anti-corruption layer
    • Translating the model
    • Unknown codes and retryable errors
    • Contract tests
    • Two vendor versions behind one port

    The shop ships with Swiftline, whose tracking API predates JSON. It returns XML-ish lines with two-letter status codes: AF arrived at facility, DF departed facility, NA no one available. Timestamps look like 202610241840, in its own format. Results are split across pages. It rate limits with a 429 and a retry-after header. It has outages that return 503. And it adds new status codes without notice. Next year, Swiftline ships a v2 SDK with different names for everything. Both versions will run side by side during the migration.

    Order tracking, customer emails and the support dashboard need to know where a parcel is. They should speak the shop’s language: PICKED_UP, IN_TRANSIT, DELIVERED. Say AF and retry-after appear in order tracking code. Then Swiftline’s model has leaked into the shop’s. And every vendor change becomes a change to the domain.

    Eric Evans named this in Domain-Driven Design: an anti-corruption layer is an adapter at the boundary between two models. It does what the Easy adapter did (implement your interface, call theirs) and more:

    • It translates the model, not just method names. Two vendor codes can be one domain fact. A vendor’s page is not a domain concept at all.
    • It decides what unknown input means, safely.
    • It turns vendor failures into your failure categories, so callers can act on them.
    • It is tested the same way as every other implementation of the port, so fakes can be trusted.
    Hard: one port, two vendor adapters and a fake

    depends on

    vendor v1

    vendor v2

    returns

    throws

    TrackingService

    «interface»

    ShipmentTracker

    +track(trackingNo) : List<ShipmentEvent>

    ShipmentEvent

    +kind: EventKind

    +at

    +location

    +rawCode

    «enumeration»

    EventKind

    PICKED_UP

    IN_TRANSIT

    OUT_FOR_DELIVERY

    DELIVERY_FAILED

    DELIVERED

    UNRECOGNIZED

    CarrierError

    +kind

    +retryable

    SwiftlineV1Adapter

    -transport

    -sleeper

    +track(trackingNo)

    SwiftlineV2Adapter

    -client

    -sleeper

    +track(trackingNo)

    InMemoryTracker

    +track(trackingNo)

    «interface»

    LegacyTransport

    +get(path) : String

    «interface»

    SwiftlineV2Client

    +events(trackingNo) : List<V2Event>

    depends on

    vendor v1

    vendor v2

    returns

    throws

    TrackingService

    «interface»

    ShipmentTracker

    +track(trackingNo) : List<ShipmentEvent>

    ShipmentEvent

    +kind: EventKind

    +at

    +location

    +rawCode

    «enumeration»

    EventKind

    PICKED_UP

    IN_TRANSIT

    OUT_FOR_DELIVERY

    DELIVERY_FAILED

    DELIVERED

    UNRECOGNIZED

    CarrierError

    +kind

    +retryable

    SwiftlineV1Adapter

    -transport

    -sleeper

    +track(trackingNo)

    SwiftlineV2Adapter

    -client

    -sleeper

    +track(trackingNo)

    InMemoryTracker

    +track(trackingNo)

    «interface»

    LegacyTransport

    +get(path) : String

    «interface»

    SwiftlineV2Client

    +events(trackingNo) : List<V2Event>

    Scroll or pinch to zoom, drag to pan
    • Color key
    • Facade
    • Entity
    • Value object
    • Enum
    • Interface
    • Implementation

    Nothing on the left of the port knows a vendor exists. Everything on the right is replaceable.

    Choose how the carrier behaves and feed its responses to the v1 adapter one at a time:

    Page 1 answers with
    Page 2
    tracker.track("SW123")

    Raw vendor response

    Nothing fetched yet.

    What the adapter does

      What the domain sees

        Step through the responses. The domain sees nothing until track() returns or throws.

        Tracking SW123 when page 2 is rate limited
        Swiftline v1 APISwiftlineV1AdapterTrackingServiceSwiftline v1 APISwiftlineV1AdapterTrackingServicerate_limited is retryable: wait 2 smap codes, keep XR as UNRECOGNIZED, sort, collapse DFtrack("SW123")GET page 1PU, AF, more page=2GET page 2429, retry-after 2GET page 2 (attempt 2)DF, XR, OD, NA5 ShipmentEventsSwiftline v1 APISwiftlineV1AdapterTrackingServiceSwiftline v1 APISwiftlineV1AdapterTrackingServicerate_limited is retryable: wait 2 smap codes, keep XR as UNRECOGNIZED, sort, collapse DFtrack("SW123")GET page 1PU, AF, more page=2GET page 2429, retry-after 2GET page 2 (attempt 2)DF, XR, OD, NA5 ShipmentEvents
        Scroll or pinch to zoom, drag to pan
        • Color key
        • Implementation
        • Entity
        Main.javain_memory_tracker.pyProgram.csscripted_v2.goin-memory-tracker.ts
        import java.util.*;
        import java.util.function.Consumer;
        import java.util.regex.Matcher;
        import java.util.regex.Pattern;
        // ===== Our domain: the port, and the language the rest of the system speaks. =====
        enum EventKind { PICKED_UP, IN_TRANSIT, OUT_FOR_DELIVERY, DELIVERY_FAILED, DELIVERED, UNRECOGNIZED }
        // One fact about a shipment, in our words. rawCode is kept only so an UNRECOGNIZED event can
        // be investigated; domain code never branches on it.
        record ShipmentEvent(EventKind kind, String at, String location, String rawCode) {
        @Override
        public String toString() {
        String k = kind == EventKind.UNRECOGNIZED ? "UNRECOGNIZED(" + rawCode + ")" : kind.name();
        return k + " " + location + " " + at;
        }
        }
        // Errors in our terms. Callers decide from `retryable`, never from a vendor's status code.
        class CarrierError extends RuntimeException {
        final String kind;
        final boolean retryable;
        CarrierError(String kind, boolean retryable) {
        super(kind);
        this.kind = kind;
        this.retryable = retryable;
        }
        }
        // The port. Order tracking and customer emails depend on this and nothing else.
        interface ShipmentTracker {
        List<ShipmentEvent> track(String trackingNo);
        }
        // ===== Shared by both adapters: retries, error classification and normalizing. =====
        // A failed vendor call, before we decide what it means. Internal to the adapters.
        class VendorFailure extends Exception {
        final int status, retryAfterSeconds;
        VendorFailure(int status, int retryAfterSeconds) {
        this.status = status;
        this.retryAfterSeconds = retryAfterSeconds;
        }
        }
        interface VendorCall<T> {
        T call() throws VendorFailure;
        }
        // Injected so retries can be tested without waiting. Nothing here really sleeps.
        class FakeSleeper {
        int totalSeconds;
        void sleep(int seconds) {
        totalSeconds += seconds;
        }
        }
        final class Boundary {
        static final int MAX_ATTEMPTS = 3;
        // Vendor statuses become two kinds of failure. Retrying a 404 or a 401 can't help and
        // only burns rate limit; retrying a 429 or a 503 usually does.
        static CarrierError classify(int status) {
        if (status == 429) return new CarrierError("rate_limited", true);
        if (status >= 500) return new CarrierError("unavailable", true);
        if (status == 404) return new CarrierError("not_found", false);
        return new CarrierError("rejected_" + status, false);
        }
        // Retries only what is retryable, honors the vendor's Retry-After, and gives up after a
        // few attempts so the caller (a job queue, say) can try again much later.
        static <T> T withRetries(String what, VendorCall<T> call, FakeSleeper sleeper, Consumer<String> log) {
        for (int attempt = 1; ; attempt++) {
        try {
        return call.call();
        } catch (VendorFailure f) {
        CarrierError error = classify(f.status);
        if (!error.retryable || attempt == MAX_ATTEMPTS) throw error;
        int wait = f.retryAfterSeconds > 0 ? f.retryAfterSeconds : attempt;
        log.accept(what + ": " + f.status + ", retry in " + wait + " s");
        sleeper.sleep(wait);
        }
        }
        }
        // The domain promises time order and one IN_TRANSIT per facility, whatever the vendor sends.
        // An "arrived" and a "departed" scan at the same hub are one fact for us.
        static List<ShipmentEvent> normalize(List<ShipmentEvent> events) {
        List<ShipmentEvent> sorted = new ArrayList<>(events);
        sorted.sort(Comparator.comparing(ShipmentEvent::at));
        List<ShipmentEvent> out = new ArrayList<>();
        for (ShipmentEvent e : sorted) {
        ShipmentEvent last = out.isEmpty() ? null : out.get(out.size() - 1);
        boolean sameHub = last != null && last.kind() == EventKind.IN_TRANSIT
        && e.kind() == EventKind.IN_TRANSIT && last.location().equals(e.location());
        if (!sameHub) out.add(e);
        }
        return out;
        }
        }
        // ===== Vendor v1: XML-ish text over HTTP, paged, rate limited. =====
        interface LegacyTransport {
        String get(String path);
        }
        // Replays recorded vendor responses: each path returns its queue in order, then repeats the
        // last one. Unknown paths get a 404, as the real API does.
        class ScriptedTransport implements LegacyTransport {
        private final Map<String, Deque<String>> script = new HashMap<>();
        ScriptedTransport on(String path, String... responses) {
        script.put(path, new ArrayDeque<>(List.of(responses)));
        return this;
        }
        public String get(String path) {
        Deque<String> q = script.get(path);
        if (q == null) return "<error code=\"404\"/>";
        return q.size() > 1 ? q.poll() : q.peek();
        }
        }
        class SwiftlineV1Adapter implements ShipmentTracker {
        // Their codes in our vocabulary. Two vendor codes can mean one domain fact.
        private static final Map<String, EventKind> CODES = Map.of(
        "PU", EventKind.PICKED_UP, "AF", EventKind.IN_TRANSIT, "DF", EventKind.IN_TRANSIT,
        "OD", EventKind.OUT_FOR_DELIVERY, "NA", EventKind.DELIVERY_FAILED, "DL", EventKind.DELIVERED);
        private static final Pattern ATTR = Pattern.compile("([\\w-]+)=\"([^\"]*)\"");
        private final LegacyTransport transport;
        private final FakeSleeper sleeper;
        private final Consumer<String> log;
        SwiftlineV1Adapter(LegacyTransport transport, FakeSleeper sleeper, Consumer<String> log) {
        this.transport = transport;
        this.sleeper = sleeper;
        this.log = log;
        }
        public List<ShipmentEvent> track(String trackingNo) {
        List<ShipmentEvent> events = new ArrayList<>();
        // Pagination is the vendor's concern. The domain gets one complete list.
        String page = "1";
        while (page != null) {
        String path = "/track?n=" + trackingNo + "&page=" + page;
        String body = Boundary.withRetries(trackingNo + " page " + page, () -> fetch(path), sleeper, log);
        page = null;
        for (String line : body.split("\n")) {
        Map<String, String> a = attrs(line);
        if (line.startsWith("<more")) page = a.get("page");
        if (line.startsWith("<scan")) events.add(toEvent(a));
        }
        }
        return Boundary.normalize(events);
        }
        private String fetch(String path) throws VendorFailure {
        String body = transport.get(path);
        if (!body.startsWith("<error")) return body;
        Map<String, String> a = attrs(body);
        throw new VendorFailure(Integer.parseInt(a.get("code")), Integer.parseInt(a.getOrDefault("retry-after", "0")));
        }
        // An unknown code is kept as UNRECOGNIZED and logged, never dropped and never guessed.
        // Dropping hides that the vendor changed; guessing can tell a customer "delivered" wrongly.
        private ShipmentEvent toEvent(Map<String, String> a) {
        String code = a.get("code");
        EventKind kind = CODES.getOrDefault(code, EventKind.UNRECOGNIZED);
        if (kind == EventKind.UNRECOGNIZED) log.accept("unknown status code " + code + ", kept as UNRECOGNIZED");
        return new ShipmentEvent(kind, isoTime(a.get("ts")), a.get("loc"), code);
        }
        // "202610240910" -> "2026-10-24T09:10". The vendor's format stops at this boundary.
        static String isoTime(String ts) {
        return ts.substring(0, 4) + "-" + ts.substring(4, 6) + "-" + ts.substring(6, 8)
        + "T" + ts.substring(8, 10) + ":" + ts.substring(10, 12);
        }
        static Map<String, String> attrs(String line) {
        Map<String, String> out = new HashMap<>();
        Matcher m = ATTR.matcher(line);
        while (m.find()) out.put(m.group(1), m.group(2));
        return out;
        }
        }
        // ===== Vendor v2: a typed SDK with different names, no paging, its own error type. =====
        record V2Event(String status, String occurredAt, String facility) {}
        class V2Error extends RuntimeException {
        final int httpStatus, retryAfter;
        V2Error(int httpStatus, int retryAfter) {
        this.httpStatus = httpStatus;
        this.retryAfter = retryAfter;
        }
        }
        interface SwiftlineV2Client {
        List<V2Event> events(String trackingNo);
        }
        class SwiftlineV2Adapter implements ShipmentTracker {
        private static final Map<String, EventKind> CODES = Map.of(
        "PICKED_UP", EventKind.PICKED_UP, "IN_TRANSIT", EventKind.IN_TRANSIT,
        "OUT_FOR_DELIVERY", EventKind.OUT_FOR_DELIVERY, "DELIVERY_ATTEMPTED", EventKind.DELIVERY_FAILED,
        "DELIVERED", EventKind.DELIVERED);
        private final SwiftlineV2Client client;
        private final FakeSleeper sleeper;
        private final Consumer<String> log;
        SwiftlineV2Adapter(SwiftlineV2Client client, FakeSleeper sleeper, Consumer<String> log) {
        this.client = client;
        this.sleeper = sleeper;
        this.log = log;
        }
        public List<ShipmentEvent> track(String trackingNo) {
        List<V2Event> raw = Boundary.withRetries(trackingNo, () -> {
        try {
        return client.events(trackingNo);
        } catch (V2Error e) {
        throw new VendorFailure(e.httpStatus, e.retryAfter);
        }
        }, sleeper, log);
        List<ShipmentEvent> events = new ArrayList<>();
        for (V2Event e : raw) {
        EventKind kind = CODES.getOrDefault(e.status(), EventKind.UNRECOGNIZED);
        if (kind == EventKind.UNRECOGNIZED) log.accept("unknown status " + e.status() + ", kept as UNRECOGNIZED");
        // "2026-10-24T09:10:00Z" -> "2026-10-24T09:10", the same shape v1 produces.
        events.add(new ShipmentEvent(kind, e.occurredAt().substring(0, 16), e.facility(), e.status()));
        }
        return Boundary.normalize(events);
        }
        }
        // ===== A fake for tests elsewhere in the system. It must behave like the real adapters. =====
        class InMemoryTracker implements ShipmentTracker {
        private final Map<String, List<ShipmentEvent>> shipments = new HashMap<>();
        InMemoryTracker add(String trackingNo, List<ShipmentEvent> events) {
        shipments.put(trackingNo, events);
        return this;
        }
        public List<ShipmentEvent> track(String trackingNo) {
        List<ShipmentEvent> events = shipments.get(trackingNo);
        if (events == null) throw new CarrierError("not_found", false);
        return events;
        }
        }
        class Main {
        // One suite, run against every implementation of the port. If the fake passes the same
        // checks as the real adapters, tests that use the fake are testing something true.
        static void contract(String name, ShipmentTracker tracker) {
        int passed = 0;
        List<ShipmentEvent> events = tracker.track("SW-DONE");
        if (!events.isEmpty() && events.get(events.size() - 1).kind() == EventKind.DELIVERED) passed++;
        boolean ordered = true;
        for (int i = 1; i < events.size(); i++) ordered &= events.get(i - 1).at().compareTo(events.get(i).at()) <= 0;
        if (ordered) passed++;
        try {
        tracker.track("SW-NONE");
        } catch (CarrierError e) {
        if (e.kind.equals("not_found") && !e.retryable) passed++;
        }
        System.out.println(name + ": " + passed + "/3 passed");
        }
        public static void main(String[] args) {
        Consumer<String> print = s -> System.out.println(" log: " + s);
        FakeSleeper sleeper = new FakeSleeper();
        ScriptedTransport v1 = new ScriptedTransport()
        .on("/track?n=SW123&page=1",
        "<scan code=\"PU\" ts=\"202610240910\" loc=\"AUS\"/>\n"
        + "<scan code=\"AF\" ts=\"202610241840\" loc=\"DFW\"/>\n"
        + "<more page=\"2\"/>")
        .on("/track?n=SW123&page=2",
        "<error code=\"429\" retry-after=\"2\"/>",
        "<scan code=\"DF\" ts=\"202610250205\" loc=\"DFW\"/>\n"
        + "<scan code=\"XR\" ts=\"202610250730\" loc=\"MEM\"/>\n"
        + "<scan code=\"OD\" ts=\"202610260815\" loc=\"MEM\"/>\n"
        + "<scan code=\"NA\" ts=\"202610261302\" loc=\"MEM\"/>")
        .on("/track?n=SW503&page=1", "<error code=\"503\"/>")
        .on("/track?n=SW-DONE&page=1",
        "<scan code=\"PU\" ts=\"202610200900\" loc=\"AUS\"/>\n"
        + "<scan code=\"DL\" ts=\"202610221100\" loc=\"AUS\"/>");
        ShipmentTracker tracker = new SwiftlineV1Adapter(v1, sleeper, print);
        System.out.println("== SW123 through the v1 adapter");
        for (ShipmentEvent e : tracker.track("SW123")) System.out.println(e);
        System.out.println("waited " + sleeper.totalSeconds + " s");
        // == SW123 through the v1 adapter
        // log: SW123 page 2: 429, retry in 2 s
        // log: unknown status code XR, kept as UNRECOGNIZED
        // PICKED_UP AUS 2026-10-24T09:10
        // IN_TRANSIT DFW 2026-10-24T18:40
        // UNRECOGNIZED(XR) MEM 2026-10-25T07:30
        // OUT_FOR_DELIVERY MEM 2026-10-26T08:15
        // DELIVERY_FAILED MEM 2026-10-26T13:02
        // waited 2 s
        System.out.println("== errors, in our terms");
        for (String no : List.of("SW404", "SW503")) {
        try {
        tracker.track(no);
        } catch (CarrierError e) {
        System.out.println(no + ": " + e.kind + ", retryable=" + e.retryable);
        }
        }
        // == errors, in our terms
        // SW404: not_found, retryable=false
        // log: SW503 page 1: 503, retry in 1 s
        // log: SW503 page 1: 503, retry in 2 s
        // SW503: unavailable, retryable=true
        // v2 ships. A second adapter goes behind the same port; nothing in the domain changes.
        Map<String, List<V2Event>> v2Data = Map.of("SW-DONE", List.of(
        new V2Event("PICKED_UP", "2026-10-20T09:00:00Z", "AUS"),
        new V2Event("DELIVERED", "2026-10-22T11:00:00Z", "AUS")));
        SwiftlineV2Client v2 = no -> {
        if (!v2Data.containsKey(no)) throw new V2Error(404, 0);
        return v2Data.get(no);
        };
        Consumer<String> quiet = s -> {};
        System.out.println("== one contract suite, every implementation");
        contract("v1 adapter", new SwiftlineV1Adapter(v1, new FakeSleeper(), quiet));
        contract("v2 adapter", new SwiftlineV2Adapter(v2, new FakeSleeper(), quiet));
        contract("in-memory fake", new InMemoryTracker().add("SW-DONE", List.of(
        new ShipmentEvent(EventKind.PICKED_UP, "2026-10-20T09:00", "AUS", "PU"),
        new ShipmentEvent(EventKind.DELIVERED, "2026-10-22T11:00", "AUS", "DL"))));
        // == one contract suite, every implementation
        // v1 adapter: 3/3 passed
        // v2 adapter: 3/3 passed
        // in-memory fake: 3/3 passed
        }
        }
        • The model is translated, not renamed. AF and DF are two vendor scans, and one domain fact. So a departure from the hub the parcel just arrived at is collapsed. The vendor’s time format, its pages and its codes all stop at the adapter. The domain gets one complete, time-ordered list.
        • Unknown codes are kept, marked and logged. XR becomes UNRECOGNIZED(XR). Dropping it would hide that the vendor changed something. Guessing (“probably in transit”) could tell a customer a parcel moved when it didn’t. Domain code treats UNRECOGNIZED as “no change”. And a metric on how often it appears tells you when the table needs a new row.
        • Errors are sorted by what the caller can do. 429 and 5xx are retryable. 404 and 401 are not, because asking again gives the same answer, and spends rate limit. Callers see CarrierError with a retryable flag, and never a vendor status code.
        • Retries have a budget. The adapter honors retry-after when the vendor sends it. It backs off (1 s, then 2 s) when it doesn’t. And it gives up after three attempts. A tracking job that gets a retryable error tries again minutes later, instead of the adapter holding a thread for a minute. With many instances, each one’s retries add up. A shared rate limiter in front of the vendor keeps the total under the vendor’s limit.
        • Time is injected. FakeSleeper records waits instead of sleeping, so the 429 path is tested in microseconds.
        • One contract suite, every implementation. The same checks run against the v1 adapter with recorded responses, the v2 adapter, and the in-memory fake. (For example: a delivered shipment ends with DELIVERED, events are in time order, and an unknown number is a non-retryable not_found.) If the fake passes, every test that uses it is testing behavior the real adapters have. Against the real vendor, the same suite can run nightly in a sandbox.
        • v2 is a second adapter, not an edit. SwiftlineV2Adapter maps different names (DELIVERY_ATTEMPTED to DELIVERY_FAILED), and a different time format, to the same domain events. It passes the same contract. During the migration, configuration chooses the adapter. So switching back is a config change. A router that picks per tracking number would be one more ShipmentTracker.
        • The port is shaped by the domain. track returns domain events, not pages, raw codes, or a vendor response object. Say the port had a page parameter. Then v2 (which doesn’t page) would have to fake one. And every caller would be paging through a vendor detail.
        Interviewer

        We integrate with a carrier whose API is old: odd status codes, paging, rate limits, and they add codes without telling us. They’re also releasing a v2. How do you keep this from leaking into our order code?

        What they are testing

        Whether you define a port in domain terms. Whether you put each vendor version behind its own adapter, which translates the model (not just names). Whether you handle unknown codes explicitly, and classify errors as retryable or not. And whether you prove fakes and adapters equivalent with a shared contract suite. The red flag is a port that mirrors the vendor’s fields, or if (version == 2) branches inside one adapter.

        Say this out loud

        I’ll define a ShipmentTracker port that returns our own events. Each Swiftline version gets an adapter. It pages, and retries what’s retryable, with a budget. It maps their codes to our event kinds, keeps unknown codes as UNRECOGNIZED, and throws our CarrierError. One contract suite runs against both adapters and the in-memory fake, so the fake can be trusted.

        Use an adapter when something useful has the wrong interface: a vendor SDK, a legacy class, a library with a different error model. Make it an anti-corruption layer when the other side has a different model, not just different names. Use a facade when several callers repeat the same sequence of calls into a subsystem, or when failure handling across those calls must be consistent. Skip both when you own the code on both sides and can simply change the interface.

        Language Adapter, usually Built-in examples
        Java A class that implements your interface and holds the other object Arrays.asList (an array as a List), InputStreamReader (bytes as characters); SLF4J is a logging facade with adapters for each backend
        Python A class with the expected methods (duck typing), or a small function io.TextIOWrapper (a binary stream as text); subprocess.run is a facade over Popen
        C# A class that implements the interface and holds the other object StreamReader (a Stream as a TextReader); File.ReadAllText is a facade over a stream and a reader
        Go A type with the expected methods, or a function type with a method http.HandlerFunc (a function as an http.Handler); http.Get is a facade over a client and a request
        TypeScript A class implementing the interface, or an object literal Node’s util.promisify (a callback API as a promise API)

        Go’s documentation for http.HandlerFunc describes it as “an adapter to allow the use of ordinary functions as HTTP handlers”. It’s the smallest adapter there is: a function type with one method that calls the function.

        Pattern Looks like it because The difference
        Decorator It wraps one object A decorator keeps the same interface and adds behavior. An adapter changes the interface and keeps the behavior.
        Proxy It wraps one object and forwards calls A proxy has the same interface as the real object and controls access to it: lazy loading, a remote call, a permission check. An adapter has a different interface from the object it wraps.
        Bridge It also separates an interface from an implementation Bridge is designed up front so an abstraction and its implementations can vary independently. An adapter is fitted afterwards, to make existing things work together.
        Mediator It also sits in the middle of several objects A mediator coordinates objects that talk to each other through it, in both directions. A facade is one-way: callers use it, and the subsystem doesn’t know it exists.
        Service layer A CheckoutService and a CheckoutFacade can look identical A service layer is an architectural boundary that owns transactions and authorization for the whole application. A facade is a convenience over one subsystem.
        Ports and adapters (hexagonal architecture) It uses the word “adapter” An architecture where the domain defines ports and every external system plugs in through an adapter. The Hard level is one port and its adapters.
        “Wrapper” Everyone calls these wrappers Wrapper is a general word for any class around another. Adapter, decorator, proxy and facade are kinds of wrapper, and each name says what the wrapper is for.

        On this site:

        • Dependency inversion: the Hard level builds a billing job as ports and adapters, the same structure as this page’s Hard level.
        • Single responsibility: the signup flow is a facade whose one responsibility is the order of the steps.
        • Decorator: the other common wrapper, with the same interface.

        In real systems, you’ll see adapters and facades in:

        • Payment gateways that support several processors behind one API.
        • Logging facades, such as SLF4J.
        • JDBC and database/sql drivers. Each adapts one database’s protocol to a standard interface.
        • SDK helpers that hide a multi-step protocol, such as the S3 transfer manager over multipart uploads.
        • Integration layers in front of legacy systems, ERPs and carriers.
        Interviewer asksWhat's the difference between an adapter and a facade?

        An adapter wraps one object, and changes its interface to one the caller already expects, with the same capability. A facade wraps several objects. It offers a new, simpler interface that exposes less than they can do. Ask whose interface it is. If the caller defined it, it’s an adapter. If it was designed to make a subsystem easy, it’s a facade.

        Interviewer asksObject adapter or class adapter?

        An object adapter holds the adaptee. A class adapter inherits from it. Prefer the object adapter. It works with any instance and subclass. It needs no inheritance from a class that may be final. And it doesn’t expose the adaptee’s methods. A class adapter is one object instead of two, and can override adaptee methods, which is occasionally needed.

        Interviewer asksWhat is an anti-corruption layer?

        An adapter at the boundary between your model and another system’s, from Domain-Driven Design. It translates their concepts into yours (codes, units, pages, errors), decides what unknown input means, and stops their vocabulary from spreading into your code. Use one when the other system’s model differs from yours, especially a legacy system or a vendor you don’t control.

        Interviewer asksHow do you test an adapter for a third-party API?

        Unit-test the translation with recorded raw responses, including errors, unknown codes and paging. Write one contract suite against the port. Run it against the adapter, against the fake used by other tests, and, on a schedule, against the vendor’s sandbox. The contract is what makes the fake trustworthy.

        Interviewer asksWhen does a facade become a problem?

        When it stops being thin: it collects every operation on the subsystem, holds state, or contains business rules instead of sequencing. Then every change goes through it and it can’t be understood in one reading. Split it by use case and push rules back into the subsystems.

        Decision We chose We gave up
        A vendor SDK with the wrong shape An object adapter behind our own interface A class to maintain per vendor
        Units and errors Translated in the adapter, with a safe default for unknown codes Direct access to vendor-specific detail
        Object or class adapter Object adapter, holding the client One extra object
        A multi-step order flow One facade that sequences and undoes, newest first One more class every flow change goes through
        Failed email after a paid order Retry later, don’t undo Confirmations can arrive late
        A legacy carrier An anti-corruption layer that translates the model Raw vendor detail, unless stored separately
        Unknown status codes Kept as UNRECOGNIZED and logged A tidy enum with no escape hatch
        Vendor failures Retryable or not, with a retry budget Vendor status codes in callers
        Trusting fakes One contract suite for adapters and fakes Time to write and run the suite
        Vendor v2 A second adapter behind the same port Two adapters to run during the migration
        • Dependency inversion, the principle that puts your interface in charge
        • Decorator, the wrapper that keeps the interface and adds behavior
        • Rate limiter, for keeping many instances under a vendor’s limit
        • Erich Gamma, Richard Helm, Ralph Johnson and John Vlissides, Design Patterns, the Adapter and Facade chapters
        • Eric Evans, Domain-Driven Design, on the anti-corruption layer
        • Martin Fowler, Patterns of Enterprise Application Architecture, on Service Layer and Gateway