One door into a subsystem
A hotel reception desk is a facade. You don’t call housekeeping, the kitchen, the laundry and the taxi company yourself; you tell reception “a late checkout and a taxi at noon, please”, and they deal with everyone. The departments still exist and still do the work; you just don’t have to know them.
In code, a facade is a class with a few simple methods that hide a group of classes working together. Screens call checkout.placeOrder(cart); the facade calls inventory, payment, shipping and the rest, in the right order.
A Facade defines a higher-level interface over a subsystem, reducing coupling between clients and the subsystem’s many types. Unlike an Adapter it doesn’t make an existing interface fit another; it creates a new, narrower one for the common use cases. It typically holds no business rules of its own: it orchestrates (sequencing, transactions or compensation, error mapping) and delegates. In layered architectures it often appears as an application service or use case; at module boundaries, as the module’s public API.
Before: Every caller choreographs six classes
// In CheckoutViewModel, and again in BuyAgainViewModel and in the notification handler
let quote = try await shipping.quote(for: cart.items, to: address)
try await inventory.reserve(cart.items)
let payment = try await payments.charge(cents: cart.totalCents + quote.cents, token: token)
let order = try await orders.create(cart: cart, shipping: quote, paymentId: payment.id)
analytics.track(.purchase(orderId: order.id, cents: order.totalCents))
cart.clear()// In CheckoutViewModel, and again in BuyAgainViewModel and in the notification handler
val quote = shipping.quote(cart.items, address)
inventory.reserve(cart.items)
val payment = payments.charge(cart.totalCents + quote.cents, token)
val order = orders.create(cart, quote, payment.id)
analytics.track(Event.Purchase(order.id, order.totalCents))
cart.clear()// In CheckoutViewModel, and again in BuyAgainViewModel and in the notification handler
ShippingQuote quote = shipping.quote(cart.items(), address);
inventory.reserve(cart.items());
Payment payment = payments.charge(cart.totalCents() + quote.cents(), token);
Order order = orders.create(cart, quote, payment.id());
analytics.track(new PurchaseEvent(order.id(), order.totalCents()));
cart.clear();// In CheckoutViewModel, and again in BuyAgainViewModel and in the notification handler
const quote = await shipping.quote(cart.items, address);
await inventory.reserve(cart.items);
const payment = await payments.charge(cart.totalCents + quote.cents, token);
const order = await orders.create(cart, quote, payment.id);
analytics.track({ type: "purchase", orderId: order.id, cents: order.totalCents });
cart.clear();After: One facade, one call
final class CheckoutFacade {
private let shipping: ShippingService
private let inventory: InventoryService
private let payments: PaymentGateway
private let orders: OrderRepository
private let analytics: Analytics
init(shipping: ShippingService, inventory: InventoryService, payments: PaymentGateway,
orders: OrderRepository, analytics: Analytics) {
self.shipping = shipping; self.inventory = inventory; self.payments = payments
self.orders = orders; self.analytics = analytics
}
func placeOrder(_ cart: Cart, address: Address, token: String) async throws -> Order {
let quote = try await shipping.quote(for: cart.items, to: address)
let reservation = try await inventory.reserve(cart.items)
do {
let payment = try await payments.charge(cents: cart.totalCents + quote.cents, token: token)
let order = try await orders.create(cart: cart, shipping: quote, paymentId: payment.id)
analytics.track(.purchase(orderId: order.id, cents: order.totalCents))
return order
} catch {
await inventory.release(reservation) // undo the step that succeeded
throw error
}
}
}
// Every caller:
let order = try await checkout.placeOrder(cart, address: address, token: token)class CheckoutFacade(
private val shipping: ShippingService,
private val inventory: InventoryService,
private val payments: PaymentGateway,
private val orders: OrderRepository,
private val analytics: Analytics,
) {
suspend fun placeOrder(cart: Cart, address: Address, token: String): Order {
val quote = shipping.quote(cart.items, address)
val reservation = inventory.reserve(cart.items)
return try {
val payment = payments.charge(cart.totalCents + quote.cents, token)
orders.create(cart, quote, payment.id).also {
analytics.track(Event.Purchase(it.id, it.totalCents))
}
} catch (e: Exception) {
inventory.release(reservation) // undo the step that succeeded
throw e
}
}
}
// Every caller:
val order = checkout.placeOrder(cart, address, token)public final class CheckoutFacade {
private final ShippingService shipping;
private final InventoryService inventory;
private final PaymentGateway payments;
private final OrderRepository orders;
private final Analytics analytics;
public CheckoutFacade(ShippingService shipping, InventoryService inventory, PaymentGateway payments,
OrderRepository orders, Analytics analytics) {
this.shipping = shipping; this.inventory = inventory; this.payments = payments;
this.orders = orders; this.analytics = analytics;
}
public Order placeOrder(Cart cart, Address address, String token) {
ShippingQuote quote = shipping.quote(cart.items(), address);
Reservation reservation = inventory.reserve(cart.items());
try {
Payment payment = payments.charge(cart.totalCents() + quote.cents(), token);
Order order = orders.create(cart, quote, payment.id());
analytics.track(new PurchaseEvent(order.id(), order.totalCents()));
return order;
} catch (RuntimeException e) {
inventory.release(reservation); // undo the step that succeeded
throw e;
}
}
}
// Every caller:
Order order = checkout.placeOrder(cart, address, token);export class CheckoutFacade {
constructor(
private readonly shipping: ShippingService,
private readonly inventory: InventoryService,
private readonly payments: PaymentGateway,
private readonly orders: OrderRepository,
private readonly analytics: Analytics,
) {}
async placeOrder(cart: Cart, address: Address, token: string): Promise<Order> {
const quote = await this.shipping.quote(cart.items, address);
const reservation = await this.inventory.reserve(cart.items);
try {
const payment = await this.payments.charge(cart.totalCents + quote.cents, token);
const order = await this.orders.create(cart, quote, payment.id);
this.analytics.track({ type: "purchase", orderId: order.id, cents: order.totalCents });
return order;
} catch (e) {
await this.inventory.release(reservation); // undo the step that succeeded
throw e;
}
}
}
// Every caller:
const order = await checkout.placeOrder(cart, address, token);- Callers know one class. The view models no longer depend on six services, so they’re simpler to read and to test (one fake facade instead of six fakes).
- The order of steps lives in one place: quote, reserve stock, charge, create the order. When it changes, it changes once.
- Failures are handled once: if the payment fails, the facade releases the reserved stock, so no caller can forget.
- The subsystem stays available. An admin screen that only needs
inventorycan still use it directly; the facade is a convenience, not a wall.
Tap the line with the bug.
func placeOrder(_ cart: Cart, address: Address, token: String) async throws -> Order {
let quote = try await shipping.quote(for: cart.items, to: address)
let payment = try await payments.charge(cents: cart.totalCents + quote.cents, token: token)
try await inventory.reserve(cart.items)
return try await orders.create(cart: cart, shipping: quote, paymentId: payment.id)
}suspend fun placeOrder(cart: Cart, address: Address, token: String): Order {
val quote = shipping.quote(cart.items, address)
val payment = payments.charge(cart.totalCents + quote.cents, token)
inventory.reserve(cart.items)
return orders.create(cart, quote, payment.id)
}public Order placeOrder(Cart cart, Address address, String token) {
ShippingQuote quote = shipping.quote(cart.items(), address);
Payment payment = payments.charge(cart.totalCents() + quote.cents(), token);
inventory.reserve(cart.items());
return orders.create(cart, quote, payment.id());
}async placeOrder(cart: Cart, address: Address, token: string): Promise<Order> {
const quote = await this.shipping.quote(cart.items, address);
const payment = await this.payments.charge(cart.totalCents + quote.cents, token);
await this.inventory.reserve(cart.items);
return this.orders.create(cart, quote, payment.id);
}Hint: The last lamp in stock is in two carts. Both customers tap Buy at the same moment. What happens to the second one?
The card is charged before stock is reserved. When the item sells out between those two lines, reserve throws, the error reaches the screen as “Out of stock”, and the customer has been charged for an order that doesn’t exist. Nothing refunds them; support will.
A facade’s main job is exactly this: owning the order of steps and what happens when one fails. Do the cheap, reversible steps first (quote, reserve), the hard-to-undo step (charging money) as late as possible, and undo earlier steps when a later one fails, as in the facade above. Steps that can’t be undone afterwards, like a charge, need a compensating action (a refund) if anything after them fails.
Predict the output
A class wraps a vendor's image SDK. It has the same methods as the SDK but retries failed downloads three times. Which pattern is it?
It keeps the same interface and adds behavior (retries): that's a [Decorator](/patterns/decorator). A Facade would offer a new, simpler interface over several classes, like loadAvatar(userId) hiding the SDK, the cache and the resizer. An [Adapter](/patterns/adapter) would make the SDK fit a different interface your app already has. All three wrap something; what differs is what happens to the interface.
A media player facade
To play a podcast episode, a screen currently: asks AudioSession to activate for playback, downloads the file with Downloader unless FileCache has it, creates a Player with the file URL, seeks to the position saved in ProgressStore, starts playback, and updates NowPlayingInfo (the lock-screen controls). Design a PodcastPlayer facade: which methods does it expose, and what stays hidden?
›
Think about what a screen wants to say, not what the classes can do: “play this episode”, “pause”, “skip 30 seconds”. Everything about files, sessions and lock-screen info is detail.
SolutionTry it yourself first
final class PodcastPlayer {
func play(_ episode: Episode) async throws {
try audioSession.activateForPlayback()
let file = try await cache.file(for: episode.id) ?? downloader.download(episode.url)
player.load(file)
player.seek(to: progress.position(for: episode.id))
player.play()
nowPlaying.update(episode, position: player.position)
}
func pause() { player.pause(); progress.save(player.position, for: player.episodeId) }
func skip(seconds: Double) { player.seek(to: player.position + seconds) }
// collaborators injected through init
}class PodcastPlayer(/* collaborators injected */) {
suspend fun play(episode: Episode) {
audioSession.activateForPlayback()
val file = cache.file(episode.id) ?: downloader.download(episode.url)
player.load(file)
player.seekTo(progress.position(episode.id))
player.play()
nowPlaying.update(episode, player.position)
}
fun pause() { player.pause(); progress.save(player.position, player.episodeId) }
fun skip(seconds: Long) = player.seekTo(player.position + seconds)
}public final class PodcastPlayer {
public CompletableFuture<Void> play(Episode episode) {
audioSession.activateForPlayback();
return cache.file(episode.id())
.map(CompletableFuture::completedFuture)
.orElseGet(() -> downloader.download(episode.url()))
.thenAccept(file -> {
player.load(file);
player.seekTo(progress.position(episode.id()));
player.play();
nowPlaying.update(episode, player.position());
});
}
public void pause() { player.pause(); progress.save(player.position(), player.episodeId()); }
public void skip(long seconds) { player.seekTo(player.position() + seconds); }
// collaborators injected through the constructor
}export class PodcastPlayer {
async play(episode: Episode): Promise<void> {
await this.audioSession.activateForPlayback();
const file = (await this.cache.file(episode.id)) ?? (await this.downloader.download(episode.url));
this.player.load(file);
this.player.seek(this.progress.position(episode.id));
this.player.play();
this.nowPlaying.update(episode, this.player.position);
}
pause(): void { this.player.pause(); this.progress.save(this.player.position, this.player.episodeId); }
skip(seconds: number): void { this.player.seek(this.player.position + seconds); }
// collaborators injected through the constructor
}The facade exposes intents (play, pause, skip). Sessions, caching, downloads, saved progress and lock-screen info are hidden, so the episode screen, the mini player and the car-play screen all behave the same way.
Facade, use case, service
The same shape goes by many names: an application service in layered architecture, a use case or interactor in Clean Architecture, a module API in modular apps. What they share: one entry point per user intent, orchestration without business rules, and dependencies on abstractions so the facade can be tested with fakes. A facade over a remote system (a gateway) also maps transport errors and DTOs, overlapping with Adapter; that’s fine, as long as one class doesn’t take on both a dozen intents and every translation.
- One higher-level entry point over a subsystem: orchestrates and delegates, holds no domain rules; subsystem stays accessible.
- Owns sequencing and failure handling: reversible steps first, irreversible ones (charges) late, compensation on failure.
- vs Adapter (fit a different existing interface), Decorator (same interface, added behavior), Proxy (same interface, controlled access).
- Appears as application services, use cases, module APIs, gateways.
Check this part
What does a facade do?
Can code still use the subsystem classes directly when there's a facade?
In what order should a checkout facade charge the card and reserve stock?