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

  1. The god facade. A PlatformService with sixty methods across orders, users and billing is just a new monolith. Keep facades narrow, one per use case area.
  2. Leaking subsystem types. If placeOrder returns the payment gateway's raw response, callers start depending on it and the decoupling is gone. Return your own types.
  3. 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.
  4. Pretending it is a transaction. The facade orders the steps and compensates on known failures, but a crash between create and commit still leaves partial state. For that you need a saga or an outbox, and the facade is where that logic would live.

Exercises

  1. Add a test for the happy path that asserts the exact order of calls: reserve, create, commit, ship, email.
  2. Make createShipment fail after the order is created. What should the facade do, and what should the customer see? Implement it.
  3. Add a cancelOrder(orderId) method. Which subsystems does it need, and in what order?
  4. Find a controller in a codebase you work on that calls three or more services in sequence and extract a facade from it.