GUIDE · Structural
Facade
Provide a unified, higher-level interface to a set of interfaces in a subsystem, making the subsystem easier to use.
At a glance
- INTENT
- Provide a unified, higher-level interface to a set of interfaces in a subsystem, making the subsystem easier to use.
- PROBLEM
- Placing an order means talking to inventory, payments, shipping and email in the right order with the right rollback — and three controllers each re-implement that dance.
- SAY
- Participants: Facade (one simple entry point), Subsystem classes (do the real work, unaware of the facade), Client. Pitch: "one door in front of a messy building."
- SEEN IN
fs.readFile(open/fstat/read/close behind one call);fetch/ axios over sockets, redirects, body parsing;express()overhttp.createServer+ router + parsers; jQuery$.ajax,$(sel);child_process.execSyncoverspawn; service-layer functions (placeOrder()) in Node backends; SDK clients (stripe.checkout.sessions.create) hiding auth, retries, serialization.- VS
- Adapter converts an interface so it matches an EXISTING one; Facade invents a NEW simpler interface over several objects. Mediator coordinates peers that talk BACK to it; Facade is one-way — subsystems don't know it exists.
The idea#
Picture a messy building with a stockroom, a cash box, a mail slot, and a note on the fridge. You do not get a map of the hallways. You get one front door. You say what you want. Somebody inside visits those rooms in the right order and comes back with a yes or a no.
A facade is that door. It is one higher-level object in front of a set of objects that each do real work. Those inner objects do not know the door exists. They do not call each other. The client, a controller or a script or a timer, calls one method. The method knows the sequence, including what to undo when a later step fails.
Placing an order is the messy building. Inventory has to have the item. Payment has to take the money. Shipping has to print a label. Email has to tell the customer. Do it in that order. If the card is declined, put the stock back. Three controllers that each copy that dance will drift apart the first time the dance changes.
Why it matters#
The bug this pattern prevents is a copied script. One controller forgets to release the reservation. Another sends the email before the charge. A third checks stock with a slightly different rule. The building still has four rooms. The door is the only place that knows the tour. Fix the tour once.
You already walk through doors like this. fs.readFile opens, stats, reads, and closes behind one call. fetch and axios sit on top of sockets, redirects, and turning a body into text. Calling express() hides http.createServer, the router, and the parsers. jQuery's ajax call and its selector function hide a pile of browser details. execSync hides a spawned process. A backend placeOrder function is this pattern with a business name. A payments SDK that creates a checkout session hides auth, retries, and serialization. You call one method. The rooms stay on the other side of the door.
An adapter converts one interface so it matches an interface you already have. A facade invents a new, simpler interface over several objects. A mediator sits in the middle of peers that call back into it. A facade is one-way. The stockroom does not know it was called from an order. You can still walk into the stockroom yourself when you need a single room. The door is a shortcut, not a lock.
A small example#
function stockroom(sku, qty) { return qty > 0 ? sku + ' reserved' : 'missing';} function cashBox(cents) { return 'paid ' + cents;} function mailSlot(address) { return 'label for ' + address;} function placeOrder(order) { const reserved = stockroom(order.sku, order.qty); if (reserved === 'missing') return { ok: false, reason: 'out of stock' }; const paid = cashBox(order.qty * order.cents); const label = mailSlot(order.address); return { ok: true, reserved, paid, label };} const tray = placeOrder({ sku: 'muffin', qty: 2, cents: 300, address: '1 Main' });placeOrder is the only function the caller needs. The stockroom, the cash box, and the mail slot stay ordinary functions. They never mention an order.
What the code below is doing#
The sheet shows the same idea twice.
The first shape uses classes so the book names are visible. InventoryService, PaymentService, ShippingService, and EmailService are the subsystem classes. Inventory starts with five of SKU-1 and zero of SKU-2. It can check, reserve, and release. Payment pushes a charge and returns an id, and it throws card declined for one hard-coded customer. Shipping stores a label. Email stores a message. None of them imports the others.
OrderFacade is the door. placeOrder checks stock and returns out_of_stock before it touches anything else. On a hit it reserves, then charges qty * unitCents. If the charge throws, it releases the reservation and returns the error message. That undo lives in this one method. On success it creates a label, sends "Order confirmed" with the charge id, and returns ok with both ids.
The happy order buys two of SKU-1 at 1500 cents. Stock falls from 5 to 3. The charge is 3000 cents. The email names charge ch_1. The label is lbl_1. An order for SKU-2 comes back out of stock, and the label list stays at one. A declined card for three more units comes back card declined, stock is still 3, and no second email goes out. After all of that, calling charge on the payment object directly still works and returns ch_2. The rooms did not become private.
The second shape is a single function in front of a few built-ins. A sha256 helper feeds a string into the streaming hash API and returns the hex digest. The digest of abc is the well-known SHA-256 value. parseEnv is a door in front of split, trim, filter, and a search for =. Blank lines and lines that start with # drop out. Quotes around a value come off. A tiny env blob becomes DB_URL and PORT. isSameOrigin asks the URL parser for two origins. https://x.io/a and https://x.io:443/b match, because 443 is already the https port. https and http on the same host do not.
When you talk about it, say this: one door in front of a messy building.
Everything below is the demonstration. Same idea, written as code you can run.
'use strict';const assert = require('node:assert/strict'); Not run in the browser: uses node:crypto. Copy the node command and run it locally.
Classic shape (classes, so the participant names are visible)#
// Subsystems — each fine alone, none knows about the othersclass InventoryService { constructor() { this.stock = new Map([['SKU-1', 5], ['SKU-2', 0]]); } available(sku, qty) { return (this.stock.get(sku) ?? 0) >= qty; } reserve(sku, qty) { this.stock.set(sku, this.stock.get(sku) - qty); } release(sku, qty) { this.stock.set(sku, this.stock.get(sku) + qty); }}class PaymentService { constructor() { this.charges = []; } charge(customerId, cents) { if (customerId === 'cust-declined') throw new Error('card declined'); const id = `ch_${this.charges.length + 1}`; this.charges.push({ id, customerId, cents }); return id; }}class ShippingService { constructor() { this.labels = []; } createLabel(address, items) { const id = `lbl_${this.labels.length + 1}`; this.labels.push({ id, address, items }); return id; }}class EmailService { constructor() { this.sent = []; } send(to, subject) { this.sent.push({ to, subject }); }} class OrderFacade { // Facade — the one call controllers/CLI/cron need constructor({ inventory, payment, shipping, email }) { Object.assign(this, { inventory, payment, shipping, email }); } placeOrder({ customerId, email, address, sku, qty, unitCents }) { if (!this.inventory.available(sku, qty)) return { ok: false, reason: 'out_of_stock' }; this.inventory.reserve(sku, qty); let chargeId; try { chargeId = this.payment.charge(customerId, qty * unitCents); } catch (err) { this.inventory.release(sku, qty); // compensating action lives in ONE place return { ok: false, reason: err.message }; } const labelId = this.shipping.createLabel(address, [{ sku, qty }]); this.email.send(email, `Order confirmed (${chargeId})`); return { ok: true, chargeId, labelId }; }} const sub = { inventory: new InventoryService(), payment: new PaymentService(), shipping: new ShippingService(), email: new EmailService() };const orders = new OrderFacade(sub);const base = { customerId: 'cust-1', email: 'a@x.io', address: '1 Main', sku: 'SKU-1', qty: 2, unitCents: 1500 };Verified by 10 asserts
assert.deepEqual(orders.placeOrder(base), { ok: true, chargeId: 'ch_1', labelId: 'lbl_1' });assert.equal(sub.inventory.stock.get('SKU-1'), 3);assert.equal(sub.payment.charges[0].cents, 3000);assert.deepEqual(sub.email.sent, [{ to: 'a@x.io', subject: 'Order confirmed (ch_1)' }]);assert.deepEqual(orders.placeOrder({ ...base, sku: 'SKU-2', qty: 1 }), { ok: false, reason: 'out_of_stock' });assert.equal(sub.shipping.labels.length, 1); // nothing downstream happenedassert.deepEqual(orders.placeOrder({ ...base, customerId: 'cust-declined', qty: 3 }), { ok: false, reason: 'card declined' });assert.equal(sub.inventory.stock.get('SKU-1'), 3); // reservation rolled back by the facadeassert.equal(sub.email.sent.length, 1);assert.equal(sub.payment.charge('cust-2', 1), 'ch_2'); // subsystems stay directly usable; facade is a shortcutIdiomatic JS shape: a module exporting one function over several imports/built-ins#
const { createHash } = require('node:crypto');const sha256 = (s) => createHash('sha256').update(s).digest('hex'); // facade over the streaming Hash API const parseEnv = (text) => Object.fromEntries( // facade over split/trim/filter/indexOf/replace text.split('\n').map((l) => l.trim()).filter((l) => l && !l.startsWith('#')) .map((l) => { const i = l.indexOf('='); return [l.slice(0, i), l.slice(i + 1).replace(/^"|"$/g, '')]; }),);Verified by 4 asserts
assert.equal(sha256('abc'), 'ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad');assert.deepEqual(parseEnv('# db\nDB_URL="pg://x"\n\nPORT=3000\n'), { DB_URL: 'pg://x', PORT: '3000' }); const { URL } = require('node:url');const isSameOrigin = (a, b) => new URL(a).origin === new URL(b).origin; // facade over URL parsing rulesassert.equal(isSameOrigin('https://x.io/a', 'https://x.io:443/b'), true);assert.equal(isSameOrigin('https://x.io', 'http://x.io'), false);See also