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.
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 shape
Section titled “The shape”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.
- 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 |
Worked examples
Section titled “Worked examples”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.
Without the pattern
Section titled “Without the pattern”Call the SDK from wherever a payment happens:
// 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) }}from dataclasses import dataclass
# The vendor SDK, as before: dollars as a decimal string, lowercase currency, PW_ error codes.@dataclassclass PayWaveReceipt: payment_ref: str amount_captured: str
class PayWaveError(Exception): def __init__(self, error_code: str) -> None: super().__init__(error_code) self.error_code = error_code
class PayWaveClient: def __init__(self) -> None: self._next = 1001
def create_payment(self, amount: str, currency_code: str) -> PayWaveReceipt: print(f'PayWave <- createPayment("{amount}", "{currency_code}")') if float(amount) > 500: raise PayWaveError("PW_INSUFFICIENT") ref = f"pw_{self._next}" self._next += 1 return PayWaveReceipt(ref, 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: def __init__(self, pay_wave: PayWaveClient) -> None: self._pay_wave = pay_wave
def pay(self, order_id: str, cents: int) -> None: amount = f"{cents // 100}.{cents % 100:02d}" try: r = self._pay_wave.create_payment(amount, "usd") print(f"{order_id}: paid {r.amount_captured} as {r.payment_ref}") except PayWaveError as e: print(f"{order_id}: declined ({e.error_code})")
# 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: def __init__(self, pay_wave: PayWaveClient) -> None: self._pay_wave = pay_wave
def renew(self, sub_id: str, cents: int) -> None: try: r = self._pay_wave.create_payment(str(cents), "usd") print(f"{sub_id}: renewed as {r.payment_ref}") except PayWaveError as e: print(f"{sub_id}: renewal failed ({e.error_code})")
pay_wave = PayWaveClient()
Checkout(pay_wave).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.Subscriptions(pay_wave).renew("S-9", 1999)# PayWave <- createPayment("1999", "usd")# S-9: renewal failed (PW_INSUFFICIENT)using System;using System.Globalization;
// The vendor SDK, as before: dollars as a decimal string, lowercase currency, PW_ error codes.public sealed record PayWaveReceipt(string PaymentRef, string AmountCaptured);
public sealed class PayWaveException : Exception{ public string ErrorCode { get; } public PayWaveException(string errorCode) : base(errorCode) => ErrorCode = errorCode;}
public sealed class PayWaveClient{ private int _next = 1001;
public PayWaveReceipt CreatePayment(string amount, string currencyCode) { Console.WriteLine($"PayWave <- createPayment(\"{amount}\", \"{currencyCode}\")"); if (double.Parse(amount, CultureInfo.InvariantCulture) > 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.public sealed class Checkout{ private readonly PayWaveClient _payWave; public Checkout(PayWaveClient payWave) => _payWave = payWave;
public void Pay(string orderId, long cents) { var amount = $"{cents / 100}.{cents % 100:D2}"; try { var r = _payWave.CreatePayment(amount, "usd"); Console.WriteLine($"{orderId}: paid {r.AmountCaptured} as {r.PaymentRef}"); } catch (PayWaveException e) { Console.WriteLine($"{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.public sealed class Subscriptions{ private readonly PayWaveClient _payWave; public Subscriptions(PayWaveClient payWave) => _payWave = payWave;
public void Renew(string subId, long cents) { try { var r = _payWave.CreatePayment(cents.ToString(CultureInfo.InvariantCulture), "usd"); Console.WriteLine($"{subId}: renewed as {r.PaymentRef}"); } catch (PayWaveException e) { Console.WriteLine($"{subId}: renewal failed ({e.ErrorCode})"); } }}
public static class Program{ public static void Main() { var 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) }}package main
import ( "errors" "fmt" "strconv")
// The vendor SDK, as before: dollars as a decimal string, lowercase currency, PW_ error codes.type PayWaveReceipt struct { PaymentRef string AmountCaptured string}
type PayWaveError struct{ ErrorCode string }
func (e *PayWaveError) Error() string { return e.ErrorCode }
type PayWaveClient struct{ next int }
func (c *PayWaveClient) CreatePayment(amount, currencyCode string) (PayWaveReceipt, error) { fmt.Printf("PayWave <- createPayment(%q, %q)\n", amount, currencyCode) if f, _ := strconv.ParseFloat(amount, 64); f > 500 { return PayWaveReceipt{}, &PayWaveError{"PW_INSUFFICIENT"} } ref := fmt.Sprintf("pw_%d", c.next) c.next++ return PayWaveReceipt{ref, amount}, nil}
// Checkout talks to the SDK directly, like every other caller, so each one repeats the// conversions and knows the vendor's error codes. Switching vendors means editing all of them.type Checkout struct{ payWave *PayWaveClient }
func (c Checkout) Pay(orderID string, cents int64) { amount := fmt.Sprintf("%d.%02d", cents/100, cents%100) r, err := c.payWave.CreatePayment(amount, "usd") var pwErr *PayWaveError if errors.As(err, &pwErr) { fmt.Printf("%s: declined (%s)\n", orderID, pwErr.ErrorCode) return } fmt.Printf("%s: paid %s as %s\n", orderID, r.AmountCaptured, r.PaymentRef)}
// Subscriptions is 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.type Subscriptions struct{ payWave *PayWaveClient }
func (s Subscriptions) Renew(subID string, cents int64) { r, err := s.payWave.CreatePayment(strconv.FormatInt(cents, 10), "usd") var pwErr *PayWaveError if errors.As(err, &pwErr) { fmt.Printf("%s: renewal failed (%s)\n", subID, pwErr.ErrorCode) return } fmt.Printf("%s: renewed as %s\n", subID, r.PaymentRef)}
func main() { payWave := &PayWaveClient{next: 1001}
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. Subscriptions{payWave}.Renew("S-9", 1999) // PayWave <- createPayment("1999", "usd") // S-9: renewal failed (PW_INSUFFICIENT)}// The vendor SDK, as before: dollars as a decimal string, lowercase currency, PW_ error codes.interface PayWaveReceipt { payment_ref: string; amount_captured: string;}
class PayWaveError extends Error { constructor(readonly errorCode: string) { super(errorCode); }}
class PayWaveClient { private next = 1001;
createPayment(amount: string, currencyCode: string): PayWaveReceipt { console.log(`PayWave <- createPayment("${amount}", "${currencyCode}")`); if (Number(amount) > 500) throw new PayWaveError("PW_INSUFFICIENT"); return { payment_ref: `pw_${this.next++}`, amount_captured: 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 { constructor(private readonly payWave: PayWaveClient) {}
pay(orderId: string, cents: number): void { const amount = `${Math.floor(cents / 100)}.${String(cents % 100).padStart(2, "0")}`; try { const r = this.payWave.createPayment(amount, "usd"); console.log(`${orderId}: paid ${r.amount_captured} as ${r.payment_ref}`); } catch (e) { if (!(e instanceof PayWaveError)) throw e; console.log(`${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 { constructor(private readonly payWave: PayWaveClient) {}
renew(subId: string, cents: number): void { try { const r = this.payWave.createPayment(String(cents), "usd"); console.log(`${subId}: renewed as ${r.payment_ref}`); } catch (e) { if (!(e instanceof PayWaveError)) throw e; console.log(`${subId}: renewal failed (${e.errorCode})`); } }}
const 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.
With an adapter
Section titled “With an adapter”Keep checkout on PaymentGateway. Write one class that implements it on top of the SDK:
- 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:
- Checkout calls
- Adapter, on the way in
- PayWave receives
- PayWave answers
- Adapter, on the way out
- Checkout gets
The adapter's error table
| PayWave error_code | PaymentDeclined reason |
|---|---|
PW_INSUFFICIENT | insufficient_funds |
PW_DO_NOT_HONOR | card_declined |
PW_EXPIRED | expired_card |
anything else | processing_error |
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) }}from dataclasses import dataclassfrom typing import Protocol
# ---- Our side: the interface checkout was written against. ----# Money is integer cents (never floating point) and currency is an ISO code like "USD".@dataclass(frozen=True)class ChargeResult: charge_id: str amount_cents: int currency: str
# Our own error, in our own words. Checkout catches this, never a vendor exception.class PaymentDeclined(Exception): def __init__(self, reason: str) -> None: super().__init__(f"declined: {reason}") self.reason = reason
class PaymentGateway(Protocol): def charge(self, amount_cents: int, currency: str) -> ChargeResult: ...
# ---- Their side: a vendor SDK we can't change. ----# Dollars as a decimal string, lowercase currency, and its own result and error shapes.@dataclassclass PayWaveReceipt: payment_ref: str status: str amount_captured: str
class PayWaveError(Exception): def __init__(self, error_code: str) -> None: super().__init__(error_code) self.error_code = error_code
# A fake of the vendor's client: it prints what it received and declines anything over $500.class PayWaveClient: def __init__(self) -> None: self._next = 1001
def create_payment(self, amount: str, currency_code: str) -> PayWaveReceipt: print(f'PayWave <- createPayment("{amount}", "{currency_code}")') if float(amount) > 500: raise PayWaveError("PW_INSUFFICIENT") ref = f"pw_{self._next}" self._next += 1 return PayWaveReceipt(ref, "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: # 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. REASONS = { "PW_INSUFFICIENT": "insufficient_funds", "PW_DO_NOT_HONOR": "card_declined", "PW_EXPIRED": "expired_card", }
def __init__(self, client: PayWaveClient) -> None: self._client = client
def charge(self, amount_cents: int, currency: str) -> ChargeResult: try: receipt = self._client.create_payment(to_decimal(amount_cents), currency.lower()) except PayWaveError as e: raise PaymentDeclined(self.REASONS.get(e.error_code, "processing_error")) from e # Their result shape becomes ours: a status string to check, a text amount to parse. if receipt.status != "succeeded": raise PaymentDeclined("processing_error") return ChargeResult(receipt.payment_ref, to_cents(receipt.amount_captured), 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).def to_decimal(cents: int) -> str: return f"{cents // 100}.{cents % 100:02d}"
# "12.50" -> 1250, parsed as text for the same reason.def to_cents(decimal: str) -> int: whole, frac = decimal.split(".") return int(whole) * 100 + int(frac)
# Business code. It knows PaymentGateway and nothing about any vendor.class Checkout: def __init__(self, payments: PaymentGateway) -> None: self._payments = payments
def pay(self, order_id: str, amount_cents: int, currency: str) -> None: try: r = self._payments.charge(amount_cents, currency) print(f"{order_id}: paid {r.amount_cents} {r.currency} as {r.charge_id}") except PaymentDeclined as e: print(f"{order_id}: declined ({e.reason})")
# The only line that names the vendor. Switching vendors changes this line and adds# one adapter; Checkout is untouched.checkout = Checkout(PayWaveAdapter(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)using System;using System.Collections.Generic;using System.Globalization;
// ---- Our side: the interface checkout was written against. ----// Money is integer cents (never floating point) and currency is an ISO code like "USD".public interface IPaymentGateway{ ChargeResult Charge(long amountCents, string currency);}
public sealed record ChargeResult(string ChargeId, long AmountCents, string Currency);
// Our own error, in our own words. Checkout catches this, never a vendor exception.public sealed class PaymentDeclined : Exception{ public string Reason { get; } public PaymentDeclined(string reason) : base($"declined: {reason}") => 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.public sealed record PayWaveReceipt(string PaymentRef, string Status, string AmountCaptured);
public sealed class PayWaveException : Exception{ public string ErrorCode { get; } public PayWaveException(string errorCode) : base(errorCode) => ErrorCode = errorCode;}
// A fake of the vendor's client: it prints what it received and declines anything over $500.public class PayWaveClient{ private int _next = 1001;
public PayWaveReceipt CreatePayment(string amount, string currencyCode) { Console.WriteLine($"PayWave <- createPayment(\"{amount}\", \"{currencyCode}\")"); if (double.Parse(amount, CultureInfo.InvariantCulture) > 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.public sealed class PayWaveAdapter : IPaymentGateway{ // 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 readonly Dictionary<string, string> Reasons = new() { ["PW_INSUFFICIENT"] = "insufficient_funds", ["PW_DO_NOT_HONOR"] = "card_declined", ["PW_EXPIRED"] = "expired_card", };
private readonly PayWaveClient _client; public PayWaveAdapter(PayWaveClient client) => _client = client;
public ChargeResult Charge(long amountCents, string currency) { PayWaveReceipt receipt; try { receipt = _client.CreatePayment(ToDecimal(amountCents), currency.ToLowerInvariant()); } catch (PayWaveException e) { throw new PaymentDeclined(Reasons.GetValueOrDefault(e.ErrorCode, "processing_error")); } // Their result shape becomes ours: a status string to check, a text amount to parse. if (receipt.Status != "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). public static string ToDecimal(long cents) => $"{cents / 100}.{cents % 100:D2}";
// "12.50" -> 1250, parsed as text for the same reason. public static long ToCents(string dec) { var parts = dec.Split('.'); return long.Parse(parts[0], CultureInfo.InvariantCulture) * 100 + long.Parse(parts[1], CultureInfo.InvariantCulture); }}
// Business code. It knows IPaymentGateway and nothing about any vendor.public sealed class Checkout{ private readonly IPaymentGateway _payments; public Checkout(IPaymentGateway payments) => _payments = payments;
public void Pay(string orderId, long amountCents, string currency) { try { var r = _payments.Charge(amountCents, currency); Console.WriteLine($"{orderId}: paid {r.AmountCents} {r.Currency} as {r.ChargeId}"); } catch (PaymentDeclined e) { Console.WriteLine($"{orderId}: declined ({e.Reason})"); } }}
public static class Program{ public static void Main() { // The only line that names the vendor. Switching vendors changes this line and adds // one adapter; Checkout is untouched. var 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) }}package main
import ( "errors" "fmt" "strconv" "strings")
// ---- Our side: the interface checkout was written against. ----// Money is integer cents (never floating point) and currency is an ISO code like "USD".type PaymentGateway interface { Charge(amountCents int64, currency string) (ChargeResult, error)}
type ChargeResult struct { ChargeID string AmountCents int64 Currency string}
// PaymentDeclined is our own error, in our own words. Checkout checks for this, never for a// vendor error.type PaymentDeclined struct{ Reason string }
func (e *PaymentDeclined) Error() string { return "declined: " + e.Reason }
// ---- Their side: a vendor SDK we can't change. ----// Dollars as a decimal string, lowercase currency, and its own result and error shapes.type PayWaveReceipt struct { PaymentRef string Status string AmountCaptured string}
type PayWaveError struct{ ErrorCode string }
func (e *PayWaveError) Error() string { return e.ErrorCode }
// PayWaveClient is a fake of the vendor's client: it prints what it received and declines// anything over $500.type PayWaveClient struct{ next int }
func NewPayWaveClient() *PayWaveClient { return &PayWaveClient{next: 1001} }
func (c *PayWaveClient) CreatePayment(amount, currencyCode string) (PayWaveReceipt, error) { fmt.Printf("PayWave <- createPayment(%q, %q)\n", amount, currencyCode) if f, _ := strconv.ParseFloat(amount, 64); f > 500 { return PayWaveReceipt{}, &PayWaveError{"PW_INSUFFICIENT"} } ref := fmt.Sprintf("pw_%d", c.next) c.next++ return PayWaveReceipt{ref, "succeeded", amount}, nil}
// PayWaveAdapter satisfies our interface and holds their client (an object adapter). Every// translation lives here: units, names, result shape and errors. Nothing else imports PayWave.type PayWaveAdapter struct{ client *PayWaveClient }
// reasons maps vendor error codes to our vocabulary. An unknown code gets a safe generic// reason instead of leaking the vendor's string into our logs and UI.var reasons = map[string]string{ "PW_INSUFFICIENT": "insufficient_funds", "PW_DO_NOT_HONOR": "card_declined", "PW_EXPIRED": "expired_card",}
func (a PayWaveAdapter) Charge(amountCents int64, currency string) (ChargeResult, error) { receipt, err := a.client.CreatePayment(toDecimal(amountCents), strings.ToLower(currency)) var pwErr *PayWaveError if errors.As(err, &pwErr) { reason, ok := reasons[pwErr.ErrorCode] if !ok { reason = "processing_error" } return ChargeResult{}, &PaymentDeclined{reason} } if err != nil { return ChargeResult{}, err } // Their result shape becomes ours: a status string to check, a text amount to parse. if receipt.Status != "succeeded" { return ChargeResult{}, &PaymentDeclined{"processing_error"} } return ChargeResult{receipt.PaymentRef, toCents(receipt.AmountCaptured), currency}, nil}
// toDecimal uses integer arithmetic only: 1250 -> "12.50", 5 -> "0.05". It assumes two// decimal places, which holds for USD and EUR but not for JPY (none) or KWD (three).func toDecimal(cents int64) string { return fmt.Sprintf("%d.%02d", cents/100, cents%100) }
// toCents turns "12.50" into 1250, parsed as text for the same reason.func toCents(decimal string) int64 { whole, frac, _ := strings.Cut(decimal, ".") w, _ := strconv.ParseInt(whole, 10, 64) f, _ := strconv.ParseInt(frac, 10, 64) return w*100 + f}
// Checkout is business code. It knows PaymentGateway and nothing about any vendor.type Checkout struct{ payments PaymentGateway }
func (c Checkout) Pay(orderID string, amountCents int64, currency string) { r, err := c.payments.Charge(amountCents, currency) var declined *PaymentDeclined if errors.As(err, &declined) { fmt.Printf("%s: declined (%s)\n", orderID, declined.Reason) return } fmt.Printf("%s: paid %d %s as %s\n", orderID, r.AmountCents, r.Currency, r.ChargeID)}
func main() { // The only line that names the vendor. Switching vendors changes this line and adds // one adapter; Checkout is untouched. checkout := Checkout{PayWaveAdapter{NewPayWaveClient()}}
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)}// ---- Our side: the interface checkout was written against. ----// Money is integer cents (never floating point) and currency is an ISO code like "USD".interface ChargeResult { chargeId: string; amountCents: number; currency: string;}
interface PaymentGateway { charge(amountCents: number, currency: string): ChargeResult;}
// Our own error, in our own words. Checkout catches this, never a vendor exception.class PaymentDeclined extends Error { constructor(readonly reason: string) { super(`declined: ${reason}`); }}
// ---- Their side: a vendor SDK we can't change. ----// Dollars as a decimal string, lowercase currency, and its own result and error shapes.interface PayWaveReceipt { payment_ref: string; status: string; amount_captured: string;}
class PayWaveError extends Error { constructor(readonly errorCode: string) { super(errorCode); }}
// A fake of the vendor's client: it prints what it received and declines anything over $500.class PayWaveClient { private next = 1001;
createPayment(amount: string, currencyCode: string): PayWaveReceipt { console.log(`PayWave <- createPayment("${amount}", "${currencyCode}")`); if (Number(amount) > 500) throw new PayWaveError("PW_INSUFFICIENT"); return { payment_ref: `pw_${this.next++}`, status: "succeeded", amount_captured: amount }; }}
// 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.const REASONS: Record<string, string> = { PW_INSUFFICIENT: "insufficient_funds", PW_DO_NOT_HONOR: "card_declined", PW_EXPIRED: "expired_card",};
// 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).function toDecimal(cents: number): string { return `${Math.floor(cents / 100)}.${String(cents % 100).padStart(2, "0")}`;}
// "12.50" -> 1250, parsed as text for the same reason.function toCents(decimal: string): number { const [whole, frac] = decimal.split("."); return Number(whole) * 100 + Number(frac);}
// 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 { constructor(private readonly client: PayWaveClient) {}
charge(amountCents: number, currency: string): ChargeResult { let receipt: PayWaveReceipt; try { receipt = this.client.createPayment(toDecimal(amountCents), currency.toLowerCase()); } catch (e) { if (e instanceof PayWaveError) throw new PaymentDeclined(REASONS[e.errorCode] ?? "processing_error"); throw e; } // Their result shape becomes ours: a status string to check, a text amount to parse. if (receipt.status !== "succeeded") throw new PaymentDeclined("processing_error"); return { chargeId: receipt.payment_ref, amountCents: toCents(receipt.amount_captured), currency }; }}
// Business code. It knows PaymentGateway and nothing about any vendor.class Checkout { constructor(private readonly payments: PaymentGateway) {}
pay(orderId: string, amountCents: number, currency: string): void { try { const r = this.payments.charge(amountCents, currency); console.log(`${orderId}: paid ${r.amountCents} ${r.currency} as ${r.chargeId}`); } catch (e) { if (!(e instanceof PaymentDeclined)) throw e; console.log(`${orderId}: declined (${e.reason})`); } }}
// The only line that names the vendor. Switching vendors changes this line and adds// one adapter; Checkout is untouched.const 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)What to notice
Section titled “What to notice”- Your interface comes first.
PaymentGatewaywas 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_reftochargeId, a text amount back to cents), and errors (PayWaveExceptiontoPaymentDeclined). 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 fakePaymentGatewayand never loads the SDK.
Object adapter or class adapter
Section titled “Object adapter or class adapter”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:
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 }}from abc import ABC, abstractmethodfrom dataclasses import dataclass
@dataclass(frozen=True)class ChargeResult: charge_id: str amount_cents: int currency: str
class PaymentGateway(ABC): @abstractmethod def charge(self, amount_cents: int, currency: str) -> ChargeResult: ...
# The vendor's client, trimmed to what this example needs.class PayWaveClient: def __init__(self) -> None: self._next = 1001
def create_payment(self, amount: str, currency_code: str) -> str: print(f'PayWave <- createPayment("{amount}", "{currency_code}")') ref = f"pw_{self._next}" self._next += 1 return ref
# 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(PayWaveClient, PaymentGateway): def charge(self, amount_cents: int, currency: str) -> ChargeResult: amount = f"{amount_cents // 100}.{amount_cents % 100:02d}" # Calls the inherited method directly. It could also override vendor methods, which # an object adapter can't. ref = self.create_payment(amount, currency.lower()) return ChargeResult(ref, amount_cents, currency)
gateway: PaymentGateway = PayWaveClassAdapter()r = gateway.charge(1250, "USD")# PayWave <- createPayment("12.50", "usd")print(f"paid {r.amount_cents} {r.currency} as {r.charge_id}")# 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.concrete = PayWaveClassAdapter()ref = concrete.create_payment("1250", "USD")# PayWave <- createPayment("1250", "USD")print(f"bypassed the adapter: {ref}")# bypassed the adapter: pw_1001using System;
public interface IPaymentGateway{ ChargeResult Charge(long amountCents, string currency);}
public sealed record ChargeResult(string ChargeId, long AmountCents, string Currency);
// The vendor's client, trimmed to what this example needs. It must not be sealed, or a// class adapter can't derive from it.public class PayWaveClient{ private int _next = 1001;
public string CreatePayment(string amount, string currencyCode) { Console.WriteLine($"PayWave <- createPayment(\"{amount}\", \"{currencyCode}\")"); return $"pw_{_next++}"; }}
// Class adapter: it IS a PayWaveClient (by inheritance) and IS an IPaymentGateway. There is// no field to hold and no object to pass in, so there's one object instead of two.public sealed class PayWaveClassAdapter : PayWaveClient, IPaymentGateway{ public ChargeResult Charge(long amountCents, string currency) { var amount = $"{amountCents / 100}.{amountCents % 100:D2}"; // Calls the inherited method directly. It could also override virtual vendor // methods, which an object adapter can't. var reference = CreatePayment(amount, currency.ToLowerInvariant()); return new ChargeResult(reference, amountCents, currency); }}
public static class Program{ public static void Main() { IPaymentGateway gateway = new PayWaveClassAdapter(); var r = gateway.Charge(1250, "USD"); // PayWave <- createPayment("12.50", "usd") Console.WriteLine($"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. var concrete = new PayWaveClassAdapter(); var reference = concrete.CreatePayment("1250", "USD"); // PayWave <- createPayment("1250", "USD") Console.WriteLine($"bypassed the adapter: {reference}"); // bypassed the adapter: pw_1001 }}package main
import ( "fmt" "strings")
type PaymentGateway interface { Charge(amountCents int64, currency string) ChargeResult}
type ChargeResult struct { ChargeID string AmountCents int64 Currency string}
// PayWaveClient is the vendor's client, trimmed to what this example needs.type PayWaveClient struct{ next int }
func (c *PayWaveClient) CreatePayment(amount, currencyCode string) string { fmt.Printf("PayWave <- createPayment(%q, %q)\n", amount, currencyCode) ref := fmt.Sprintf("pw_%d", c.next) c.next++ return ref}
// PayWaveClassAdapter is Go's closest thing to a class adapter. Go has no inheritance:// embedding is composition, but the embedded type's methods are promoted, so the adapter// can be used as if it were a PayWaveClient too.type PayWaveClassAdapter struct { *PayWaveClient}
func (a PayWaveClassAdapter) Charge(amountCents int64, currency string) ChargeResult { amount := fmt.Sprintf("%d.%02d", amountCents/100, amountCents%100) // Calls the promoted method directly, as if it were the adapter's own. ref := a.CreatePayment(amount, strings.ToLower(currency)) return ChargeResult{ref, amountCents, currency}}
func main() { var gateway PaymentGateway = PayWaveClassAdapter{&PayWaveClient{next: 1001}} r := gateway.Charge(1250, "USD") // PayWave <- createPayment("12.50", "usd") fmt.Printf("paid %d %s as %s\n", r.AmountCents, r.Currency, r.ChargeID) // paid 1250 USD as pw_1001
// The catch: the vendor's methods are promoted onto the adapter too. Code that holds the // concrete type can skip every translation, and nothing stops it. concrete := PayWaveClassAdapter{&PayWaveClient{next: 1001}} ref := concrete.CreatePayment("1250", "USD") // PayWave <- createPayment("1250", "USD") fmt.Printf("bypassed the adapter: %s\n", ref) // bypassed the adapter: pw_1001}interface ChargeResult { chargeId: string; amountCents: number; currency: string;}
interface PaymentGateway { charge(amountCents: number, currency: string): ChargeResult;}
// The vendor's client, trimmed to what this example needs.class PayWaveClient { private next = 1001;
createPayment(amount: string, currencyCode: string): string { console.log(`PayWave <- createPayment("${amount}", "${currencyCode}")`); return `pw_${this.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 { charge(amountCents: number, currency: string): ChargeResult { const amount = `${Math.floor(amountCents / 100)}.${String(amountCents % 100).padStart(2, "0")}`; // Calls the inherited method directly. It could also override vendor methods, which // an object adapter can't. const ref = this.createPayment(amount, currency.toLowerCase()); return { chargeId: ref, amountCents, currency }; }}
const gateway: PaymentGateway = new PayWaveClassAdapter();const r = gateway.charge(1250, "USD");// PayWave <- createPayment("12.50", "usd")console.log(`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.const concrete = new PayWaveClassAdapter();const ref = concrete.createPayment("1250", "USD");// PayWave <- createPayment("1250", "USD")console.log(`bypassed the adapter: ${ref}`);// bypassed the adapter: pw_1001We’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?
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).
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.
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.
Without the pattern
Section titled “Without the pattern”Each caller runs the sequence itself:
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.) }}class StepFailed(Exception): pass
# The same subsystems as before, trimmed. Shipping is set to fail.class Inventory: def reserve(self, sku: str, qty: int) -> str: print(f"inventory: reserved res-1 ({sku} x{qty})") return "res-1"
def release(self, rid: str) -> None: print(f"inventory: released {rid}")
class Payments: def charge(self, cents: int) -> str: print(f"payments: charged ch-1 {cents} cents") return "ch-1"
def refund(self, cid: str) -> None: print(f"payments: refunded {cid}")
class Shipping: def create_label(self, order_id: str) -> str: print("shipping: carrier unavailable") raise 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: def __init__(self) -> None: self._inventory = Inventory() self._payments = Payments() self._shipping = Shipping()
def place_order(self, order_id: str) -> str: reservation = self._inventory.reserve("SKU-42", 2) try: self._payments.charge(4998) except StepFailed as e: self._inventory.release(reservation) return f"{order_id}: failed, {e}" try: self._shipping.create_label(order_id) except StepFailed as e: # Written when there was no shipping step; the refund was never added here. self._inventory.release(reservation) return f"{order_id}: failed, {e}" return f"{order_id}: placed"
print(WebOrderController().place_order("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.)using System;
public sealed class StepFailed : Exception{ public StepFailed(string message) : base(message) { }}
// The same subsystems as before, trimmed. Shipping is set to fail.public sealed class Inventory{ public string Reserve(string sku, int qty) { Console.WriteLine($"inventory: reserved res-1 ({sku} x{qty})"); return "res-1"; }
public void Release(string id) => Console.WriteLine($"inventory: released {id}");}
public sealed class Payments{ public string Charge(long cents) { Console.WriteLine($"payments: charged ch-1 {cents} cents"); return "ch-1"; }
public void Refund(string id) => Console.WriteLine($"payments: refunded {id}");}
public sealed class Shipping{ public string CreateLabel(string orderId) { Console.WriteLine("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.public sealed class WebOrderController{ private readonly Inventory _inventory = new(); private readonly Payments _payments = new(); private readonly Shipping _shipping = new();
public string PlaceOrder(string orderId) { var reservation = _inventory.Reserve("SKU-42", 2); try { _payments.Charge(4998); } catch (StepFailed e) { _inventory.Release(reservation); return $"{orderId}: failed, {e.Message}"; } 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.Message}"; } return $"{orderId}: placed"; }}
public static class Program{ public static void Main() { Console.WriteLine(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.) }}package main
import ( "errors" "fmt")
// The same subsystems as before, trimmed. Shipping is set to fail.type Inventory struct{}
func (Inventory) Reserve(sku string, qty int) string { fmt.Printf("inventory: reserved res-1 (%s x%d)\n", sku, qty) return "res-1"}
func (Inventory) Release(id string) { fmt.Printf("inventory: released %s\n", id) }
type Payments struct{}
func (Payments) Charge(cents int64) (string, error) { fmt.Printf("payments: charged ch-1 %d cents\n", cents) return "ch-1", nil}
func (Payments) Refund(id string) { fmt.Printf("payments: refunded %s\n", id) }
type Shipping struct{}
func (Shipping) CreateLabel(orderID string) (string, error) { fmt.Println("shipping: carrier unavailable") return "", errors.New("carrier unavailable")}
// WebOrderController 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.type WebOrderController struct { inventory Inventory payments Payments shipping Shipping}
func (c WebOrderController) PlaceOrder(orderID string) string { reservation := c.inventory.Reserve("SKU-42", 2) if _, err := c.payments.Charge(4998); err != nil { c.inventory.Release(reservation) return fmt.Sprintf("%s: failed, %v", orderID, err) } if _, err := c.shipping.CreateLabel(orderID); err != nil { // Written when there was no shipping step; the refund was never added here. c.inventory.Release(reservation) return fmt.Sprintf("%s: failed, %v", orderID, err) } return orderID + ": placed"}
func main() { fmt.Println(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.)}class StepFailed extends Error {}
// The same subsystems as before, trimmed. Shipping is set to fail.class Inventory { reserve(sku: string, qty: number): string { console.log(`inventory: reserved res-1 (${sku} x${qty})`); return "res-1"; } release(id: string): void { console.log(`inventory: released ${id}`); }}
class Payments { charge(cents: number): string { console.log(`payments: charged ch-1 ${cents} cents`); return "ch-1"; } refund(id: string): void { console.log(`payments: refunded ${id}`); }}
class Shipping { createLabel(orderId: string): string { console.log("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 readonly inventory = new Inventory(); private readonly payments = new Payments(); private readonly shipping = new Shipping();
placeOrder(orderId: string): string { const reservation = this.inventory.reserve("SKU-42", 2); try { this.payments.charge(4998); } catch (e) { this.inventory.release(reservation); return `${orderId}: failed, ${(e as Error).message}`; } try { this.shipping.createLabel(orderId); } catch (e) { // Written when there was no shipping step; the refund was never added here. this.inventory.release(reservation); return `${orderId}: failed, ${(e as Error).message}`; } return `${orderId}: placed`; }}
console.log(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.
With a facade
Section titled “With a facade”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:
- 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:
checkout.placeOrder(cart)- Inventory
reserve(SKU-42, 2)waitingfails with: out of stock - Payments
charge(4998)waitingfails with: card declined - Shipping
createLabel(...)waitingfails with: carrier unavailable - Email
sendConfirmation(...)waitingfails with: smtp timeout
What happened, in order
Pick a subsystem to fail (or none), then place an order.
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 }}from dataclasses import dataclassfrom typing import Callable
# One failure type for every subsystem keeps the facade's error handling in one place.class StepFailed(Exception): pass
# ---- Four subsystems, each with its own API. ----# Each fake prints what it did. fail_next makes its next call fail, so the demo can break any step.class Subsystem: def __init__(self, name: str) -> None: self.name = name self.fail_next: str | None = None
def _maybe_fail(self) -> None: if self.fail_next is not None: why, self.fail_next = self.fail_next, None print(f"{self.name}: {why}") raise StepFailed(why)
class Inventory(Subsystem): def __init__(self) -> None: super().__init__("inventory") self._next = 1
def reserve(self, sku: str, qty: int) -> str: self._maybe_fail() rid = f"res-{self._next}" self._next += 1 print(f"inventory: reserved {rid} ({sku} x{qty})") return rid
def release(self, reservation_id: str) -> None: print(f"inventory: released {reservation_id}")
class Payments(Subsystem): def __init__(self) -> None: super().__init__("payments") self._next = 1
def charge(self, cents: int) -> str: self._maybe_fail() cid = f"ch-{self._next}" self._next += 1 print(f"payments: charged {cid} {cents} cents") return cid
def refund(self, charge_id: str) -> None: print(f"payments: refunded {charge_id}")
class Shipping(Subsystem): def __init__(self) -> None: super().__init__("shipping") self._next = 1
def create_label(self, order_id: str, address: str) -> str: self._maybe_fail() tracking = f"TRK-{self._next}" self._next += 1 print(f"shipping: label {tracking} for {order_id}") return tracking
class Email(Subsystem): def __init__(self) -> None: super().__init__("email")
def send_confirmation(self, to: str, order_id: str, tracking: str) -> None: self._maybe_fail() print(f"email: confirmation for {order_id} sent to {to}")
@dataclass(frozen=True)class Cart: order_id: str sku: str qty: int total_cents: int email: str address: str
@dataclass(frozen=True)class OrderResult: order_id: str placed: bool detail: str
# 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: def __init__(self, inventory: Inventory, payments: Payments, shipping: Shipping, email: Email) -> None: self._inventory = inventory self._payments = payments self._shipping = shipping self._email = email self.email_retries: list[str] = []
def place_order(self, cart: Cart) -> OrderResult: # 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. undo: list[Callable[[], None]] = [] try: reservation = self._inventory.reserve(cart.sku, cart.qty) undo.append(lambda: self._inventory.release(reservation))
charge = self._payments.charge(cart.total_cents) undo.append(lambda: self._payments.refund(charge))
tracking = self._shipping.create_label(cart.order_id, 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: self._email.send_confirmation(cart.email, cart.order_id, tracking) except StepFailed: self.email_retries.append(cart.order_id) print(f"checkout: confirmation for {cart.order_id} queued for retry") return OrderResult(cart.order_id, True, f"tracking {tracking}") except StepFailed as e: for step in reversed(undo): step() return OrderResult(cart.order_id, False, str(e))
def show(r: OrderResult) -> None: print(f"{r.order_id}: {'placed' if r.placed else 'failed'}, {r.detail}")
def cart(order_id: str) -> Cart: return Cart(order_id, "SKU-42", 2, 4998, "ana@example.com", "1 Main St")
inventory, payments, shipping, email = Inventory(), Payments(), Shipping(), Email()checkout = CheckoutFacade(inventory, payments, shipping, email)
show(checkout.place_order(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.fail_next = "card declined"show(checkout.place_order(cart("A-2")))# inventory: reserved res-2 (SKU-42 x2)# payments: card declined# inventory: released res-2# A-2: failed, card declined
shipping.fail_next = "carrier unavailable"show(checkout.place_order(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.fail_next = "smtp timeout"show(checkout.place_order(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-2using System;using System.Collections.Generic;
// One failure type for every subsystem keeps the facade's error handling in one place.public sealed class StepFailed : Exception{ public StepFailed(string message) : base(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.public abstract class Subsystem{ private readonly string _name; public string? FailNext { get; set; } protected Subsystem(string name) => _name = name;
protected void MaybeFail() { if (FailNext is null) return; var why = FailNext; FailNext = null; Console.WriteLine($"{_name}: {why}"); throw new StepFailed(why); }}
public sealed class Inventory : Subsystem{ private int _next = 1; public Inventory() : base("inventory") { }
public string Reserve(string sku, int qty) { MaybeFail(); var id = $"res-{_next++}"; Console.WriteLine($"inventory: reserved {id} ({sku} x{qty})"); return id; }
public void Release(string reservationId) => Console.WriteLine($"inventory: released {reservationId}");}
public sealed class Payments : Subsystem{ private int _next = 1; public Payments() : base("payments") { }
public string Charge(long cents) { MaybeFail(); var id = $"ch-{_next++}"; Console.WriteLine($"payments: charged {id} {cents} cents"); return id; }
public void Refund(string chargeId) => Console.WriteLine($"payments: refunded {chargeId}");}
public sealed class Shipping : Subsystem{ private int _next = 1; public Shipping() : base("shipping") { }
public string CreateLabel(string orderId, string address) { MaybeFail(); var tracking = $"TRK-{_next++}"; Console.WriteLine($"shipping: label {tracking} for {orderId}"); return tracking; }}
public sealed class Email : Subsystem{ public Email() : base("email") { }
public void SendConfirmation(string to, string orderId, string tracking) { MaybeFail(); Console.WriteLine($"email: confirmation for {orderId} sent to {to}"); }}
public sealed record Cart(string OrderId, string Sku, int Qty, long TotalCents, string Email, string Address);
public sealed record OrderResult(string OrderId, bool 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.public sealed class CheckoutFacade{ private readonly Inventory _inventory; private readonly Payments _payments; private readonly Shipping _shipping; private readonly Email _email; public List<string> EmailRetries { get; } = new();
public CheckoutFacade(Inventory inventory, Payments payments, Shipping shipping, Email email) { _inventory = inventory; _payments = payments; _shipping = shipping; _email = email; }
public 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. var undo = new Stack<Action>(); try { var reservation = _inventory.Reserve(cart.Sku, cart.Qty); undo.Push(() => _inventory.Release(reservation));
var charge = _payments.Charge(cart.TotalCents); undo.Push(() => _payments.Refund(charge));
var 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) { EmailRetries.Add(cart.OrderId); Console.WriteLine($"checkout: confirmation for {cart.OrderId} queued for retry"); } return new OrderResult(cart.OrderId, true, $"tracking {tracking}"); } catch (StepFailed e) { while (undo.Count > 0) undo.Pop()(); return new OrderResult(cart.OrderId, false, e.Message); } }}
public static class Program{ static void Show(OrderResult r) => Console.WriteLine($"{r.OrderId}: {(r.Placed ? "placed" : "failed")}, {r.Detail}");
static Cart NewCart(string orderId) => new(orderId, "SKU-42", 2, 4998, "ana@example.com", "1 Main St");
public static void Main() { var inventory = new Inventory(); var payments = new Payments(); var shipping = new Shipping(); var email = new Email(); var checkout = new CheckoutFacade(inventory, payments, shipping, email);
Show(checkout.PlaceOrder(NewCart("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(NewCart("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(NewCart("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(NewCart("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 }}package main
import ( "errors" "fmt")
// StepFailed is one failure type for every subsystem, which keeps the facade's error// handling in one place.type StepFailed struct{ Reason string }
func (e *StepFailed) Error() string { return e.Reason }
// failer is shared by the fakes. FailNext makes the next call fail, so the demo can break// any step.type failer struct { name string FailNext string}
func (f *failer) maybeFail() error { if f.FailNext == "" { return nil } why := f.FailNext f.FailNext = "" fmt.Printf("%s: %s\n", f.name, why) return &StepFailed{why}}
// ---- Four subsystems, each with its own API. ----
type Inventory struct { failer next int}
func (i *Inventory) Reserve(sku string, qty int) (string, error) { if err := i.maybeFail(); err != nil { return "", err } i.next++ id := fmt.Sprintf("res-%d", i.next) fmt.Printf("inventory: reserved %s (%s x%d)\n", id, sku, qty) return id, nil}
func (i *Inventory) Release(reservationID string) { fmt.Printf("inventory: released %s\n", reservationID)}
type Payments struct { failer next int}
func (p *Payments) Charge(cents int64) (string, error) { if err := p.maybeFail(); err != nil { return "", err } p.next++ id := fmt.Sprintf("ch-%d", p.next) fmt.Printf("payments: charged %s %d cents\n", id, cents) return id, nil}
func (p *Payments) Refund(chargeID string) { fmt.Printf("payments: refunded %s\n", chargeID) }
type Shipping struct { failer next int}
func (s *Shipping) CreateLabel(orderID, address string) (string, error) { if err := s.maybeFail(); err != nil { return "", err } s.next++ tracking := fmt.Sprintf("TRK-%d", s.next) fmt.Printf("shipping: label %s for %s\n", tracking, orderID) return tracking, nil}
type Email struct{ failer }
func (e *Email) SendConfirmation(to, orderID, tracking string) error { if err := e.maybeFail(); err != nil { return err } fmt.Printf("email: confirmation for %s sent to %s\n", orderID, to) return nil}
type Cart struct { OrderID string SKU string Qty int TotalCents int64 Email string Address string}
type OrderResult struct { OrderID string Placed bool Detail string}
// CheckoutFacade is 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.type CheckoutFacade struct { inventory *Inventory payments *Payments shipping *Shipping email *Email EmailRetries []string}
func (c *CheckoutFacade) PlaceOrder(cart Cart) OrderResult { // Each step that succeeds appends 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. var undo []func() fail := func(err error) OrderResult { for i := len(undo) - 1; i >= 0; i-- { undo[i]() } var sf *StepFailed errors.As(err, &sf) return OrderResult{cart.OrderID, false, sf.Reason} }
reservation, err := c.inventory.Reserve(cart.SKU, cart.Qty) if err != nil { return fail(err) } undo = append(undo, func() { c.inventory.Release(reservation) })
charge, err := c.payments.Charge(cart.TotalCents) if err != nil { return fail(err) } undo = append(undo, func() { c.payments.Refund(charge) })
tracking, err := c.shipping.CreateLabel(cart.OrderID, cart.Address) if err != nil { return fail(err) }
// 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. if err := c.email.SendConfirmation(cart.Email, cart.OrderID, tracking); err != nil { c.EmailRetries = append(c.EmailRetries, cart.OrderID) fmt.Printf("checkout: confirmation for %s queued for retry\n", cart.OrderID) } return OrderResult{cart.OrderID, true, "tracking " + tracking}}
func show(r OrderResult) { status := "failed" if r.Placed { status = "placed" } fmt.Printf("%s: %s, %s\n", r.OrderID, status, r.Detail)}
func cart(orderID string) Cart { return Cart{orderID, "SKU-42", 2, 4998, "ana@example.com", "1 Main St"}}
func main() { inventory := &Inventory{failer: failer{name: "inventory"}} payments := &Payments{failer: failer{name: "payments"}} shipping := &Shipping{failer: failer{name: "shipping"}} email := &Email{failer{name: "email"}} checkout := &CheckoutFacade{inventory: inventory, payments: payments, shipping: shipping, email: 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}// One failure type for every subsystem keeps the facade's error handling in one place.class StepFailed extends Error {}
// ---- 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.abstract class Subsystem { failNext: string | null = null; constructor(private readonly name: string) {}
protected maybeFail(): void { if (this.failNext === null) return; const why = this.failNext; this.failNext = null; console.log(`${this.name}: ${why}`); throw new StepFailed(why); }}
class Inventory extends Subsystem { private next = 1; constructor() { super("inventory"); }
reserve(sku: string, qty: number): string { this.maybeFail(); const id = `res-${this.next++}`; console.log(`inventory: reserved ${id} (${sku} x${qty})`); return id; }
release(reservationId: string): void { console.log(`inventory: released ${reservationId}`); }}
class Payments extends Subsystem { private next = 1; constructor() { super("payments"); }
charge(cents: number): string { this.maybeFail(); const id = `ch-${this.next++}`; console.log(`payments: charged ${id} ${cents} cents`); return id; }
refund(chargeId: string): void { console.log(`payments: refunded ${chargeId}`); }}
class Shipping extends Subsystem { private next = 1; constructor() { super("shipping"); }
createLabel(orderId: string, address: string): string { this.maybeFail(); const tracking = `TRK-${this.next++}`; console.log(`shipping: label ${tracking} for ${orderId}`); return tracking; }}
class Email extends Subsystem { constructor() { super("email"); }
sendConfirmation(to: string, orderId: string, tracking: string): void { this.maybeFail(); console.log(`email: confirmation for ${orderId} sent to ${to}`); }}
interface Cart { orderId: string; sku: string; qty: number; totalCents: number; email: string; address: string;}
interface OrderResult { orderId: string; placed: boolean; detail: string;}
// 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 { readonly emailRetries: string[] = [];
constructor( private readonly inventory: Inventory, private readonly payments: Payments, private readonly shipping: Shipping, private readonly email: Email, ) {}
placeOrder(cart: Cart): OrderResult { // 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. const undo: (() => void)[] = []; try { const reservation = this.inventory.reserve(cart.sku, cart.qty); undo.push(() => this.inventory.release(reservation));
const charge = this.payments.charge(cart.totalCents); undo.push(() => this.payments.refund(charge));
const tracking = this.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 { this.email.sendConfirmation(cart.email, cart.orderId, tracking); } catch (e) { if (!(e instanceof StepFailed)) throw e; this.emailRetries.push(cart.orderId); console.log(`checkout: confirmation for ${cart.orderId} queued for retry`); } return { orderId: cart.orderId, placed: true, detail: `tracking ${tracking}` }; } catch (e) { if (!(e instanceof StepFailed)) throw e; while (undo.length > 0) undo.pop()!(); return { orderId: cart.orderId, placed: false, detail: e.message }; } }}
function show(r: OrderResult): void { console.log(`${r.orderId}: ${r.placed ? "placed" : "failed"}, ${r.detail}`);}
function cart(orderId: string): Cart { return { orderId, sku: "SKU-42", qty: 2, totalCents: 4998, email: "ana@example.com", address: "1 Main St" };}
const inventory = new Inventory();const payments = new Payments();const shipping = new Shipping();const email = new Email();const 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-2What to notice
Section titled “What to notice”- 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
Inventorydirectly. 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.
When a facade becomes a god object
Section titled “When a facade becomes a god object”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.
Facade or service layer
Section titled “Facade or service layer”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”.
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?
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.
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.
An anti-corruption layer
Section titled “An anti-corruption layer”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.
- 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.
Raw responses in, domain events out
Section titled “Raw responses in, domain events out”Choose how the carrier behaves and feed its responses to the v1 adapter one at a time:
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.
- Color key
- Implementation
- Entity
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 }}import refrom dataclasses import dataclassfrom enum import Enumfrom typing import Callable, Protocol, TypeVar
T = TypeVar("T")Log = Callable[[str], None]
# ===== Our domain: the port, and the language the rest of the system speaks. =====
class EventKind(Enum): PICKED_UP = "PICKED_UP" IN_TRANSIT = "IN_TRANSIT" OUT_FOR_DELIVERY = "OUT_FOR_DELIVERY" DELIVERY_FAILED = "DELIVERY_FAILED" DELIVERED = "DELIVERED" UNRECOGNIZED = "UNRECOGNIZED"
# One fact about a shipment, in our words. raw_code is kept only so an UNRECOGNIZED event can# be investigated; domain code never branches on it.@dataclass(frozen=True)class ShipmentEvent: kind: EventKind at: str location: str raw_code: str
def __str__(self) -> str: k = f"UNRECOGNIZED({self.raw_code})" if self.kind is EventKind.UNRECOGNIZED else self.kind.value return f"{k} {self.location} {self.at}"
# Errors in our terms. Callers decide from `retryable`, never from a vendor's status code.class CarrierError(Exception): def __init__(self, kind: str, retryable: bool) -> None: super().__init__(kind) self.kind = kind self.retryable = retryable
# The port. Order tracking and customer emails depend on this and nothing else.class ShipmentTracker(Protocol): def track(self, tracking_no: str) -> list[ShipmentEvent]: ...
# ===== 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(Exception): def __init__(self, status: int, retry_after_seconds: int) -> None: self.status = status self.retry_after_seconds = retry_after_seconds
# Injected so retries can be tested without waiting. Nothing here really sleeps.class FakeSleeper: def __init__(self) -> None: self.total_seconds = 0
def sleep(self, seconds: int) -> None: self.total_seconds += seconds
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.def classify(status: int) -> CarrierError: if status == 429: return CarrierError("rate_limited", True) if status >= 500: return CarrierError("unavailable", True) if status == 404: return CarrierError("not_found", False) return CarrierError(f"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.def with_retries(what: str, call: Callable[[], T], sleeper: FakeSleeper, log: Log) -> T: attempt = 1 while True: try: return call() except VendorFailure as f: error = classify(f.status) if not error.retryable or attempt == MAX_ATTEMPTS: raise error from None wait = f.retry_after_seconds if f.retry_after_seconds > 0 else attempt log(f"{what}: {f.status}, retry in {wait} s") sleeper.sleep(wait) attempt += 1
# 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.def normalize(events: list[ShipmentEvent]) -> list[ShipmentEvent]: out: list[ShipmentEvent] = [] for e in sorted(events, key=lambda e: e.at): last = out[-1] if out else None same_hub = ( last is not None and last.kind is EventKind.IN_TRANSIT and e.kind is EventKind.IN_TRANSIT and last.location == e.location ) if not same_hub: out.append(e) return out
# ===== Vendor v1: XML-ish text over HTTP, paged, rate limited. =====
# 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: def __init__(self) -> None: self._script: dict[str, list[str]] = {}
def on(self, path: str, *responses: str) -> "ScriptedTransport": self._script[path] = list(responses) return self
def get(self, path: str) -> str: q = self._script.get(path) if q is None: return '<error code="404"/>' return q.pop(0) if len(q) > 1 else q[0]
ATTR = re.compile(r'([\w-]+)="([^"]*)"')
def attrs(line: str) -> dict[str, str]: return dict(ATTR.findall(line))
class SwiftlineV1Adapter: # Their codes in our vocabulary. Two vendor codes can mean one domain fact. CODES = { "PU": EventKind.PICKED_UP, "AF": EventKind.IN_TRANSIT, "DF": EventKind.IN_TRANSIT, "OD": EventKind.OUT_FOR_DELIVERY, "NA": EventKind.DELIVERY_FAILED, "DL": EventKind.DELIVERED, }
def __init__(self, transport: ScriptedTransport, sleeper: FakeSleeper, log: Log) -> None: self._transport = transport self._sleeper = sleeper self._log = log
def track(self, tracking_no: str) -> list[ShipmentEvent]: events: list[ShipmentEvent] = [] # Pagination is the vendor's concern. The domain gets one complete list. page: str | None = "1" while page is not None: path = f"/track?n={tracking_no}&page={page}" body = with_retries(f"{tracking_no} page {page}", lambda: self._fetch(path), self._sleeper, self._log) page = None for line in body.split("\n"): a = attrs(line) if line.startswith("<more"): page = a["page"] if line.startswith("<scan"): events.append(self._to_event(a)) return normalize(events)
def _fetch(self, path: str) -> str: body = self._transport.get(path) if not body.startswith("<error"): return body a = attrs(body) raise VendorFailure(int(a["code"]), int(a.get("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. def _to_event(self, a: dict[str, str]) -> ShipmentEvent: code = a["code"] kind = self.CODES.get(code, EventKind.UNRECOGNIZED) if kind is EventKind.UNRECOGNIZED: self._log(f"unknown status code {code}, kept as UNRECOGNIZED") return ShipmentEvent(kind, iso_time(a["ts"]), a["loc"], code)
# "202610240910" -> "2026-10-24T09:10". The vendor's format stops at this boundary.def iso_time(ts: str) -> str: return f"{ts[0:4]}-{ts[4:6]}-{ts[6:8]}T{ts[8:10]}:{ts[10:12]}"
# ===== Vendor v2: a typed SDK with different names, no paging, its own error type. =====
@dataclass(frozen=True)class V2Event: status: str occurred_at: str facility: str
class V2Error(Exception): def __init__(self, http_status: int, retry_after: int) -> None: self.http_status = http_status self.retry_after = retry_after
class SwiftlineV2Client(Protocol): def events(self, tracking_no: str) -> list[V2Event]: ...
class SwiftlineV2Adapter: CODES = { "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, }
def __init__(self, client: SwiftlineV2Client, sleeper: FakeSleeper, log: Log) -> None: self._client = client self._sleeper = sleeper self._log = log
def track(self, tracking_no: str) -> list[ShipmentEvent]: def call() -> list[V2Event]: try: return self._client.events(tracking_no) except V2Error as e: raise VendorFailure(e.http_status, e.retry_after) from None
raw = with_retries(tracking_no, call, self._sleeper, self._log) events = [] for e in raw: kind = self.CODES.get(e.status, EventKind.UNRECOGNIZED) if kind is EventKind.UNRECOGNIZED: self._log(f"unknown status {e.status}, kept as UNRECOGNIZED") # "2026-10-24T09:10:00Z" -> "2026-10-24T09:10", the same shape v1 produces. events.append(ShipmentEvent(kind, e.occurred_at[:16], e.facility, e.status)) return normalize(events)
# ===== A fake for tests elsewhere in the system. It must behave like the real adapters. =====
class InMemoryTracker: def __init__(self, shipments: dict[str, list[ShipmentEvent]]) -> None: self._shipments = shipments
def track(self, tracking_no: str) -> list[ShipmentEvent]: if tracking_no not in self._shipments: raise CarrierError("not_found", False) return self._shipments[tracking_no]
# 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.def contract(name: str, tracker: ShipmentTracker) -> None: passed = 0 events = tracker.track("SW-DONE") if events and events[-1].kind is EventKind.DELIVERED: passed += 1 if all(events[i - 1].at <= events[i].at for i in range(1, len(events))): passed += 1 try: tracker.track("SW-NONE") except CarrierError as e: if e.kind == "not_found" and not e.retryable: passed += 1 print(f"{name}: {passed}/3 passed")
def show(s: str) -> None: print(f" log: {s}")
sleeper = FakeSleeper()v1 = ( 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"/>', ))tracker: ShipmentTracker = SwiftlineV1Adapter(v1, sleeper, show)
print("== SW123 through the v1 adapter")for e in tracker.track("SW123"): print(e)print(f"waited {sleeper.total_seconds} 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
print("== errors, in our terms")for no in ["SW404", "SW503"]: try: tracker.track(no) except CarrierError as e: print(f"{no}: {e.kind}, retryable={'true' if e.retryable else 'false'}")# == 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.class ScriptedV2: data = {"SW-DONE": [ V2Event("PICKED_UP", "2026-10-20T09:00:00Z", "AUS"), V2Event("DELIVERED", "2026-10-22T11:00:00Z", "AUS"), ]}
def events(self, tracking_no: str) -> list[V2Event]: if tracking_no not in self.data: raise V2Error(404, 0) return self.data[tracking_no]
def quiet(_: str) -> None: pass
print("== one contract suite, every implementation")contract("v1 adapter", SwiftlineV1Adapter(v1, FakeSleeper(), quiet))contract("v2 adapter", SwiftlineV2Adapter(ScriptedV2(), FakeSleeper(), quiet))contract("in-memory fake", InMemoryTracker({"SW-DONE": [ ShipmentEvent(EventKind.PICKED_UP, "2026-10-20T09:00", "AUS", "PU"), 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 passedusing System;using System.Collections.Generic;using System.Linq;using System.Text.RegularExpressions;
// ===== Our domain: the port, and the language the rest of the system speaks. =====
public 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.public sealed record ShipmentEvent(EventKind Kind, string At, string Location, string RawCode){ public override string ToString() { var k = Kind == EventKind.UNRECOGNIZED ? $"UNRECOGNIZED({RawCode})" : Kind.ToString(); return $"{k} {Location} {At}"; }}
// Errors in our terms. Callers decide from Retryable, never from a vendor's status code.public sealed class CarrierError : Exception{ public string Kind { get; } public bool Retryable { get; } public CarrierError(string kind, bool retryable) : base(kind) => (Kind, Retryable) = (kind, retryable);}
// The port. Order tracking and customer emails depend on this and nothing else.public interface IShipmentTracker{ 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.public sealed class VendorFailure : Exception{ public int Status { get; } public int RetryAfterSeconds { get; } public VendorFailure(int status, int retryAfterSeconds) => (Status, RetryAfterSeconds) = (status, retryAfterSeconds);}
// Injected so retries can be tested without waiting. Nothing here really sleeps.public sealed class FakeSleeper{ public int TotalSeconds { get; private set; } public void Sleep(int seconds) => TotalSeconds += seconds;}
public static class Boundary{ public const int MaxAttempts = 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. public static CarrierError Classify(int status) => status switch { 429 => new CarrierError("rate_limited", true), >= 500 => new CarrierError("unavailable", true), 404 => new CarrierError("not_found", false), _ => 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. // (Real code is async and awaits Task.Delay; the fake sleeper keeps this one synchronous.) public static T WithRetries<T>(string what, Func<T> call, FakeSleeper sleeper, Action<string> log) { for (int attempt = 1; ; attempt++) { try { return call(); } catch (VendorFailure f) { var error = Classify(f.Status); if (!error.Retryable || attempt == MaxAttempts) throw error; int wait = f.RetryAfterSeconds > 0 ? f.RetryAfterSeconds : attempt; log($"{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. public static List<ShipmentEvent> Normalize(IEnumerable<ShipmentEvent> events) { var output = new List<ShipmentEvent>(); foreach (var e in events.OrderBy(e => e.At, StringComparer.Ordinal)) { var last = output.Count > 0 ? output[^1] : null; bool sameHub = last is not null && last.Kind == EventKind.IN_TRANSIT && e.Kind == EventKind.IN_TRANSIT && last.Location == e.Location; if (!sameHub) output.Add(e); } return output; }}
// ===== Vendor v1: XML-ish text over HTTP, paged, rate limited. =====
public interface ILegacyTransport{ 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.public sealed class ScriptedTransport : ILegacyTransport{ private readonly Dictionary<string, Queue<string>> _script = new();
public ScriptedTransport On(string path, params string[] responses) { _script[path] = new Queue<string>(responses); return this; }
public string Get(string path) { if (!_script.TryGetValue(path, out var q)) return "<error code=\"404\"/>"; return q.Count > 1 ? q.Dequeue() : q.Peek(); }}
public sealed class SwiftlineV1Adapter : IShipmentTracker{ // Their codes in our vocabulary. Two vendor codes can mean one domain fact. private static readonly Dictionary<string, EventKind> Codes = new() { ["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 readonly Regex Attr = new("([\\w-]+)=\"([^\"]*)\"");
private readonly ILegacyTransport _transport; private readonly FakeSleeper _sleeper; private readonly Action<string> _log;
public SwiftlineV1Adapter(ILegacyTransport transport, FakeSleeper sleeper, Action<string> log) => (_transport, _sleeper, _log) = (transport, sleeper, log);
public List<ShipmentEvent> Track(string trackingNo) { var events = new List<ShipmentEvent>(); // Pagination is the vendor's concern. The domain gets one complete list. string? page = "1"; while (page is not null) { var path = $"/track?n={trackingNo}&page={page}"; var body = Boundary.WithRetries($"{trackingNo} page {page}", () => Fetch(path), _sleeper, _log); page = null; foreach (var line in body.Split('\n')) { var a = Attrs(line); if (line.StartsWith("<more")) page = a["page"]; if (line.StartsWith("<scan")) events.Add(ToEvent(a)); } } return Boundary.Normalize(events); }
private string Fetch(string path) { var body = _transport.Get(path); if (!body.StartsWith("<error")) return body; var a = Attrs(body); throw new VendorFailure(int.Parse(a["code"]), int.Parse(a.GetValueOrDefault("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(Dictionary<string, string> a) { var code = a["code"]; var kind = Codes.GetValueOrDefault(code, EventKind.UNRECOGNIZED); if (kind == EventKind.UNRECOGNIZED) _log($"unknown status code {code}, kept as UNRECOGNIZED"); return new ShipmentEvent(kind, IsoTime(a["ts"]), a["loc"], code); }
// "202610240910" -> "2026-10-24T09:10". The vendor's format stops at this boundary. private static string IsoTime(string ts) => $"{ts[..4]}-{ts[4..6]}-{ts[6..8]}T{ts[8..10]}:{ts[10..12]}";
private static Dictionary<string, string> Attrs(string line) => Attr.Matches(line).ToDictionary(m => m.Groups[1].Value, m => m.Groups[2].Value);}
// ===== Vendor v2: a typed SDK with different names, no paging, its own error type. =====
public sealed record V2Event(string Status, string OccurredAt, string Facility);
public sealed class V2Error : Exception{ public int HttpStatus { get; } public int RetryAfter { get; } public V2Error(int httpStatus, int retryAfter) => (HttpStatus, RetryAfter) = (httpStatus, retryAfter);}
public interface ISwiftlineV2Client{ List<V2Event> Events(string trackingNo);}
public sealed class SwiftlineV2Adapter : IShipmentTracker{ private static readonly Dictionary<string, EventKind> Codes = new() { ["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 readonly ISwiftlineV2Client _client; private readonly FakeSleeper _sleeper; private readonly Action<string> _log;
public SwiftlineV2Adapter(ISwiftlineV2Client client, FakeSleeper sleeper, Action<string> log) => (_client, _sleeper, _log) = (client, sleeper, log);
public List<ShipmentEvent> Track(string trackingNo) { var raw = Boundary.WithRetries(trackingNo, () => { try { return _client.Events(trackingNo); } catch (V2Error e) { throw new VendorFailure(e.HttpStatus, e.RetryAfter); } }, _sleeper, _log); var events = raw.Select(e => { var kind = Codes.GetValueOrDefault(e.Status, EventKind.UNRECOGNIZED); if (kind == EventKind.UNRECOGNIZED) _log($"unknown status {e.Status}, kept as UNRECOGNIZED"); // "2026-10-24T09:10:00Z" -> "2026-10-24T09:10", the same shape v1 produces. return new ShipmentEvent(kind, e.OccurredAt[..16], e.Facility, e.Status); }).ToList(); return Boundary.Normalize(events); }}
// ===== A fake for tests elsewhere in the system. It must behave like the real adapters. =====
public sealed class InMemoryTracker : IShipmentTracker{ private readonly Dictionary<string, List<ShipmentEvent>> _shipments; public InMemoryTracker(Dictionary<string, List<ShipmentEvent>> shipments) => _shipments = shipments;
public List<ShipmentEvent> Track(string trackingNo) => _shipments.TryGetValue(trackingNo, out var events) ? events : throw new CarrierError("not_found", false);}
public sealed class ScriptedV2 : ISwiftlineV2Client{ private readonly Dictionary<string, List<V2Event>> _data; public ScriptedV2(Dictionary<string, List<V2Event>> data) => _data = data;
public List<V2Event> Events(string trackingNo) => _data.TryGetValue(trackingNo, out var evs) ? evs : throw new V2Error(404, 0);}
public static class Program{ // 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, IShipmentTracker tracker) { int passed = 0; var events = tracker.Track("SW-DONE"); if (events.Count > 0 && events[^1].Kind == EventKind.DELIVERED) passed++; if (events.Zip(events.Skip(1)).All(p => string.CompareOrdinal(p.First.At, p.Second.At) <= 0)) passed++; try { tracker.Track("SW-NONE"); } catch (CarrierError e) { if (e.Kind == "not_found" && !e.Retryable) passed++; } Console.WriteLine($"{name}: {passed}/3 passed"); }
public static void Main() { Action<string> show = s => Console.WriteLine($" log: {s}"); var sleeper = new FakeSleeper(); var 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\"/>"); IShipmentTracker tracker = new SwiftlineV1Adapter(v1, sleeper, show);
Console.WriteLine("== SW123 through the v1 adapter"); foreach (var e in tracker.Track("SW123")) Console.WriteLine(e); Console.WriteLine($"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
Console.WriteLine("== errors, in our terms"); foreach (var no in new[] { "SW404", "SW503" }) { try { tracker.Track(no); } catch (CarrierError e) { Console.WriteLine($"{no}: {e.Kind}, retryable={(e.Retryable ? "true" : "false")}"); } } // == 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. var v2 = new ScriptedV2(new() { ["SW-DONE"] = new() { new("PICKED_UP", "2026-10-20T09:00:00Z", "AUS"), new("DELIVERED", "2026-10-22T11:00:00Z", "AUS"), }, }); Action<string> quiet = _ => { };
Console.WriteLine("== 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(new() { ["SW-DONE"] = new() { new(EventKind.PICKED_UP, "2026-10-20T09:00", "AUS", "PU"), new(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 }}package main
import ( "errors" "fmt" "regexp" "sort" "strconv" "strings")
// ===== Our domain: the port, and the language the rest of the system speaks. =====
type EventKind string
const ( PickedUp EventKind = "PICKED_UP" InTransit EventKind = "IN_TRANSIT" OutForDelivery EventKind = "OUT_FOR_DELIVERY" DeliveryFailed EventKind = "DELIVERY_FAILED" Delivered EventKind = "DELIVERED" Unrecognized EventKind = "UNRECOGNIZED")
// ShipmentEvent is 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.type ShipmentEvent struct { Kind EventKind At string Location string RawCode string}
func (e ShipmentEvent) String() string { k := string(e.Kind) if e.Kind == Unrecognized { k = "UNRECOGNIZED(" + e.RawCode + ")" } return k + " " + e.Location + " " + e.At}
// CarrierError is an error in our terms. Callers decide from Retryable, never from a// vendor's status code.type CarrierError struct { Kind string Retryable bool}
func (e *CarrierError) Error() string { return e.Kind }
// ShipmentTracker is the port. Order tracking and customer emails depend on this and// nothing else.type ShipmentTracker interface { Track(trackingNo string) ([]ShipmentEvent, error)}
// ===== Shared by both adapters: retries, error classification and normalizing. =====
// vendorFailure is a failed vendor call, before we decide what it means.type vendorFailure struct{ status, retryAfterSeconds int }
func (f *vendorFailure) Error() string { return strconv.Itoa(f.status) }
// FakeSleeper is injected so retries can be tested without waiting. Nothing here really sleeps.type FakeSleeper struct{ TotalSeconds int }
func (s *FakeSleeper) Sleep(seconds int) { s.TotalSeconds += seconds }
type Log func(string)
const maxAttempts = 3
// classify turns vendor statuses into 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.func classify(status int) *CarrierError { switch { case status == 429: return &CarrierError{"rate_limited", true} case status >= 500: return &CarrierError{"unavailable", true} case status == 404: return &CarrierError{"not_found", false} } return &CarrierError{fmt.Sprintf("rejected_%d", status), false}}
// withRetries 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.func withRetries[T any](what string, call func() (T, error), sleeper *FakeSleeper, log Log) (T, error) { for attempt := 1; ; attempt++ { v, err := call() var f *vendorFailure if !errors.As(err, &f) { return v, err } cerr := classify(f.status) if !cerr.Retryable || attempt == maxAttempts { var zero T return zero, cerr } wait := f.retryAfterSeconds if wait == 0 { wait = attempt } log(fmt.Sprintf("%s: %d, retry in %d s", what, f.status, wait)) sleeper.Sleep(wait) }}
// normalize keeps the domain's promises whatever the vendor sends: time order, and one// IN_TRANSIT per facility. An "arrived" and a "departed" scan at the same hub are one fact.func normalize(events []ShipmentEvent) []ShipmentEvent { sorted := append([]ShipmentEvent(nil), events...) sort.SliceStable(sorted, func(i, j int) bool { return sorted[i].At < sorted[j].At }) var out []ShipmentEvent for _, e := range sorted { if n := len(out); n > 0 { last := out[n-1] if last.Kind == InTransit && e.Kind == InTransit && last.Location == e.Location { continue } } out = append(out, e) } return out}
// ===== Vendor v1: XML-ish text over HTTP, paged, rate limited. =====
type LegacyTransport interface { Get(path string) string}
// ScriptedTransport 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.type ScriptedTransport struct{ script map[string][]string }
func (t *ScriptedTransport) On(path string, responses ...string) *ScriptedTransport { if t.script == nil { t.script = map[string][]string{} } t.script[path] = responses return t}
func (t *ScriptedTransport) Get(path string) string { q, ok := t.script[path] if !ok { return `<error code="404"/>` } if len(q) > 1 { t.script[path] = q[1:] } return q[0]}
var attrRe = regexp.MustCompile(`([\w-]+)="([^"]*)"`)
func attrs(line string) map[string]string { out := map[string]string{} for _, m := range attrRe.FindAllStringSubmatch(line, -1) { out[m[1]] = m[2] } return out}
// v1Codes maps their codes to our vocabulary. Two vendor codes can mean one domain fact.var v1Codes = map[string]EventKind{ "PU": PickedUp, "AF": InTransit, "DF": InTransit, "OD": OutForDelivery, "NA": DeliveryFailed, "DL": Delivered,}
type SwiftlineV1Adapter struct { transport LegacyTransport sleeper *FakeSleeper log Log}
func (a SwiftlineV1Adapter) Track(trackingNo string) ([]ShipmentEvent, error) { var events []ShipmentEvent // Pagination is the vendor's concern. The domain gets one complete list. for page := "1"; page != ""; { path := "/track?n=" + trackingNo + "&page=" + page body, err := withRetries(trackingNo+" page "+page, func() (string, error) { return a.fetch(path) }, a.sleeper, a.log) if err != nil { return nil, err } page = "" for _, line := range strings.Split(body, "\n") { at := attrs(line) if strings.HasPrefix(line, "<more") { page = at["page"] } if strings.HasPrefix(line, "<scan") { events = append(events, a.toEvent(at)) } } } return normalize(events), nil}
func (a SwiftlineV1Adapter) fetch(path string) (string, error) { body := a.transport.Get(path) if !strings.HasPrefix(body, "<error") { return body, nil } at := attrs(body) code, _ := strconv.Atoi(at["code"]) retryAfter, _ := strconv.Atoi(at["retry-after"]) return "", &vendorFailure{code, retryAfter}}
// toEvent keeps an unknown code as UNRECOGNIZED and logs it, never dropping or guessing.// Dropping hides that the vendor changed; guessing can tell a customer "delivered" wrongly.func (a SwiftlineV1Adapter) toEvent(at map[string]string) ShipmentEvent { code := at["code"] kind, ok := v1Codes[code] if !ok { kind = Unrecognized a.log("unknown status code " + code + ", kept as UNRECOGNIZED") } return ShipmentEvent{kind, isoTime(at["ts"]), at["loc"], code}}
// isoTime turns "202610240910" into "2026-10-24T09:10". The vendor's format stops here.func isoTime(ts string) string { return ts[0:4] + "-" + ts[4:6] + "-" + ts[6:8] + "T" + ts[8:10] + ":" + ts[10:12]}
// ===== Vendor v2: a typed SDK with different names, no paging, its own error type. =====
type V2Event struct{ Status, OccurredAt, Facility string }
type V2Error struct{ HTTPStatus, RetryAfter int }
func (e *V2Error) Error() string { return strconv.Itoa(e.HTTPStatus) }
type SwiftlineV2Client interface { Events(trackingNo string) ([]V2Event, error)}
var v2Codes = map[string]EventKind{ "PICKED_UP": PickedUp, "IN_TRANSIT": InTransit, "OUT_FOR_DELIVERY": OutForDelivery, "DELIVERY_ATTEMPTED": DeliveryFailed, "DELIVERED": Delivered,}
type SwiftlineV2Adapter struct { client SwiftlineV2Client sleeper *FakeSleeper log Log}
func (a SwiftlineV2Adapter) Track(trackingNo string) ([]ShipmentEvent, error) { raw, err := withRetries(trackingNo, func() ([]V2Event, error) { evs, err := a.client.Events(trackingNo) var v2 *V2Error if errors.As(err, &v2) { return nil, &vendorFailure{v2.HTTPStatus, v2.RetryAfter} } return evs, err }, a.sleeper, a.log) if err != nil { return nil, err } var events []ShipmentEvent for _, e := range raw { kind, ok := v2Codes[e.Status] if !ok { kind = Unrecognized a.log("unknown status " + e.Status + ", kept as UNRECOGNIZED") } // "2026-10-24T09:10:00Z" -> "2026-10-24T09:10", the same shape v1 produces. events = append(events, ShipmentEvent{kind, e.OccurredAt[:16], e.Facility, e.Status}) } return normalize(events), nil}
// ===== A fake for tests elsewhere in the system. It must behave like the real adapters. =====
type InMemoryTracker map[string][]ShipmentEvent
func (m InMemoryTracker) Track(trackingNo string) ([]ShipmentEvent, error) { events, ok := m[trackingNo] if !ok { return nil, &CarrierError{"not_found", false} } return events, nil}
type scriptedV2 map[string][]V2Event
func (s scriptedV2) Events(trackingNo string) ([]V2Event, error) { evs, ok := s[trackingNo] if !ok { return nil, &V2Error{404, 0} } return evs, nil}
// contract is 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.func contract(name string, tracker ShipmentTracker) { passed := 0 events, _ := tracker.Track("SW-DONE") if n := len(events); n > 0 && events[n-1].Kind == Delivered { passed++ } ordered := true for i := 1; i < len(events); i++ { ordered = ordered && events[i-1].At <= events[i].At } if ordered { passed++ } _, err := tracker.Track("SW-NONE") var cerr *CarrierError if errors.As(err, &cerr) && cerr.Kind == "not_found" && !cerr.Retryable { passed++ } fmt.Printf("%s: %d/3 passed\n", name, passed)}
func main() { show := func(s string) { fmt.Println(" log: " + s) } sleeper := &FakeSleeper{} v1 := (&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"/>`) var tracker ShipmentTracker = SwiftlineV1Adapter{v1, sleeper, show}
fmt.Println("== SW123 through the v1 adapter") events, _ := tracker.Track("SW123") for _, e := range events { fmt.Println(e) } fmt.Printf("waited %d s\n", sleeper.TotalSeconds) // == 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
fmt.Println("== errors, in our terms") for _, no := range []string{"SW404", "SW503"} { _, err := tracker.Track(no) var cerr *CarrierError if errors.As(err, &cerr) { fmt.Printf("%s: %s, retryable=%t\n", no, cerr.Kind, cerr.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. v2 := scriptedV2{"SW-DONE": { {"PICKED_UP", "2026-10-20T09:00:00Z", "AUS"}, {"DELIVERED", "2026-10-22T11:00:00Z", "AUS"}, }} quiet := func(string) {}
fmt.Println("== one contract suite, every implementation") contract("v1 adapter", SwiftlineV1Adapter{v1, &FakeSleeper{}, quiet}) contract("v2 adapter", SwiftlineV2Adapter{v2, &FakeSleeper{}, quiet}) contract("in-memory fake", InMemoryTracker{"SW-DONE": { {PickedUp, "2026-10-20T09:00", "AUS", "PU"}, {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}// ===== Our domain: the port, and the language the rest of the system speaks. =====
type 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.interface ShipmentEvent { kind: EventKind; at: string; location: string; rawCode: string;}
function describe(e: ShipmentEvent): string { const k = e.kind === "UNRECOGNIZED" ? `UNRECOGNIZED(${e.rawCode})` : e.kind; return `${k} ${e.location} ${e.at}`;}
// Errors in our terms. Callers decide from `retryable`, never from a vendor's status code.class CarrierError extends Error { constructor( readonly kind: string, readonly retryable: boolean, ) { super(kind); }}
// The port. Order tracking and customer emails depend on this and nothing else.interface ShipmentTracker { track(trackingNo: string): ShipmentEvent[];}
// ===== 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 Error { constructor( readonly status: number, readonly retryAfterSeconds: number, ) { super(String(status)); }}
// Injected so retries can be tested without waiting. Nothing here really sleeps.class FakeSleeper { totalSeconds = 0; sleep(seconds: number): void { this.totalSeconds += seconds; }}
type Log = (line: string) => void;const 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.function classify(status: number): CarrierError { 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.// (Real code is async and awaits the sleep; the fake sleeper keeps this one synchronous.)function withRetries<T>(what: string, call: () => T, sleeper: FakeSleeper, log: Log): T { for (let attempt = 1; ; attempt++) { try { return call(); } catch (f) { if (!(f instanceof VendorFailure)) throw f; const error = classify(f.status); if (!error.retryable || attempt === MAX_ATTEMPTS) throw error; const wait = f.retryAfterSeconds > 0 ? f.retryAfterSeconds : attempt; log(`${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.function normalize(events: ShipmentEvent[]): ShipmentEvent[] { const sorted = [...events].sort((a, b) => (a.at < b.at ? -1 : a.at > b.at ? 1 : 0)); const out: ShipmentEvent[] = []; for (const e of sorted) { const last = out[out.length - 1]; const sameHub = last !== undefined && last.kind === "IN_TRANSIT" && e.kind === "IN_TRANSIT" && last.location === e.location; if (!sameHub) out.push(e); } return out;}
// ===== Vendor v1: XML-ish text over HTTP, paged, rate limited. =====
interface LegacyTransport { get(path: string): string;}
// 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 readonly script = new Map<string, string[]>();
on(path: string, ...responses: string[]): this { this.script.set(path, responses); return this; }
get(path: string): string { const q = this.script.get(path); if (!q) return '<error code="404"/>'; return q.length > 1 ? q.shift()! : q[0]; }}
function attrs(line: string): Record<string, string> { const out: Record<string, string> = {}; for (const m of line.matchAll(/([\w-]+)="([^"]*)"/g)) out[m[1]] = m[2]; return out;}
// "202610240910" -> "2026-10-24T09:10". The vendor's format stops at this boundary.function isoTime(ts: string): string { return `${ts.slice(0, 4)}-${ts.slice(4, 6)}-${ts.slice(6, 8)}T${ts.slice(8, 10)}:${ts.slice(10, 12)}`;}
// Their codes in our vocabulary. Two vendor codes can mean one domain fact.const V1_CODES: Record<string, EventKind> = { PU: "PICKED_UP", AF: "IN_TRANSIT", DF: "IN_TRANSIT", OD: "OUT_FOR_DELIVERY", NA: "DELIVERY_FAILED", DL: "DELIVERED",};
class SwiftlineV1Adapter implements ShipmentTracker { constructor( private readonly transport: LegacyTransport, private readonly sleeper: FakeSleeper, private readonly log: Log, ) {}
track(trackingNo: string): ShipmentEvent[] { const events: ShipmentEvent[] = []; // Pagination is the vendor's concern. The domain gets one complete list. let page: string | undefined = "1"; while (page !== undefined) { const path = `/track?n=${trackingNo}&page=${page}`; const body = withRetries(`${trackingNo} page ${page}`, () => this.fetch(path), this.sleeper, this.log); page = undefined; for (const line of body.split("\n")) { const a = attrs(line); if (line.startsWith("<more")) page = a.page; if (line.startsWith("<scan")) events.push(this.toEvent(a)); } } return normalize(events); }
private fetch(path: string): string { const body = this.transport.get(path); if (!body.startsWith("<error")) return body; const a = attrs(body); throw new VendorFailure(Number(a.code), Number(a["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 toEvent(a: Record<string, string>): ShipmentEvent { const kind = V1_CODES[a.code] ?? "UNRECOGNIZED"; if (kind === "UNRECOGNIZED") this.log(`unknown status code ${a.code}, kept as UNRECOGNIZED`); return { kind, at: isoTime(a.ts), location: a.loc, rawCode: a.code }; }}
// ===== Vendor v2: a typed SDK with different names, no paging, its own error type. =====
interface V2Event { status: string; occurredAt: string; facility: string;}
class V2Error extends Error { constructor( readonly httpStatus: number, readonly retryAfter: number, ) { super(String(httpStatus)); }}
interface SwiftlineV2Client { events(trackingNo: string): V2Event[];}
const V2_CODES: Record<string, EventKind> = { PICKED_UP: "PICKED_UP", IN_TRANSIT: "IN_TRANSIT", OUT_FOR_DELIVERY: "OUT_FOR_DELIVERY", DELIVERY_ATTEMPTED: "DELIVERY_FAILED", DELIVERED: "DELIVERED",};
class SwiftlineV2Adapter implements ShipmentTracker { constructor( private readonly client: SwiftlineV2Client, private readonly sleeper: FakeSleeper, private readonly log: Log, ) {}
track(trackingNo: string): ShipmentEvent[] { const raw = withRetries( trackingNo, () => { try { return this.client.events(trackingNo); } catch (e) { if (e instanceof V2Error) throw new VendorFailure(e.httpStatus, e.retryAfter); throw e; } }, this.sleeper, this.log, ); const events = raw.map((e): ShipmentEvent => { const kind = V2_CODES[e.status] ?? "UNRECOGNIZED"; if (kind === "UNRECOGNIZED") this.log(`unknown status ${e.status}, kept as UNRECOGNIZED`); // "2026-10-24T09:10:00Z" -> "2026-10-24T09:10", the same shape v1 produces. return { kind, at: e.occurredAt.slice(0, 16), location: e.facility, rawCode: e.status }; }); return normalize(events); }}
// ===== A fake for tests elsewhere in the system. It must behave like the real adapters. =====
class InMemoryTracker implements ShipmentTracker { constructor(private readonly shipments: Map<string, ShipmentEvent[]>) {}
track(trackingNo: string): ShipmentEvent[] { const events = this.shipments.get(trackingNo); if (!events) throw new CarrierError("not_found", false); return events; }}
// 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.function contract(name: string, tracker: ShipmentTracker): void { let passed = 0; const events = tracker.track("SW-DONE"); if (events.length > 0 && events[events.length - 1].kind === "DELIVERED") passed++; if (events.every((e, i) => i === 0 || events[i - 1].at <= e.at)) passed++; try { tracker.track("SW-NONE"); } catch (e) { if (e instanceof CarrierError && e.kind === "not_found" && !e.retryable) passed++; } console.log(`${name}: ${passed}/3 passed`);}
const show: Log = (s) => console.log(` log: ${s}`);const sleeper = new FakeSleeper();const 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"/>', );const tracker: ShipmentTracker = new SwiftlineV1Adapter(v1, sleeper, show);
console.log("== SW123 through the v1 adapter");for (const e of tracker.track("SW123")) console.log(describe(e));console.log(`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
console.log("== errors, in our terms");for (const no of ["SW404", "SW503"]) { try { tracker.track(no); } catch (e) { if (e instanceof CarrierError) console.log(`${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.const v2Data = new Map<string, V2Event[]>([ [ "SW-DONE", [ { status: "PICKED_UP", occurredAt: "2026-10-20T09:00:00Z", facility: "AUS" }, { status: "DELIVERED", occurredAt: "2026-10-22T11:00:00Z", facility: "AUS" }, ], ],]);const v2: SwiftlineV2Client = { events(no) { const evs = v2Data.get(no); if (!evs) throw new V2Error(404, 0); return evs; },};const quiet: Log = () => {};
console.log("== 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( new Map([ [ "SW-DONE", [ { kind: "PICKED_UP", at: "2026-10-20T09:00", location: "AUS", rawCode: "PU" }, { kind: "DELIVERED", at: "2026-10-22T11:00", location: "AUS", rawCode: "DL" }, ], ], ]), ),);// == one contract suite, every implementation// v1 adapter: 3/3 passed// v2 adapter: 3/3 passed// in-memory fake: 3/3 passedWhat to notice
Section titled “What to notice”- The model is translated, not renamed.
AFandDFare 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.
XRbecomesUNRECOGNIZED(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 treatsUNRECOGNIZEDas “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
CarrierErrorwith aretryableflag, and never a vendor status code. - Retries have a budget. The adapter honors
retry-afterwhen 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.
FakeSleeperrecords 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-retryablenot_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.
SwiftlineV2Adaptermaps different names (DELIVERY_ATTEMPTEDtoDELIVERY_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 moreShipmentTracker. - The port is shaped by the domain.
trackreturns domain events, not pages, raw codes, or a vendor response object. Say the port had apageparameter. Then v2 (which doesn’t page) would have to fake one. And every caller would be paging through a vendor detail.
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?
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.
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.
Tradeoffs
Section titled “Tradeoffs”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.
In each language
Section titled “In each language”| 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.
Commonly confused with
Section titled “Commonly confused with”| 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. |
Where it shows up
Section titled “Where it shows up”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/sqldrivers. 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.
Interview questions
Section titled “Interview questions”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 |
Further reading
Section titled “Further reading”- 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
The designbeyondcode digest, every Saturday
New deep dives, interview tips and site news in one short email. Free. Leave with one click.
We never sell or share your email. We only use it to send this newsletter.