The ticket says “checkout is failing in the app; the web one works.” You open CheckoutController and count collaborators: DiscountPolicy, PaymentGateway, OrderRepository, a fraud SDK, a tax service, a notifier. The mobile team copied that conversation into MobileCheckoutController and missed the tax quote. Both controllers still compile. One of them undercharges.
That is Facade’s entire complaint. From the Design Patterns Roadmap: callers should talk to one simple front, not to every type in a messy subsystem.
This post stays on the shared Order / PaymentGateway / DiscountPolicy / OrderProcessor lab. We do not re-lecture the three families. We hide a conversation that checkout should not keep repeating.
The controller that knows everyone
Here is the web checkout action after fraud and tax landed in the same method as the charge. Honest code — each call was a reasonable ticket:
public class CheckoutController {
private final FraudClient fraud;
private final TaxService tax;
private final DiscountPolicy discount;
private final PaymentGateway gateway;
private final OrderRepository orders;
private final OrderNotifier notifier;
public ResponseEntity<PlacementDto> place(@RequestBody PlaceOrderRequest request) {
Order order = request.toOrder();
FraudDecision fraudDecision = fraud.screen(order);
if (fraudDecision.blocked()) {
return ResponseEntity.status(403).body(PlacementDto.blocked(fraudDecision.reason()));
}
BigDecimal taxAmount = tax.quote(order);
BigDecimal payable = discount.payable(order).add(taxAmount);
PaymentResult result = gateway.charge(order, payable);
if (!result.approved()) {
return ResponseEntity.status(402).body(PlacementDto.declined(result.failureReason()));
}
orders.markPaid(order.id(), result.reference());
notifier.orderPlaced(order);
return ResponseEntity.ok(PlacementDto.paid(result.reference(), payable));
}
}
Now price the next two changes:
Add Apple Pay as a second gateway -> edit every controller that names PaymentGateway
Change fraud-before-tax to tax-first -> web, mobile, and the CLI replay tool
Test "declined charge does not notify" -> construct HTTP, fraud, tax, gateway, repo, notifier
Three costs, and none of them are about HTTP status codes. The controllers have become the owners of the checkout conversation.
Note: A controller that maps a request to an Order and calls one thing is doing its job. The problem is a subsystem protocol — screen, quote, price, charge, persist, notify — sitting in every entry point that can place an order.
What Facade actually is
Two parts, one promise:
| Part | Job |
|---|---|
| Subsystem | The real types: fraud, tax, discount, gateway, repository, notifier. They stay. |
| Facade | One type with one (or a few related) methods that run the conversation. |
The facade does not replace the subsystem. It is the only type most callers are allowed to name. If CheckoutController still injects TaxService “just for a header,” you have not hidden the conversation; you have added a seventh constructor argument next to the facade.
The Gang of Four name is easy to over-read. You do not need a class named Facade. You need a verb the caller already understands — here, “place this order”:
public class CheckoutFacade {
private final FraudClient fraud;
private final TaxService tax;
private final DiscountPolicy discount;
private final PaymentGateway gateway;
private final OrderRepository orders;
private final OrderNotifier notifier;
public CheckoutFacade(
FraudClient fraud,
TaxService tax,
DiscountPolicy discount,
PaymentGateway gateway,
OrderRepository orders,
OrderNotifier notifier) {
this.fraud = fraud;
this.tax = tax;
this.discount = discount;
this.gateway = gateway;
this.orders = orders;
this.notifier = notifier;
}
public PlacementResult place(Order order) {
FraudDecision fraudDecision = fraud.screen(order);
if (fraudDecision.blocked()) {
return PlacementResult.blocked(fraudDecision.reason());
}
BigDecimal taxAmount = tax.quote(order);
BigDecimal payable = discount.payable(order).add(taxAmount);
PaymentResult charged = gateway.charge(order, payable);
if (!charged.approved()) {
return PlacementResult.declined(charged.failureReason());
}
orders.markPaid(order.id(), charged.reference());
notifier.orderPlaced(order);
return PlacementResult.paid(charged.reference(), payable);
}
}
PlacementResult is a small outcome type the HTTP layer can map. The facade returns domain results. It does not import Spring.
Controllers shrink to translation
Web and mobile now share the conversation. Each entry point keeps only the work that is actually about its protocol:
public class CheckoutController {
private final CheckoutFacade checkout;
public CheckoutController(CheckoutFacade checkout) {
this.checkout = checkout;
}
public ResponseEntity<PlacementDto> place(@RequestBody PlaceOrderRequest request) {
PlacementResult result = checkout.place(request.toOrder());
return switch (result.status()) {
case PAID -> ResponseEntity.ok(PlacementDto.paid(result.reference(), result.payable()));
case DECLINED -> ResponseEntity.status(402).body(PlacementDto.declined(result.reason()));
case BLOCKED -> ResponseEntity.status(403).body(PlacementDto.blocked(result.reason()));
};
}
}
The CLI replay tool, the admin “place on behalf of,” and the mobile BFF call the same place. Changing fraud-before-tax is one method. Adding a second gateway is a Strategy (or a constructor argument) behind the facade, not a diff in three controllers.
OrderProcessor can remain the inner policy if you already have it — charge and persist — and the facade can hold it instead of talking to PaymentGateway directly. Either shape is fine. What matters is callers outside checkout name one type.
Note: Keep the facade a conversation, not a pass-through. place that only forwards to processor.process and does nothing else is an extra class. Wait until a second collaborator (fraud, tax, notify) actually belongs in the same talk.
What the diff looks like now
Same feature request, both designs:
Before — persist-before-notify
M CheckoutController.java bookends in HTTP
M MobileCheckoutController.java same conversation, easy to miss
M ReplayOrders.java third copy
After — persist-before-notify
M CheckoutFacade.java one conversation
(controllers still map HTTP / CLI only)
One edited conversation. The entry points are not in the diff. CheckoutFacade is closed against “a new client wants to place an order” and still fully open to editing when the checkout talk changes — a second fraud vendor, a tax-inclusive price, a capture instead of a charge. Those belong in place, because they are the subsystem protocol.
Proving it with fakes behind the front
The seam pays a second dividend immediately: HTTP tests stop constructing a tax service. Use the same constructor the production wiring uses, with fakes:
@Test
void declinedChargeMapsTo402() {
CheckoutFacade checkout = new CheckoutFacade(
FraudClient.alwaysClear(),
TaxService.zero(),
new NoDiscount(),
FakePaymentGateway.alwaysDeclined("card_declined"),
new RecordingRepository(),
OrderNotifier.noop());
CheckoutController controller = new CheckoutController(checkout);
ResponseEntity<PlacementDto> response = controller.place(sampleRequest());
assertEquals(402, response.getStatusCode().value());
}
And the conversation itself can be tested without HTTP:
@Test
void declinedChargeDoesNotNotify() {
RecordingNotifier notifier = new RecordingNotifier();
CheckoutFacade checkout = new CheckoutFacade(
FraudClient.alwaysClear(),
TaxService.zero(),
new NoDiscount(),
FakePaymentGateway.alwaysDeclined("card_declined"),
new RecordingRepository(),
notifier);
checkout.place(order());
assertTrue(notifier.orderPlacedCalls().isEmpty());
}
If a controller test needs a real tax table, you have mixed two reasons to change. Test HTTP mapping against a stub facade, or against a facade with fakes. Test tax against TaxService.
Facade is not Adapter
Adapter makes one existing type look like the type a client already speaks — a vendor StripeClient wearing a PaymentGateway hat. Facade hides a conversation among several types. Same lab, different axis:
| Adapter | Facade | |
|---|---|---|
| How many types you wrap | One (maybe two if you translate a result) | A subsystem: many |
| What the caller wanted | The interface it already has (PaymentGateway) | A simpler operation (place) |
| Typical smell | Vendor SDK leaking into policy | Every controller names fraud, tax, and gateway |
| What you add | A translator | A front door |
Do not rename StripeGateway to StripeFacade. That class is an adapter (and a Strategy implementer). Do not split CheckoutFacade into six adapters that the controller still has to call in order — you hid nothing.
The Adapter write-up is Adapter: Make Incompatible Types Talk. The practical test: if the caller still has to know the order of three other objects, you have not built a facade.
When Facade is the wrong move
Skip the extra type when:
- There is one caller and one collaborator.
OrderProcessoralready talking to onePaymentGatewaydoes not needPaymentFacade. - The “subsystem” is two methods on the same class. A wrapper that forwards
chargeandrefundunchanged is ceremony. - Every caller needs a different slice. Then a facade that exposes everything the subsystem does is a second public API, not a simplification.
- You are collecting unrelated verbs.
place,refund,sendWeeklyDigest,reindexCatalog, andresetPasswordon one type is not a facade. That is an SRP failure wearing a GoF name.
A facade that is a god bag of unrelated methods is not “a big facade.” It is a god object. This is the shape to refuse — one type, five conversations, no shared sequence:
public class CheckoutFacade {
public PlacementResult place(Order order) { /* screen, quote, charge */ }
public void sendWeeklyDigest() { /* mail, not checkout */ }
public void reindexCatalog() { /* search, not checkout */ }
public void resetPassword(String email) { /* identity, not checkout */ }
}
place is a conversation. The other three methods do not belong on this type because they share a package name, not a protocol. Refunds may deserve RefundFacade — or a Command if the pain is queue and undo — but they do not belong here because they appeared in the same slide deck.
The healthy trigger is a conversation you can quote, copied into a second entry point. Screen, quote, price, charge, persist, notify — already in web, about to be copied into mobile. That is one place. “We might add more services someday” is not a change request.
Cheat sheet
Facade CheckoutFacade.place one method; owns the conversation
Subsystem fraud, tax, discount, still exist; most callers never name them
gateway, orders, notifier
Callers HTTP, mobile BFF, CLI map protocol in; call place; map result out
Trigger to apply: two entry points repeat the same talk among several types
Trigger to stop: one caller, one collaborator, or a bag of unrelated verbs
Scoreboard: new client = new mapping; checkout protocol is not copied
Not Adapter: Adapter translates one type; facade hides a whole conversation
Do:
- Name the facade after the job (
CheckoutFacade,place), not after “Subsystem.” - Return a domain result. Do not leak HTTP or SQL types through the front door.
- Keep the real types. The facade is a door, not a rewrite of tax law.
- Test the conversation once; test each entry point as a mapper.
Don’t:
- Put
place,reindex, andresetPasswordon the same facade — that is SRP, not a pattern. - Inject the facade and
TaxServiceinto the same controller. - Build a facade that only forwards a single
processcall with no second collaborator. - Call an adapter a facade because it wraps a vendor.
Wrap-up
Facade is a small front that owns a conversation so every entry point does not. Checkout was expensive because web, mobile, and replay each assembled fraud, tax, discount, gateway, persist, and notify. CheckoutFacade.place(Order) makes the next client a mapper, keeps the protocol unit-testable, and leaves place free to change when the talk changes.
Keep Adapter for one type translation. Keep Strategy for a swappable algorithm behind the door. Refuse a god bag: if the methods do not share a conversation, they do not share a facade.