Appearance
9.4.9 — Facade
What the original Gang of Four book says: Provide one unified interface to a set of interfaces in a subsystem. A Facade gives the subsystem a higher-level interface that makes it easier to use.
What that means when you are actually writing code: When getting one job done means driving five libraries in the right order with the right error handling, put that whole dance behind a single method — so callers state the goal, not the steps.
Facade is the most quietly common pattern in application code. Almost every "service" class you have ever written is a facade. Its value is not cleverness — it is removing cleverness from where it does not belong, so that ninety percent of callers can say what they want in one line, and the ten percent who need the raw subsystem can still reach it.
1. The story: the eight-step ritual copied into every controller
You build a feature: upload a video, transcode it, generate a thumbnail, store both, and record it in the database. The honest sequence touches five subsystems:
typescript
// inside the upload controller — the raw ritual
const raw = await s3.putObject({ Bucket: "in", Key: id, Body: buf }).promise();
const job = await ffmpeg.transcode({ input: `s3://in/${id}`, presets: ["720p", "1080p"] });
await waitForJob(job.id); // poll until done
const thumb = await thumbnailer.grab(`s3://in/${id}`, { at: "00:00:03" });
const outUrl = await s3.putObject({ Bucket: "out", Key: `${id}.mp4`, Body: job.output }).promise();
const thumbUrl = await s3.putObject({ Bucket: "out", Key: `${id}.jpg`, Body: thumb }).promise();
await db.videos.insert({ id, outUrl, thumbUrl, status: "ready", owner });
await cdn.invalidate([`/videos/${id}`]);Then the same eight steps get copied — with subtle variations and drift — into the mobile upload path, the admin re-encode tool, the bulk importer, and three tests. Now here is what goes wrong.
The order and the error handling are duplicated across five places, and they diverge: one path forgets the CDN invalidation, another polls with a different timeout, a third does not clean up the input object on failure. Each divergence is a bug.
Every caller is coupled to five subsystems — s3, ffmpeg, the thumbnailer, db, cdn. A change to any one of them (a new ffmpeg option, an S3 SDK upgrade) ripples into every controller.
The controllers are unreadable. A reader who wants to know "what does upload do?" has to parse a transcoding pipeline to find the answer, which is "it makes a video available."
Testing a controller requires standing up five mocks in the right sequence — so nobody does, and the paths are under-tested.
The knowledge that should be one thing — "publish a video" — is scattered as an eight-step ritual everywhere it is needed. Facade collects it into one place with one honest method.
typescript
const video = await videoService.publish({ file: buf, owner, id }); // ← the whole ritual, one call2. How you arrive at the pattern
Step 1 — Start naive. Call each subsystem directly at the point of use. This is correct when the task is genuinely one call, or when the caller legitimately needs fine control over each step.
Step 2 — Wait for the force. Accomplishing one conceptual operation requires driving several components in a specific order, with shared error handling, cleanup, and sequencing — and that orchestration is needed in more than one place, or is complex enough that callers should not have to know it.
Step 3 — Draw the line between what varies and what stays fixed.
| What varies | the internal steps — which subsystems, in what order, how errors and cleanup are handled |
| What stays fixed | the goal the caller cares about — "publish a video", stated once as a method |
The fixed goal becomes the facade's interface — an interface you invent for the caller's convenience. The varying steps hide behind it. Note that this "invented interface" is the key difference from Adapter, which must match a pre-existing required shape (9.4.7).
Step 4 — Decide when the choice is made. Composition at construction: the facade holds references to the subsystems it orchestrates (ideally injected, 9.4.6), and callers hold the facade.
Step 5 — Name the pattern and say what it costs. The name is Facade. The costs:
- It can turn into a god object, if it collects every operation in the system (section 10).
- It hides power. A caller with a real advanced need may find the simple interface is not enough. The fix is not to seal off the subsystem (section 7).
- It adds one more layer.
The benefit dominates for the common case: callers state intent, the orchestration lives once, and the subsystem can be restructured behind the facade without touching callers.
3. The mental model
In one sentence: a facade is the front desk of a hotel — you say "I'd like to check in," and one person handles housekeeping, billing, and keycards behind the counter; you never speak to those departments directly, but they are still there if you truly need them.
The analogy that makes it stick — the car ignition. Turning the key (or pressing Start) triggers a precise sequence: engage the starter motor, fuel the injectors, fire the ignition coils, monitor the sensors. The driver expresses one intent — "start" — and a facade drives a dozen subsystems in order. A mechanic can still open the hood and touch each subsystem directly; the facade simplifies the common interaction without removing the expert path.
When to reach for it. The signals: "this one operation touches five different services or libraries" · "we keep copying this sequence of calls" · "callers shouldn't need to know about all these internal components" · "give me a simple front for this complicated subsystem" · "I want a PaymentService so controllers don't talk to Stripe, the ledger, and the fraud checker directly." At architecture scale: "one API gateway in front of twelve microservices."
The distinction that matters most — Facade versus Adapter (again, because it is the most confused pair). Adapter changes an interface to match one the client already requires — the target shape pre-exists and is dictated by the client's needs. Facade invents a new, simpler interface for a subsystem where no such target existed — you designed it for convenience. The test: did the interface exist before I wrote the wrapper? If yes (I'm making X fit that shape) → Adapter. If no (I'm inventing a simpler front) → Facade. A secondary tell: Adapter usually wraps one adaptee; Facade usually orchestrates several subsystems.
4. Structure
The participants: the Facade (the unified interface, does the orchestration), the Subsystem classes (the components doing the real work — they do not know the facade exists), and the Client (uses the facade instead of the subsystems).
5. The implementation, line by line
typescript
export class VideoService { // the facade
constructor( // (1) subsystems INJECTED, not new-ed
private readonly storage: BlobStore,
private readonly transcoder: Transcoder, // each is itself a port ([9.4.7])
private readonly thumbs: Thumbnailer,
private readonly repo: VideoRepository,
private readonly cdn: Cdn,
private readonly log: Logger,
) {}
async publish(input: PublishInput): Promise<Video> { // (2) ONE intent, honest name
const { id, owner, file } = input;
await this.storage.put(`in/${id}`, file); // (3) the orchestration lives HERE, once
try {
const outputs = await this.transcoder.run(`in/${id}`, ["720p", "1080p"]); // (4) sequencing
const [thumb] = await this.thumbs.grab(`in/${id}`, { at: "00:00:03" });
const outUrl = await this.storage.put(`out/${id}.mp4`, outputs["1080p"]);
const thumbUrl = await this.storage.put(`out/${id}.jpg`, thumb);
const video = await this.repo.insert({ id, owner, outUrl, thumbUrl, status: "ready" });
await this.cdn.invalidate([`/videos/${id}`]);
this.log.info({ id, owner }, "video published");
return video;
} catch (e) {
await this.storage.delete(`in/${id}`).catch(() => {}); // (5) cleanup on failure, in ONE place
this.log.error({ id, err: e }, "publish failed");
throw new PublishError(id, { cause: e }); // (6) subsystem errors → one domain error
}
}
}Now the numbered lines.
(1) Subsystems are injected, not constructed inside. This is what keeps the facade testable and swappable — and note that each subsystem is ideally itself a port/adapter (9.4.7), so the facade orchestrates roles, not vendor SDKs. A facade over raw SDKs works, but a facade over adapters is the composition professionals reach for.
(2) The method names the goal, not the mechanism. publish, not putTranscodeThumbnailStoreRecordInvalidate. The name is the abstraction — if you cannot name the operation cleanly, the facade may be doing too many unrelated things (section 10, the god-object smell).
(3) Orchestration knowledge lives once. The order — store input → transcode → thumbnail → store outputs → record → invalidate — exists in exactly one place. Change the order or add a step (say, virus-scan the input), and one method changes, not five controllers.
(4) Sequencing and dependencies are the facade's real job. The thumbnail needs the input stored; the DB record needs the output URLs; the CDN invalidation needs the record. Getting these dependencies right, once, is exactly the value — every copy-pasted version was a chance to get them wrong.
(5) Cleanup and compensation live in one place. On failure, the input object is deleted so a failed publish does not leak storage. Doing this once is the difference between a system that self-heals and one that accumulates orphaned objects — which was the reality of the five scattered copies.
(6) Subsystem errors are translated to a domain error. Callers catch PublishError, not S3ServiceException or FfmpegError. This is the same error-translation discipline as Adapter — the facade shields callers from the subsystems' error vocabularies too.
What this does when you run it: videoService.publish({ file, owner, id }) runs the whole pipeline, returns a Video, cleans up on failure, logs once, and throws exactly one error type. Every caller — the controller, the importer, the admin tool — is now one line and depends on one thing.
5.1 The expert path stays open
A facade simplifies without imprisoning. If the admin re-encode tool needs to transcode with unusual presets that publish() does not expose, it should still be able to use the transcoder directly — the subsystems stay public. The mistake is making a facade the only door and then bloating it with every advanced option to compensate (section 10). The rule: facade for the common ninety percent, direct subsystem access for the expert ten percent.
typescript
// common path: the facade
await videoService.publish({ file, owner, id });
// expert path: reach past it deliberately, for a genuine one-off need
const custom = await transcoder.run(`in/${id}`, ["4k", "hdr"], { crf: 18, twoPass: true });5.2 The functional facade
A facade need not be a class — for a stateless orchestration, a function that closes over its dependencies is the lighter spelling:
typescript
export const makeVideoService = (deps: Deps) => ({
publish: (input: PublishInput) => publishImpl(deps, input),
unpublish: (id: VideoId) => unpublishImpl(deps, id),
});This is the composition-root-friendly form (9.4.6) and often what "service" means in a functional-core codebase.
6. Five domains, the same shape
(a) The service layer — the everyday facade. OrderService, PaymentService, AuthService — the classes between your controllers and your infrastructure are facades. PaymentService.charge() drives the gateway, the ledger, the fraud checker, and the receipt emailer; controllers call one method. This is the reason "service" is the most common class suffix in backend code, and recognising services as facades tells you how to keep them healthy: one cohesive area per service, orchestration inside, no god object.
(b) The API gateway — Facade at architecture scale. One public API in front of a dozen microservices (10.8.3): the client makes one call, and the gateway fans out, aggregates, and shields the client from the internal topology. It is literally "a Facade with an IP address," and the Backend-for-Frontend is the same pattern specialised per client type.
(c) An SDK's top-level object. new Stripe(key) presents charges, customers, webhooks — a facade over dozens of internal HTTP endpoints, retry logic, and pagination. Good libraries are facades; their whole job is to make a complex remote subsystem feel like a few method calls.
(d) A library wrapper for your domain. MediaConverter.toMp4(input) hiding ffmpeg's hundreds of flags; Mailer.send(msg) hiding MIME construction, SMTP, and retries; Search.query(q) hiding OpenSearch's DSL. Each turns an intimidating subsystem into one intent-shaped method for your team.
(e) Simplifying your own legacy subsystem. Even code you own can become subsystem-shaped after years of growth. A facade over a tangled legacy module gives new code a clean entry point and a seam behind which you can refactor the legacy internals incrementally — a facade is often the first move in a strangler-fig migration.
7. Variants
| Variant | Shape | Notes |
|---|---|---|
| Class facade | a service class holding subsystems | the default; the "service layer" |
| Functional facade | a function or object closing over deps | functional-core codebases |
| Module facade | a module's public exports over private internals | index.ts re-exporting a curated surface |
| API gateway / BFF | a network service fronting others | Facade at architecture scale |
| Opaque vs transparent | subsystem hidden vs still reachable | prefer transparent (expert path open) |
| Session facade | one facade per use case or workflow | keeps facades cohesive, avoids the god object |
Module-as-facade is worth its own mention, because you use it constantly: a package's index.ts that re-exports a small, curated public surface while keeping the internal files private is a facade over the module's structure — callers import from one place and the internals can be reorganised freely (3.6.5). This is the cheapest facade there is, and the one most teams under-use.
8. Where you already use it
| What you have used | What it stands in front of |
|---|---|
fetch(url) | DNS, TCP, TLS, connection reuse, redirects, chunked decoding |
Any OrderService class you have written | repositories, the payment client, the email sender |
A package's index.ts with three exports | thirty private files behind it |
JSON.parse(text) | a full tokenizer and parser (3.1) |
fetch is the clearest one, and it is worth counting what it saves you. One line — await fetch(url) — and behind it the browser looks up the hostname, opens a connection, negotiates encryption, possibly reuses a connection it already had, follows redirects, and reassembles a response that arrived in pieces. Every one of those is a real subsystem with its own failure modes, and none of them appear in your code. That is a deep facade: a tiny front door, an enormous amount of work behind it.
9. Ways to get it wrong
The god object. A facade that collects every operation in the app (
AppServicewith 80 methods touching everything) has as many reasons to change as the whole system (9.3.5, Single Responsibility).The fix: one facade per cohesive area or use case (session facades); split when the name stops being honest.
Leaking subsystem types.
publish()returning anS3.PutObjectOutputor throwingFfmpegErrorre-couples callers to the internals.The fix: translate to domain types and one domain error, like Adapter.
Sealing off the subsystem. Making the facade the only access and then bloating it with every advanced flag to serve the ten-percent expert cases.
The fix: keep the subsystems reachable; facade the common path only (section 5.1).
A facade that only forwards. One method that just calls one subsystem method adds a layer with no orchestration. That is a pass-through, not a facade — delete it unless it exists to translate types (and then it's an Adapter).
Business logic hidden in the facade. A facade orchestrates; when it starts making domain decisions (pricing rules, eligibility), those belong in domain objects the facade calls, not in the facade itself.
The fix: facade coordinates, domain decides (9.2.3).
The facade constructing its own subsystems.
new S3Client()inside the facade makes it untestable and hard-codes lifetimes.The fix: inject them (9.4.6).
Too many facades over facades. Layers of facades each forwarding to the next.
The fix: a facade should collapse complexity, not add a floor to the tower.
10. Facade compared with its neighbours
| Compared with | The difference | Choose Facade when |
|---|---|---|
| Adapter | Adapter matches a required interface (one adaptee); Facade invents a simpler one (many subsystems) | you're simplifying, not fitting a target |
| Decorator | Decorator keeps the same interface and adds behaviour; Facade presents a new, simpler interface | you want a simpler front, not a wrapped-same-shape |
| Proxy | Proxy keeps the same interface and controls access to one object; Facade fronts many | the goal is simplification over a group |
| Mediator (9.4.1 section 2) | sits between components that talk to each other | callers come in from outside, one way |
| Abstract Factory | a Factory creates a family; a Facade uses and orchestrates a subsystem | the concern is calling, not constructing |
The last row is the one people muddle, so it is worth spelling out. Both a facade and a mediator cut down the number of things that have to know about each other, and both do it by putting one object in the middle. What differs is who is talking to whom.
A facade faces outward. Callers who live outside the group come in through it to get one job done, and the traffic only ever flows in that one direction: caller, then facade, then the parts inside.
A mediator faces inward. The objects it serves are peers who all live inside the same group, and the traffic flows both ways — a peer tells the mediator something happened, and the mediator turns round and tells other peers what to do about it. The peers never hold a reference to each other, only to the mediator.
So a facade makes a group easier to use from outside, and a mediator makes a group easier to wire up on the inside.
11. Interview calibration
The 45-second answer, in the order you would say it:
Facade puts a simple, intent-named interface in front of a complex subsystem — the several libraries or services you must orchestrate to get one job done. Instead of every caller copying the eight-step ritual and coupling to five components, they call
videoService.publish()and the orchestration, sequencing, error handling, and cleanup live once behind it.The interface is one you invent for the caller's convenience — that's the difference from Adapter, which has to match a pre-existing required shape. Most service classes are facades. The two things to keep right: don't let it become a god object — one facade per cohesive area — and don't seal off the subsystem, so the rare expert caller can still go direct.
Follow-up questions, with the seed of each answer:
- "Facade versus Adapter?" — Facade invents a simpler interface over many components; Adapter matches a required interface over one. Simplify versus fit.
- "Is a service class a Facade?" — Usually yes — it orchestrates infra and domain behind intent-named methods. Keep it cohesive.
- "Facade versus Mediator?" — Facade is a one-way front for clients; Mediator coordinates peers talking to each other.
- "How do you keep it from becoming a god object?" — Session facades: one per use case or area; split when the class name stops describing it.
- "Does the facade hide the subsystem completely?" — No — it simplifies the common path; the subsystem stays reachable for expert needs.
Recall
- Facade = one intent-named interface over a subsystem you must orchestrate. It collects a multi-step ritual (order, sequencing, error handling, cleanup) into one place so callers state the goal, not the steps — killing the duplication-and-drift of the same sequence copied across controllers.
- Versus Adapter (the key distinction): Facade invents a simpler interface where none pre-existed (and usually fronts several subsystems); Adapter matches a required interface (usually one adaptee). Test: did the interface exist before the wrapper? Yes → Adapter; no → Facade.
- Do it right:
- Pass the subsystems in, so they can be swapped. Ideally they are ports and adapters, so the facade coordinates roles rather than specific vendors.
- Name the method after the goal.
- Put the order of steps, the cleanup when something fails, and error translation (subsystem errors turned into one domain error) in that one place.
- Keep the expert path open. Simplify, but do not lock the subsystem away.
- Where you meet it every day:
- The service layer, such as
OrderServiceandPaymentService. Most services are facades. - The API gateway or BFF, which is a facade for a whole architecture, "a facade with an IP address".
- The top-level object of an SDK.
- A package's
index.ts, its public surface. It is the cheapest facade, and the most under-used.
- The service layer, such as
- Misuse: the god object (collects everything — split into cohesive session facades) · leaking subsystem types or errors · sealing the subsystem then bloating the facade · a pure pass-through (no orchestration) · business logic in the facade (it coordinates; domain objects decide) · constructing its own subsystems.
Self-test: What is the one-test difference between Facade and Adapter? Name the four things that belong in the one place a facade centralises. Why keep the subsystem reachable? What makes a service class a facade, and what makes it a god object? Facade versus Mediator — which direction does each simplify?
Quiz Bank
FoundationalDerive Facade from scattered orchestration code and name everything it centralizes.
Naive: call each subsystem directly where needed — fine when the task is one call or the caller legitimately needs step-level control.
The force: one conceptual operation ("publish a video") requires driving several components (storage, transcoder, thumbnailer, database, CDN) in a specific order with shared error handling and cleanup — and that orchestration is needed in more than one place.
What breaks without the pattern:
- The multi-step sequence gets copied into every caller, and the copies slowly drift apart. One path forgets to clear the CDN cache. Another uses a different timeout. A third leaves the uploaded file behind when something fails.
- Every caller depends on five subsystems, so a change to any one of them ripples everywhere.
- Controllers become hard to read, because the simple goal, "make a video available", is buried inside a video-processing pipeline.
- Checking each caller means faking all five subsystems in the right order, so in practice some paths never get checked.
The varies/fixed line: the internal steps vary (which subsystems, order, error and cleanup handling); the goal is fixed ("publish").
The pattern: a facade exposing the goal as one method, with the orchestration behind it.
What it gathers into one place:
- The order of the steps, and which step needs which. The thumbnail needs the stored upload. The database record needs the output URLs.
- Error handling. Errors from each subsystem are turned into one error that makes sense to the caller.
- Cleanup when something fails. For example, delete the uploaded file, so a failed publish leaves nothing behind.
- Logging and monitoring for the whole operation.
The cost: the risk of becoming a god object, one added layer, and hidden power (mitigated by keeping the subsystem reachable). Every caller becomes one line depending on one thing.
FoundationalGive the precise distinction between Facade and Adapter and a test to tell them apart.
Both wrap something and present an interface, and both keep callers from touching the wrapped thing directly — but they solve opposite-facing problems.
Adapter changes an interface to match one the client already requires. The target shape pre-exists and is dictated by the client's needs (your domain already expects a PaymentGateway, so StripeGateway is written to fit it); Adapter usually wraps a single adaptee; its purpose is compatibility.
Facade invents a new, simpler interface for a subsystem where no such target existed. You design videoService.publish() for convenience, choosing its shape freely; a facade usually orchestrates several subsystems; its purpose is simplification.
The test: ask did the interface exist before I wrote the wrapper? If a required shape already existed and you are making the foreign thing conform to it → Adapter. If you are inventing a simpler front because the raw subsystem is unpleasant to use → Facade.
A secondary tell: count the wrapped things — one foreign object suggests Adapter, several coordinated subsystems suggests Facade. A tertiary tell: an adapter's method roughly mirrors an adaptee method (translated), while a facade's method typically triggers many subsystem calls in sequence.
They also combine happily: a facade often orchestrates several adapters (the VideoService facade calling a BlobStore adapter, a Transcoder adapter, and so on), which is the professional arrangement — Adapter isolates each external dependency, and Facade orchestrates the isolated set.
AppliedWhy is 'keep the expert path open' a defining property of a good facade, and what goes wrong if you seal the subsystem?
A facade's value proposition is simplifying the common case — the ninety percent of callers who want "publish a video" with sensible defaults. But there is always a minority of legitimate advanced needs: an admin tool that must transcode with unusual presets, a debugging path that needs a single subsystem, a performance-critical caller that wants to skip a step. A good facade serves the common case without removing the ability to reach the subsystems directly — the subsystem classes stay public, and the expert caller uses them deliberately.
What goes wrong if you seal the subsystem (make the facade the only door): the facade must grow to accommodate every advanced need, because there is nowhere else to go. So publish() sprouts a dozen optional parameters, such as presets, crf, twoPass, skipThumbnail and customCdnPaths, each one for a single rare caller. The facade drifts into one huge method with too many parameters. It is no longer simple, because the common case now has to wade through a huge signature. And it is still not complete, because it cannot do everything the raw subsystem can. You have destroyed the facade's one job — simplification — in the name of totality.
The correct model is the car: the ignition simplifies starting, but a mechanic can still open the hood; the facade and the subsystem coexist. Concretely: keep publish() lean for the ninety percent, and let the ten percent call transcoder.run(...) directly for their one-off. The discipline is that reaching past the facade should be a deliberate, visible choice (so reviewers notice it and can ask "should this be a new facade method?"), not the default — but it must be possible, or the abstraction becomes a cage.
InterviewMost backend 'service' classes are facades. What does that tell you about how to design and maintain them, and when does a service stop being a good facade?
Recognising services as facades gives you a design checklist and a decay detector.
Design implications: first, a service should be named for a cohesive area or capability (PaymentService, OrderService), because a facade's method names are its abstraction — if you cannot name the service's responsibility in a few words, it is fronting an incoherent subsystem. Second, it should orchestrate, holding injected subsystems (repositories, adapters, other services) and coordinating them, while the actual domain decisions live in domain objects the service calls — a facade coordinates, domain objects decide (9.2.3). Third, it should translate infrastructure errors to domain errors and infrastructure types to domain types, shielding controllers exactly as a facade shields callers. Fourth, its subsystems should be injected so the service is testable by faking them, and the service's tests should assert orchestration (sequence, cleanup, error paths) rather than re-testing the subsystems.
When it stops being a good facade, and turns into a god object. The warning signs:
- A service with dozens of methods across unrelated areas, such as a
UserServicethat also does billing, notifications and reporting. - A constructor with fifteen dependencies passed in.
- A class that changes for many unrelated reasons (9.3.9, Single Responsibility).
- Methods that no longer share a common theme.
The fix is to split it by use case or by area of the business, so each facade sits in front of one subsystem whose parts belong together. A workflow that spans several areas becomes an explicit coordinator over several focused services, not one giant service.
The meta-point for an interview: "service layer" is not a magic word — a service is a facade, and it is healthy exactly as long as it stays a cohesive, orchestrating front, and unhealthy the moment it becomes a dumping ground.
StaffYour monolith has controllers calling repositories, third-party SDKs, and each other directly — 200 endpoints, no service layer, and every cross-cutting change (add audit logging to all writes, swap the email vendor) requires touching dozens of controllers. Design a facade-based service layer and a migration that delivers value incrementally.
The diagnosis is missing facades: business operations have no single home, so they are re-expressed in every controller, and cross-cutting changes have no seam to attach to. The target is a service layer where each cohesive capability is a facade orchestrating repositories, adapters, and domain objects, and controllers become thin (parse request → call one service method → format response).
The design, in three steps.
First, group by what the business does, not by data tables. Sort the 200 endpoints by business operation into services whose parts belong together: OrderService, PaymentService, CatalogService and NotificationService. Size each one so you can name its job in a phrase, and so it has only a few dependencies. Avoid one giant AppService, which is a god object. Also avoid one facade per endpoint, which groups nothing.
Second, each service is a facade, with its dependencies passed in. Those dependencies are themselves ports and adapters (9.4.7). The email vendor sits behind a Mailer port, and the SDKs sit behind adapters. Now "swap the email vendor" means writing one new adapter. And "add audit logging to every write" means one 9.4.8 decorator on the repository port, added once in the composition root. That is the payoff. Changes that cut across everything attach to the seams the facade layer creates, instead of to dozens of controllers.
Third, move business logic into domain objects that the services call. Services coordinate, and domain objects decide. Otherwise you have only moved the mess into fat services.
Migration — incremental, value-first, never a rewrite. First, start with the capability that has the most duplication or the most pending cross-cutting pain (if audit logging is the driver, start with the write-heavy operations). Second, extract one service behind a facade, move the orchestration out of the controllers that duplicate it, and point those controllers at the service — a bounded PR per capability, each independently shippable and independently valuable (the first extraction that removes a five-way duplication is a visible win that funds the rest). Third, as each service lands, its dependencies become ports — the first time PaymentService is extracted, Stripe goes behind an adapter, immediately enabling the vendor-swap and testability benefits for that capability. Fourth, cross-cutting concerns become decorators or middleware at the composition root once the ports exist — audit logging wraps the repository port, so every service that uses it gets auditing without change.
Fifth, ratchet it: a lint rule forbidding controllers from importing repositories or SDKs directly, with a shrinking allowlist of not-yet-migrated controllers, so the architecture can only improve.
What each step buys, measured: controllers touched per cross-cutting change drops toward one (the decorator) or one (the adapter); test setup per capability drops from N mocks to one faked service; and endpoints sharing an operation stop drifting.
The plan in a few sentences: the monolith's problem is not that it is a monolith. The problem is that it has no service layer, so every business operation is scattered. We add facades one capability at a time, and put their dependencies behind ports as we go. Then a change that cuts across the system no longer means editing dozens of files, because there is finally one place to make it.
Flashcards
FlashFacade in one line
One intent-named interface over a subsystem you must orchestrate. Callers state the goal; sequencing, error handling and cleanup live once behind it.
FlashFacade vs Adapter
Adapter matches a pre-existing required interface (one adaptee); Facade invents a simpler one (many subsystems). Test: did the interface exist before the wrapper?
FlashService classes
Most *Service classes are facades: orchestrate injected repos/adapters/domain, name methods for the goal, translate errors to domain types. Keep cohesive.
FlashKeep the expert path open
Facade simplifies the common 90% without sealing the subsystem. Sealing it forces the facade to bloat with every advanced option and become a god method.
FlashFacade at scale
API gateway / BFF = a Facade with an IP address, fronting many microservices. A package's index.ts is a facade over its private files.
FlashFacade misuse
God object (split into session facades) · leaking subsystem types/errors · pure pass-through · business logic in the facade · constructing its own subsystems.
Scenario Drill
DrillYou're building a checkout operation that must: validate the cart, reserve inventory, calculate tax, charge payment, create an order record, decrement inventory, send a confirmation email, and emit an analytics event — with correct rollback if any step fails after payment. Design this as a facade, decide what belongs in the facade versus elsewhere, and handle the partial-failure problem honestly.
Checkout is the textbook facade: one business intent orchestrating many subsystems in a strict order with real failure semantics — and the honest hard part is not the happy path but what happens when step 6 fails after step 4 took the customer's money.
The facade. CheckoutService.checkout(cart, payment, customer): Order holds injected ports — InventoryService, TaxCalculator, PaymentGateway (an adapter), OrderRepository, Mailer, EventBus — and orchestrates: validate cart → reserve inventory → calculate tax → charge payment → persist order → confirm inventory decrement → send email → emit analytics.
What belongs in the facade versus elsewhere — the design judgment. The sequence and the failure/compensation policy belong in the facade; that is its job. The decisions do not: cart validation rules live in the Cart domain object, tax logic lives in TaxCalculator, inventory-reservation rules live in InventoryService — the facade calls them and reacts, it does not embody them (or it becomes a god object with all the business logic, section 10). Email and analytics are side effects the facade triggers but must not let fail the operation — a confirmation email bouncing must not undo a paid order.
The partial-failure problem, handled honestly — this is where junior and senior answers diverge. The steps split into two phases at the payment boundary. Before payment (validate, reserve, tax), failures are cheap: release the inventory reservation and return an error; nothing irreversible happened.
After payment (persist order, decrement inventory, notify), a failure cannot be handled by naive rollback, because you cannot un-charge a card with a local try/catch — the money moved in an external system. So the facade uses compensation, not rollback (10.8.4's saga thinking at LLD scale): if order persistence fails after a successful charge, the compensating action is a refund (or, better, a retry of persistence first, since the charge succeeding while the DB write fails is a transient-looking condition worth one retry before refunding). Critically, the charge and the order record should be tied by an idempotency key so a retry of the whole checkout does not double-charge (9.6.3) — the facade generates it once per checkout attempt and passes it to the gateway.
The genuinely unrecoverable middle states (charged, refund also failing) must be made durable and visible: write a record to a reconciliation or dead-letter store and alert, because a payment system that silently loses track of a charged-but-unfulfilled order is a financial incident, not a bug.
What must happen together, and what can happen later. If the order record and the stock reduction live in the same database, put them in one transaction, so both succeed or both fail. The email and the analytics event can happen after the transaction commits. Send them through an outbox, so they are not lost if the process dies between the commit and the send (10.8.4).
Why a facade is exactly right here: this logic is intricate. It covers the order of steps, where payment happens, how to undo earlier steps when a later one fails, how to make repeats safe, and the outbox. It must be identical for every way into checkout: web, mobile, one-click and admin. So it has to live in exactly one place. If it were scattered across controllers, the undo logic would drift apart, and sooner or later some path would charge twice or leave stock reserved forever.
The design in a few sentences: checkout is a facade, and what really matters inside it is how it handles failure. When nothing goes wrong, it is just eight calls in order. Its value is that the payment step, the undo steps, safe repeats and the work done after commit are all decided once, correctly, in a place every checkout path shares. Otherwise a double-charge bug is waiting in whichever controller someone copies next.