Facade — Find the Bug¶
Each section presents a Facade that looks fine but is broken. Find the bug yourself, then check.
Table of Contents¶
- Bug 1: Missing rollback on partial failure
- Bug 2: Subsystem exception leaks across the boundary
- Bug 3: Subsystem types in the API
- Bug 4: Facade constructs its own dependencies
- Bug 5: Hidden side effect surprises caller
- Bug 6: Default that becomes a security incident
- Bug 7: God-class Facade
- Bug 8: Sequential when parallel was safe
- Bug 9: Facade swallows error silently
- Bug 10: Idempotent Facade isn't actually idempotent
- Bug 11: Connection pool created per call
- Bug 12: Saga compensation doesn't compensate
- Practice Tips
Bug 1: Missing rollback on partial failure¶
public Order placeOrder(PlaceOrderCommand cmd) {
var reservation = inventory.reserve(cmd.items());
var receipt = payments.charge(cmd.user(), cmd.total()); // can throw
var order = orders.save(new Order(cmd.user(), reservation, receipt));
notifications.send(order);
return order;
}
A user reports being charged for an order that "doesn't exist."
Reveal
**Bug:** If `payments.charge` throws, `inventory.reserve` already happened — items are reserved without an order. If `orders.save` throws, the user is charged with no record. No rollback. **Fix:** wrap in try/catch and compensate.var reservation = inventory.reserve(cmd.items());
Receipt receipt;
try {
receipt = payments.charge(cmd.user(), cmd.total());
} catch (Exception e) {
inventory.cancel(reservation);
throw e;
}
try {
return orders.save(new Order(cmd.user(), reservation, receipt));
} catch (Exception e) {
payments.refund(receipt);
inventory.cancel(reservation);
throw e;
}
Bug 2: Subsystem exception leaks across the boundary¶
public Receipt pay(PaymentRequest req) {
return stripeClient.charges().create(req.toStripeParams());
// Throws StripeException — propagates up
}
Callers wrote catch (StripeException e) { ... }. Now the team is migrating to Adyen; every catch block needs to change.
Reveal
**Bug:** The Facade lets `StripeException` propagate. Callers depend on the vendor's exception type. Migrating vendors means touching every catch block. **Fix:** translate at the boundary. **Lesson:** Facades must translate vendor-specific errors to domain errors. Otherwise the Facade isn't really decoupling anything.Bug 3: Subsystem types in the API¶
public Charge processPayment(int amount) {
return stripeClient.charges().create(...); // returns Stripe's Charge type
}
Caller code: var charge = service.processPayment(100); chargeId = charge.getId();. When Stripe deprecates Charge.getId() in favor of getChargeId(), every caller breaks.
Reveal
**Bug:** The return type is the vendor's `Charge`. Callers are coupled to Stripe's API. **Fix:** return a domain type. **Lesson:** Don't expose subsystem types in your Facade's signatures. Translate to domain types.Bug 4: Facade constructs its own dependencies¶
class OrderService:
def __init__(self):
self._inv = InventoryService() # constructed inside
self._pay = PaymentProcessor(api_key="...") # constructed inside
self._orders = OrderRepository(db_url="...") # constructed inside
Tests fail: "can't reach the real Stripe API" / "no DB available."
Reveal
**Bug:** The Facade constructs its own dependencies. Tests can't substitute mocks; integration with real services is required for any test. **Fix:** inject via constructor. Tests construct `OrderService(MockInv(), MockPay(), MockOrders())`. **Lesson:** Inject subsystem dependencies; don't construct them inside the Facade. DI is fundamental for testability.Bug 5: Hidden side effect surprises caller¶
class FileService:
def save(self, path: str, data: bytes) -> None:
with open(path, "wb") as f:
f.write(data)
# Also: send a "file uploaded" notification to admins
self._email.send_admin("file uploaded: " + path)
A user uploads 1000 files in a script; admins get spammed with 1000 emails.
Reveal
**Bug:** `save` has a hidden side effect (sending email). The caller didn't ask for it; the API doesn't reveal it; the consequences are surprising. **Fix:** make it explicit, optional, or remove. Or split into two methods, or move notification to a separate scheduled job. **Lesson:** Side effects in Facades must be visible. Document explicitly and provide opt-out.Bug 6: Default that becomes a security incident¶
class S3Facade:
def upload(self, key: str, data: bytes) -> None:
self._client.put_object(
Bucket=self._bucket,
Key=key,
Body=data,
ACL="public-read", # ← default
)
A new developer uses the Facade for a private document. Two weeks later it's indexed by Google.
Reveal
**Bug:** The Facade defaults to public-read. The developer assumed sensible (private) defaults; the Facade silently made the file public. **Fix:** change the default to safer + require opt-in. **Lesson:** Defaults in Facades should be safest reasonable choice. Make dangerous options explicit opt-in. Document.Bug 7: God-class Facade¶
public class OrderService {
public Order placeOrder(...) { ... }
public void cancelOrder(...) { ... }
public Refund issueRefund(...) { ... }
public Order reissue(...) { ... }
public void exportInvoice(...) { ... }
public void notifyMarketing(...) { ... }
public void recalculateInventory(...) { ... }
public List<Order> searchOrders(...) { ... }
public OrderAnalytics getAnalytics(...) { ... }
// ... 20 more
}
The team complains: testing is slow, every PR touches this file, merges are painful.
Reveal
**Bug:** The Facade is a god class. It's no longer simplifying — it's collecting. Different audiences (storefront, admin, analytics) all depend on the same class. Test isolation is hard; merges conflict; reasoning is slow. **Fix:** split by audience. **Lesson:** A Facade that grows past ~10 methods is failing to simplify. Split aggressively.Bug 8: Sequential when parallel was safe¶
public OrderQuote quote(QuoteCommand cmd) {
var inv = inventory.check(cmd.items()); // 50ms
var price = pricing.quote(cmd.items()); // 80ms
var risk = fraud.score(cmd.user()); // 100ms
return new OrderQuote(inv, price, risk); // total: 230ms
}
P99 latency on this endpoint is 280ms. Subsystems are independent; could run in parallel.
Reveal
**Bug:** Three independent calls, sequentially. Total = sum (230ms). Could be max (100ms) with parallelization. **Fix:** run in parallel.var inv = supplyAsync(() -> inventory.check(cmd.items()), executor);
var price = supplyAsync(() -> pricing.quote(cmd.items()), executor);
var risk = supplyAsync(() -> fraud.score(cmd.user()), executor);
return allOf(inv, price, risk)
.thenApply(_ -> new OrderQuote(inv.join(), price.join(), risk.join()))
.join();
Bug 9: Facade swallows error silently¶
class EmailFacade:
def send(self, to: str, subject: str, body: str) -> None:
try:
self._smtp.send(to, subject, body)
except Exception as e:
self._log.warning(f"email failed: {e}")
# Continue silently
A customer complains: "I never got my password reset email." The team logs say "email failed" but the user got a "success" page.
Reveal
**Bug:** The Facade catches and logs the error but doesn't propagate. The caller thinks success; the user expected an email and didn't get one. **Fix:** propagate the error or return a meaningful result. Or return a result object the caller can inspect. **Lesson:** Facades shouldn't swallow errors. Logging is not the same as handling. Decide: propagate, or document the silent-fail contract loudly.Bug 10: Idempotent Facade isn't actually idempotent¶
func (c *CheckoutFacade) PlaceOrder(cmd PlaceOrderCommand) (*Order, error) {
if cmd.IdempotencyKey == "" {
cmd.IdempotencyKey = uuid.NewString() // ← always new
}
return c.inner.PlaceOrder(cmd)
}
Network retries cause duplicate charges.
Reveal
**Bug:** The Facade *generates* an idempotency key when missing — but each call generates a *new* one. Retries from the client (with no key) are not deduplicated. Charges happen twice. **Fix:** require the caller to provide the key, or persist + lookup the key based on a stable input hash. **Lesson:** Idempotency requires a stable key from the caller, plus a lookup before action. Generating a fresh key every call defeats the purpose.Bug 11: Connection pool created per call¶
class HttpFacade:
def get(self, url: str) -> dict:
pool = urllib3.PoolManager() # ← new per call
r = pool.request("GET", url)
return json.loads(r.data)
Under load, the application is slow. TLS handshakes dominate the trace.
Reveal
**Bug:** Each call creates a new pool — discards keep-alive connections, redoes TLS handshake every time. At 100 QPS that's 100 handshakes/sec. **Fix:** create the pool once. **Lesson:** Long-lived resources (pools, clients) belong as Facade fields, not constructed per call. TLS handshakes are expensive.Bug 12: Saga compensation doesn't compensate¶
public Order placeOrder(PlaceOrderCommand cmd) {
var reservation = inventory.reserve(cmd.items());
try {
var receipt = payments.charge(cmd.user(), cmd.total());
return orders.save(new Order(cmd.user(), reservation, receipt));
} catch (Exception e) {
inventory.cancel(reservation);
throw e;
}
}
After a payment failure, the inventory cancellation also fails (network issue). The reservation hangs forever.
Reveal
**Bug:** The compensating action (`inventory.cancel`) can also fail. If the cancel throws, the reservation isn't released. The Facade re-throws the original exception; the cancel failure is invisible. **Fix:** make compensation reliable. For real reliability: use an outbox pattern or a saga orchestrator with retries. **Lesson:** Compensation in distributed Facades must itself be reliable — or have a fallback (DLQ, manual reconciliation, async retry). Naive try/catch isn't enough at scale.Practice Tips¶
- Read each snippet, stop, predict the failure mode.
- For each bug, ask: "what's the worst production outcome?" Many Facade bugs are silent (hidden side effects, swallowed errors, defaults that bite).
- After fixing, write a test that would have caught the bug. If it's awkward, the fix is incomplete.
- Repeat in a week. These bugs repeat across codebases.
← Back to Facade folder · ↑ Structural Patterns · ↑↑ Roadmap Home
Next: Facade — Optimize