Facade

Every checkout emailed a receipt. Most of the time. The bug report was “some customers say they never got their receipt,” which is the worst kind of report, because it is intermittent and the logs looked clean.

The receipt code lived in four places: checkout, refund, subscription renewal, and a “resend” button in the admin panel. Four copies of the same eight lines, because sending through our push provider took eight lines. Create a client, authenticate, open a channel, encode the payload, deliver it, retry once if the error was retriable, close the channel. Three of the four copies were correct. The admin resend had been written in a hurry, and it never closed its channel.

Under normal traffic nobody noticed. During a sale, support hammered the resend button a few hundred times in an afternoon, the provider’s connection pool filled with channels that never closed, and new deliveries started timing out. Real customers stopped getting receipts because someone, months earlier, pasted eight lines and dropped the last one.

The dropped line is not the interesting part. The interesting part is that sending a receipt required knowing eight lines at all. Every caller had to understand how the subsystem worked, in what order, with what cleanup. That knowledge got copied to four files, and one copy was wrong. A facade is the fix, and you have written a hundred of them without ever calling them that.

The subsystem leaks into every caller

Here is the shape of the mess. The provider gives you a powerful, low-level client, and it makes you drive every step yourself:

import { PushClient } from "@acme/push";

// the same eight lines, four times (checkout.js, refund.js, renewal.js, admin-resend.js)
const client = new PushClient({ region: "eu", timeoutMs: 5000 });
await client.authenticate(process.env.PUSH_KEY);
const channel = await client.openChannel("receipts");

const frame = client.encode({ title: "Your receipt", body: `Order #${order.id}` });
try {
  await channel.deliver(frame);
} catch (err) {
  if (err.retriable) await channel.deliver(frame); // retry once... in three of the four copies
}
await channel.close(); // the admin copy forgot this line

Nothing here is unreasonable on its own. Together they are a trap. The order matters (authenticate before openChannel, encode before deliver), the cleanup is mandatory and easy to skip, the retry policy is a judgment call every caller re-makes, and the whole thing is spread across files owned by different people. This is what “the subsystem leaks into every caller” means in practice. The provider’s complexity did not stay in the provider. It moved into your codebase, four times.

The fix is a function

Pull the eight lines into one place and give it a name that describes the task, not the machinery:

// notifier.js: the whole subsystem behind one task-shaped function
import { PushClient } from "@acme/push";
import { withRetry } from "./retry.js";

let client; // one client, reused across calls

async function ready() {
  if (!client) {
    client = new PushClient({ region: "eu", timeoutMs: 5000 });
    await client.authenticate(process.env.PUSH_KEY);
  }
  return client;
}

export async function notify(channelName, { title, body }) {
  const c = await ready();
  const channel = await c.openChannel(channelName);
  const frame = c.encode({ title, body });
  try {
    await withRetry(() => channel.deliver(frame), { tries: 3 });
  } finally {
    await channel.close(); // nobody can forget it again
  }
}

Now every caller says one thing:

import { notify } from "./notifier.js";

await notify("receipts", { title: "Your receipt", body: `Order #${order.id}` });

That is the facade. A small, task-oriented surface over a subsystem that stays hidden. The caller does not know there is a client, a channel, an encode step, a retry policy, or a close. It knows notify. The eight lines that used to live in four files now live in one, the finally guarantees the channel closes on every path, and the retry rule is decided once instead of four times, inconsistently.

Notice what the facade is: an exported function in a module. Not a FacadeFactory, not an abstract base class, not a registry. In a language built around Java-style objects the textbook draws this as a Facade class with the subsystem objects as fields. In JavaScript that mostly collapses to a module with a couple of exported functions, and it should. The single module-level client reused by ready() is a singleton and a connection pool of one that fell out for free, and withRetry is the retry-and-backoff policy living where it belongs instead of copy-pasted into call sites. Reach for a class only when you genuinely need many independent instances with their own state. For “hide this mess behind one door,” a function is the honest tool.

before · every caller wires the subsystemeach callerauthenticateopenChannelencodedeliverclosemust know all five, and the orderforget close() and the channels leakafter · one door, the mess hiddencallerfacadenotify()subsystemauthenticateopenChannelencodedeliverclosecallers went from knowing five calls to knowing one
Before, every caller drives all five subsystem steps in order. After, callers make one call and the subsystem stays behind the door.

You already live behind facades

Once you see the shape, you see it everywhere, because the whole job of a good library is to be one.

fetch(url) is a facade. Behind that one call sit DNS resolution, connection reuse, TLS, redirects, content decompression, and a streaming body, and you touch none of it to GET a page. The Fetch API is a clean door over a genuinely enormous subsystem, which is exactly why it replaced the old XMLHttpRequest dance.

