Facade is the pattern you reach for when using a feature correctly takes ten calls in the right order. You keep all the subsystems as they are and add one object in front of them with a few methods that say what the caller actually wants. Callers talk to the facade; the facade talks to everything else.
Difficulty: ★★☆☆☆. Popularity: ★★★★★. It is everywhere, usually under names like CheckoutService, StorageClient or sdk.
The problem: a controller that orchestrates everything
Placing an order touches five subsystems. Without a facade, the HTTP handler does the orchestration itself:
app.post("/orders", async (req, res) => {
const { userId, items, address, paymentToken } = req.body;
const stock = await inventoryClient.check(items.map((i) => ({ sku: i.sku, qty: i.qty })));
if (stock.some((s) => !s.available)) return res.status(409).json({ error: "Out of stock" });
const reservation = await inventoryClient.reserve(items, { ttlSeconds: 900 });
const quote = await shippingClient.quote({ to: address, weightGrams: totalWeight(items) });
const amount = subtotal(items) + quote.fee;
let charge;
try {
charge = await paymentGateway.charge({ token: paymentToken, amount, currency: "VND" });
} catch (error) {
await inventoryClient.release(reservation.id);
return res.status(402).json({ error: "Payment failed" });
}
const order = await db.orders.insert({ userId, items, amount, chargeId: charge.id, status: "paid" });
await inventoryClient.commit(reservation.id);
await shippingClient.createShipment({ orderId: order.id, to: address, service: quote.service });
await mailer.send(userId, "order-confirmation", { orderId: order.id });
res.status(201).json({ orderId: order.id });
});
Every line is reasonable, but together they cause real pain:
- The ordering rules live in a controller. Reserve before charging, release if the charge fails, commit after saving. Those rules are business logic, and they are hidden in HTTP code.
- Duplication. The mobile API, the admin "create order for customer" screen and a CLI import tool all need the same sequence, and each copy drifts.
- Coupling. The controller imports five clients and knows their exact method names and options. Swap the shipping provider and you edit every caller.
- Hard to test. Testing "release stock when payment fails" means spinning up an HTTP request and mocking five clients.
The pattern: one entry point with intention-revealing methods
A facade wraps the subsystems and exposes the operations callers actually need. The subsystems stay untouched; they are still available to code that needs their full power.
type LineItem = { sku: string; qty: number; unitPrice: number; weightGrams: number };
type Address = { line1: string; district: string; city: string };
type PlaceOrderInput = { userId: string; items: LineItem[]; address: Address; paymentToken: string };
type PlaceOrderResult =
| { ok: true; orderId: string; amount: number }
| { ok: false; reason: "out-of-stock" | "payment-failed" };
// The subsystems. In a real codebase each one is its own module or SDK.
interface Inventory {
reserve(items: LineItem[]): Promise<{ id: string } | null>; // null when anything is out of stock
commit(reservationId: string): Promise<void>;
release(reservationId: string): Promise<void>;
}
interface Shipping {
quote(to: Address, weightGrams: number): Promise<{ fee: number; service: string }>;
createShipment(orderId: string, to: Address, service: string): Promise<void>;
}
interface Payments {
charge(token: string, amount: number): Promise<{ id: string }>;
}
interface Orders {
create(data: { userId: string; items: LineItem[]; amount: number; chargeId: string }): Promise<{ id: string }>;
}
interface Notifications {
orderConfirmed(userId: string, orderId: string): Promise<void>;
}
export class CheckoutFacade {
constructor(
private readonly inventory: Inventory,
private readonly shipping: Shipping,
private readonly payments: Payments,
private readonly orders: Orders,
private readonly notifications: Notifications,
) {}
async quote(items: LineItem[], to: Address) {
const subtotal = items.reduce((sum, i) => sum + i.unitPrice * i.qty, 0);
const { fee } = await this.shipping.quote(to, weight(items));
return { subtotal, shippingFee: fee, total: subtotal + fee };
}
async placeOrder({ userId, items, address, paymentToken }: PlaceOrderInput): Promise<PlaceOrderResult> {
const reservation = await this.inventory.reserve(items);
if (!reservation) return { ok: false, reason: "out-of-stock" };
const shippingQuote = await this.shipping.quote(address, weight(items));
const amount = items.reduce((sum, i) => sum + i.unitPrice * i.qty, 0) + shippingQuote.fee;
let chargeId: string;
try {
chargeId = (await this.payments.charge(paymentToken, amount)).id;
} catch {
await this.inventory.release(reservation.id);
return { ok: false, reason: "payment-failed" };
}
const order = await this.orders.create({ userId, items, amount, chargeId });
await this.inventory.commit(reservation.id);
await this.shipping.createShipment(order.id, address, shippingQuote.service);
await this.notifications.orderConfirmed(userId, order.id).catch(() => {}); // email must not fail the order
return { ok: true, orderId: order.id, amount };
}
}
const weight = (items: LineItem[]) => items.reduce((sum, i) => sum + i.weightGrams * i.qty, 0);
The controller shrinks to translating HTTP into a call and the result back into HTTP:
app.post("/orders", async (req, res) => {
const result = await checkout.placeOrder({ ...req.body, userId: req.user.id });
if (!result.ok) return res.status(result.reason === "out-of-stock" ? 409 : 402).json(result);
res.status(201).json(result);
});
And the ordering rules can be tested with in-memory fakes, no HTTP involved:
const log: string[] = [];
const checkout = new CheckoutFacade(
{
reserve: async () => (log.push("reserve"), { id: "r1" }),
commit: async () => void log.push("commit"),
release: async () => void log.push("release"),
},
{
quote: async () => ({ fee: 30_000, service: "standard" }),
createShipment: async () => void log.push("ship"),
},
{
charge: async () => {
throw new Error("card declined");
},
},
{ create: async () => (log.push("create"), { id: "o1" }) },
{ orderConfirmed: async () => void log.push("email") },
);
const result = await checkout.placeOrder({
userId: "u1",
items: [{ sku: "MUG", qty: 2, unitPrice: 120_000, weightGrams: 350 }],
address: { line1: "12 Nguyen Hue", district: "1", city: "HCMC" },
paymentToken: "tok_test",
});
console.log(result, log);
// { ok: false, reason: "payment-failed" } [ "reserve", "release" ]
What a facade is, and what it is not
A facade simplifies. It does not add behaviour the subsystems lack and it does not hide them completely; code with unusual needs can still use Inventory directly. That distinguishes it from its neighbours:
| Pattern | Wraps | Purpose |
|---|---|---|
| Facade | Many objects | A simpler interface to a whole subsystem |
| Adapter (chapter 9) | One object | Make an existing interface match the one the caller expects |
| Proxy (chapter 11) | One object | Same interface, with control added (cache, lazy loading, access checks) |
| Mediator | Many objects | Objects talk to each other through it, instead of directly |
In layered backends, the "application service" or "use case" layer is a set of facades over the domain and infrastructure. SDKs such as the AWS SDK's high-level Upload helper (which hides multipart upload: create, upload parts in parallel, complete or abort) are facades too.
When to use it, and when not to
Use it when:
- Callers repeat the same multi-step sequence over several subsystems.
- The sequence has rules (ordering, compensation on failure) that belong in one place.
- You want to decouple callers from vendor SDKs you might replace.
Skip it when:
- There is one subsystem with a reasonable API already. A pass-through wrapper with the same methods adds a file and nothing else.
- Different callers need genuinely different sequences. One facade with fifteen flags is worse than two small ones.
Common pitfalls
- The god facade. A
PlatformServicewith sixty methods across orders, users and billing is just a new monolith. Keep facades narrow, one per use case area. - Leaking subsystem types. If
placeOrderreturns the payment gateway's raw response, callers start depending on it and the decoupling is gone. Return your own types. - Hiding failure handling. A facade that swallows every error makes problems invisible. Swallow only what is truly optional (the email above) and return explicit failure results for the rest.
- Pretending it is a transaction. The facade orders the steps and compensates on known failures, but a crash between
createandcommitstill leaves partial state. For that you need a saga or an outbox, and the facade is where that logic would live.
Exercises
- Add a test for the happy path that asserts the exact order of calls:
reserve, create, commit, ship, email. - Make
createShipmentfail after the order is created. What should the facade do, and what should the customer see? Implement it. - Add a
cancelOrder(orderId)method. Which subsystems does it need, and in what order? - Find a controller in a codebase you work on that calls three or more services in sequence and extract a facade from it.