An SDK is a facade over an HTTP endpoint. When you write client.responses.create({ ... }) to call a model, the SDK is hiding the base URL, the auth header, JSON encoding, retries on a 429, and response parsing. Strip the SDK away and you are back to fetch with a hand-built header object and your own retry loop. The SDK’s entire value is that you do not do that.

And you build them yourself, constantly. A placeOrder(cart) service method that quietly touches an inventory repository, a payments client, and an email sender is a facade over three subsystems. A copy(text) helper wrapping navigator.clipboard.writeText with a fallback for older browsers is a facade over one fiddly API. You wrote a facade the last time you turned three ugly steps into one nice function. That is the pattern. It is not exotic; it is the most common structural move in software, and naming it just lets you talk about when it is done well and when it is done badly.

A facade must reduce coupling, not just add a layer

Here is the version that looks like a facade and is not:

// a "facade" that only forwards. Same knowledge, extra file.
export const notifier = {
  create: (cfg) => new PushClient(cfg),
  auth: (c, key) => c.authenticate(key),
  channel: (c, name) => c.openChannel(name),
  encode: (c, msg) => c.encode(msg),
  deliver: (ch, frame) => ch.deliver(frame),
  close: (ch) => ch.close(),
};

Every method maps one-to-one onto a subsystem method. The caller still has to create, then auth, then open a channel, then encode, then deliver, then close, in that order, holding the same intermediate objects. You have not removed any knowledge from the caller. You added a file and a layer of indirection and got a rename in exchange. This is the most common way a facade fails review: it mirrors the subsystem instead of collapsing it.

The test is simple. A real facade lets the caller forget something. If the surface has the same number of calls, in the same order, as the raw subsystem, it is a pass-through, and a pass-through is worse than nothing because it hides the fact that it hid nothing. A good facade takes many steps and returns one task.

pass-through · one method per subsystem call, nothing collapsedfacadeauth()channel()encode()deliver()subsystemauthenticate()openChannel()encode()deliver()still fourcalls, in orderfacade · one task collapses the whole sequencenotify()one callsubsystem (hidden)authenticateopenChannelencode → deliverretry → closeorchestrates internally
A one-to-one forwarder makes the caller do the same work with an extra hop. A real facade collapses the steps into a single task.

Do not trap the caller

Every non-trivial abstraction leaks a little. That is a real law, not a slogan: the moment your subsystem does something the facade did not anticipate, someone needs to reach past it. The push provider adds a priority flag. A single caller needs a beta endpoint. Someone has to debug a delivery by inspecting the raw channel. A facade that has no answer for those forces a bad choice: fork the facade, or abandon it and re-import the low-level client at the call site, which drags all the leaked complexity right back into the code you cleaned up.

So the good version keeps a door open. Cover the common cases with a small surface, and expose the underlying object for the rare ones:

// notifier.js
export async function notify(channelName, { title, body }) {
  /* the 95% path from earlier */
}

// the 5% that needs something notify() doesn't wrap
export function rawClient() {
  return ready();
}

The rule of thumb: a facade should make the easy things trivial without making the hard things impossible. Ninety-five percent of callers use notify and never think about a channel. The one caller who needs the priority flag gets rawClient() and full access, on the record, in a named export you can grep for, instead of a rogue new PushClient() hidden in a handler somewhere. Trapping the user is how a helpful facade becomes the thing people route around.

callerscommonrare casefacade surfacenotify()send()the common cases, one call eachrawClient() escape hatchsubsystem (hidden)PushClientchannelencoderetryraw power,on the record
The facade offers a small task surface for the common cases and a named escape hatch to the raw client for the rare ones.

The god-object trap

The opposite failure is a facade that never says no. It starts as notify. Then someone needs receipts specifically, so sendReceipt gets added. Then premium users get a different template, so sendPremiumReceipt. Then the EU has different rules, so sendPremiumReceiptEu. A year later the notifier module is two thousand lines, exports forty functions, imports nine other subsystems, and touches every corner of the app. It is no longer a door over one subsystem. It is a mansion.

A facade wraps a subsystem and gives you tasks against that subsystem. When it starts sprouting a method per screen, per user tier, per business rule, the growth is not facade work anymore. Those are use cases, and they belong in the callers or in their own thin services, not accreted onto the wrapper. A facade that coordinates many unrelated subsystems and holds the workflow logic between them has drifted into a different pattern with a worse reputation. That coordination role is the mediator, and the moment a “facade” is orchestrating conversations between subsystems rather than simplifying access to one, you should name it correctly and judge it by the mediator’s rules, which include “watch out, this becomes a god object.”

Facade, adapter, mediator

Three structural patterns get confused because all three wrap something. The difference is what they wrap it for.

A facade simplifies. It takes a subsystem with a large, awkward surface and gives you a small, task-shaped one. You point it at complexity you own or depend on, and it hands back “do the thing.”

An adapter converts. It takes one interface and makes it look like a different, specific interface that some other code already expects. You reach for it when two things that should fit do not, like a payment provider whose method names and argument shapes do not match the interface the rest of your app calls. A facade is free to invent whatever surface reads best; an adapter has a target it must match exactly.

A mediator coordinates. It sits between several peers that would otherwise talk directly to each other and routes their interactions through one hub, so the peers stop knowing about each other. Its arrows point in every direction; a facade’s point one way, from caller to subsystem.

facadesimplifyone()many stepsone calladapterconvertshape Aadaptershape Bmatch a target interfacemediatorcoordinatehubpeers talk through a hub
Three related wrappers, three different jobs: simplify a subsystem, convert to a target interface, or coordinate several peers.

The anti-corruption layer

The facade earns its keep hardest at the edge of your system, where you integrate something you did not design and cannot change. A third-party billing provider, a legacy internal service, a partner’s API written by people who name fields cust_ref and encode status as the number 2. Left alone, those foreign shapes creep inward. Their field names end up in your domain objects, their status codes end up in your if statements, and the day they rename sts to state, you are editing forty files.

An anti-corruption layer is a facade (usually with an adapter inside it) whose entire job is to be the one place that speaks the foreign dialect. Nothing on your side of it ever sees the vendor’s shape:

// billing-acl.js: the only file allowed to speak the provider's dialect
import * as vendor from "@vendor/billing";

export async function getAccount(id) {
  const raw = await vendor.fetchCustomer(id); // { cust_ref, full_nm, sts, bal_cents }
  return {
    id: raw.cust_ref,
    name: raw.full_nm,
    status: raw.sts === 2 ? "active" : "suspended",
    balanceUsd: raw.bal_cents / 100,
  };
}

Your domain deals in { id, name, status, balanceUsd } and has never heard of cust_ref. When the vendor renames a field, ships a v2, or gets replaced by a competitor, exactly one file changes and the blast radius stops at its edge. This is the idea Eric Evans named in domain-driven design, and it is the most valuable place to spend a facade: not to tidy code you own, but to quarantine code you do not.

external vendorcust_ref: “c_88”full_nm: “Ada L.”sts: 2bal_cents: 415their dialectanti-corruptiontranslateone file onlyid: “c_88”name: “Ada L.”status: “active”balanceUsd: 4.15your domain, cleanrename a field on the left and only the middle box changes
The anti-corruption layer translates the vendor's foreign shape into your clean domain shape at the boundary, so nothing inside ever sees cust_ref.

Try it

Send the same receipt two ways. The raw path makes you write every step; the facade path makes you write one call and runs the same steps for you, dimmed. Same result, wildly different amount you have to know. Watch the “calls you wrote” counter.

interactiveRaw steps vs one facade call

Both columns end on the same green line. The only thing that changed is how much the caller had to hold in its head, and that is the entire value proposition. Six things you can get wrong, versus one you cannot.

Summary

  • A facade is a small, task-oriented surface over a subsystem with a large, awkward one. You have written many without naming them: every time you wrap three ugly steps in one nice function, that is a facade.
  • In JavaScript a facade is almost always a module or a function, not a class. The Java-style Facade object collapses to an exported function over the subsystem. Reach for a class only when you truly need many instances with independent state.
  • The reason it matters: a subsystem that makes callers know too much (which objects, what order, which cleanup) leaks that knowledge into every call site, and copies drift out of sync. The facade holds the knowledge once.
  • You already depend on facades constantly. fetch hides the networking stack, an SDK hides a model endpoint, and your own service methods hide repositories and clients.
  • A real facade collapses many steps into one task. A wrapper that forwards one-to-one, same calls in the same order, is a pass-through that adds a layer and removes nothing. That is the most common way the pattern is faked.
  • Do not trap the caller. Every abstraction leaks eventually, so cover the common cases simply and keep a named escape hatch to the raw object for the rare ones. Otherwise people route around your facade and drag the complexity back in.
  • Do not let it become a god object. A facade fronts one subsystem. When it grows a method per screen or per business rule and its import list reads like a tour of the whole app, it has drifted into mediator territory, with the mediator’s failure modes.
  • Facade vs neighbours: a facade simplifies a subsystem, an adapter converts to a target interface, a mediator coordinates several peers. All three wrap; only the intent differs.
  • The anti-corruption layer is a facade at the system edge that quarantines a foreign model (a legacy or third-party API) so its field names and quirks never leak into your domain. It is the highest-value place to spend a facade.
  • When to use: a subsystem that forces callers to know too much, or an external system whose shape you want to keep out of your code. When to avoid: when the wrapper would forward one-to-one (it buys nothing), or when you are really coordinating subsystems rather than simplifying one (that is a different pattern).