Facade Pattern #
The checkout process in an e-commerce site looks simple from the user’s side: click “Pay”, wait a moment, order confirmed. But behind the scenes, many steps must happen in sequence, coordinated — user validation, inventory stock checking, price calculation after discounts, payment processing, stock updates after purchase, sending the invoice by email, and notifying the fulfillment team. Without the Facade Pattern, the code calling checkout would have to know all these steps, call each subsystem one by one in the right order, and handle each one’s errors. The Facade Pattern moves all that complexity into a single struct providing one Checkout() method — the client calls one function, and the Facade takes care of the rest.
What Is the Facade Pattern? #
The Facade Pattern is a structural design pattern that provides a simple interface to a more complex set of interfaces within a subsystem. The Facade does not remove the complexity — it hides it behind one clean entry point, so the client does not need to understand or depend on the subsystem’s internal details.
The right analogy: a hotel receptionist. Guests do not need to contact housekeeping, the restaurant, and security separately for every need — just call the receptionist, and the receptionist coordinates everything. The receptionist is the Facade.
Three properties of the Facade Pattern:
- One entry point — the client interacts with one simple interface, not many subsystems directly
- Orchestration, not implementation — the Facade calls and coordinates subsystems; business logic stays in each subsystem
- Subsystems remain directly accessible — the Facade is a convenience layer, not a barrier; code that needs detailed access can still use subsystems directly
flowchart LR
subgraph "Without Facade — Client Knows Everything"
C1[Client] --> US1[UserService]
C1 --> IS1[InventoryService]
C1 --> PS1[PaymentService]
C1 --> DS1[DiscountService]
C1 --> NS1[NotificationService]
C1 --> LS1[LogService]
end
subgraph "With Facade — Client Knows One"
C2[Client] -->|Checkout| F[CheckoutFacade]
F --> US2[UserService]
F --> IS2[InventoryService]
F --> PS2[PaymentService]
F --> DS2[DiscountService]
F --> NS2[NotificationService]
F --> LS2[LogService]
endThe Problem It Solves #
The Facade solves two different but related problems: high coupling between the client and subsystems, and duplicated orchestration logic scattered across many places.
Problem 1: The Client Has to Know Too Much #
// ANTI-PATTERN: the client does its own orchestration — full of technical details
func HandleCheckout(w http.ResponseWriter, r *http.Request) {
req := parseCheckoutRequest(r)
// The client must know the exact order
userSvc := user.NewService(db)
if err := userSvc.Validate(req.UserID); err != nil {
http.Error(w, err.Error(), 400)
return
}
inventorySvc := inventory.NewService(db)
items, err := inventorySvc.CheckAndReserve(req.Items)
if err != nil {
http.Error(w, err.Error(), 422)
return
}
discountSvc := discount.NewService(db)
finalPrice := discountSvc.Apply(req.TotalPrice, req.CouponCode, req.UserID)
paymentSvc := payment.NewService(cfg.PaymentKey)
txID, err := paymentSvc.Charge(req.UserID, finalPrice, req.PaymentMethod)
if err != nil {
inventorySvc.ReleaseReservation(items) // must remember the rollback!
http.Error(w, err.Error(), 402)
return
}
notifSvc := notification.NewService(cfg.SMTPConfig)
notifSvc.SendInvoice(req.UserID, txID, items, finalPrice)
logSvc := log.NewAuditLogger(db)
logSvc.LogCheckout(req.UserID, txID, finalPrice)
// This handler knows too much about the subsystems
// If the order changes, every handler with similar code must be updated
}
// CORRECT: the client only knows one method
func HandleCheckout(w http.ResponseWriter, r *http.Request) {
req := parseCheckoutRequest(r)
result, err := checkoutFacade.Checkout(r.Context(), req)
if err != nil {
http.Error(w, err.Error(), resolveStatusCode(err))
return
}
writeJSON(w, result)
}
Problem 2: The Same Orchestration Repeated in Many Places #
Without a Facade, the logic “check user → check stock → process payment” may be reimplemented in an HTTP handler, a CLI command, a background job, and an integration test. Every change to the order or every added step must be updated everywhere.
Structure and Components #
The Facade Pattern involves three simple components, fewer than most other patterns.
flowchart TD
C[Client\\nHTTP Handler, CLI, Background Job] -->|high-level call| F
subgraph F[Facade]
direction TB
FM["Checkout(ctx, req) error\\nProcessRefund(ctx, req) error\\nCancelOrder(ctx, orderID) error"]
end
F --> S1[UserService\\nvalidation & profile]
F --> S2[InventoryService\\nstock & reservation]
F --> S3[PaymentService\\ncharge & refund]
F --> S4[DiscountService\\ncalculate discounts]
F --> S5[NotificationService\\nemail & SMS]
F --> S6[AuditLogService\\nrecord all events]| Component | Role | Characteristics |
|---|---|---|
| Facade | Single entry point, orchestrates subsystems | Contains no business logic of its own |
| Subsystem | The actual logic implementations | Do not know the Facade exists |
| Client | Calls the Facade for high-level operations | Does not depend on subsystems directly |
Full Implementation: Checkout System #
A more complete case study than the original article — an e-commerce checkout involving six subsystems with error handling, rollback, and audit logging.
Subsystem Interfaces #
package checkout
import (
"context"
"time"
)
// CheckoutRequest holds all the data needed for the checkout process.
type CheckoutRequest struct {
UserID string
Items []OrderItem
CouponCode string
PaymentMethod string
ShippingAddr Address
}
// OrderItem represents one item in an order.
type OrderItem struct {
ProductID string
Quantity int
UnitPrice float64
}
// Address represents a shipping address.
type Address struct {
Street string
City string
ZIP string
Country string
}
// CheckoutResult contains a summary of a successful checkout.
type CheckoutResult struct {
OrderID string
TransactionID string
FinalPrice float64
EstimatedDelivery time.Time
}
// --- Subsystem interfaces --- //
// UserValidator validates a user's status and authorization.
type UserValidator interface {
Validate(ctx context.Context, userID string) error
GetProfile(ctx context.Context, userID string) (*UserProfile, error)
}
// UserProfile stores a user's profile information.
type UserProfile struct {
ID string
Name string
Email string
Phone string
Tier string // "regular", "silver", "gold"
}
// InventoryService handles stock and item reservations.
type InventoryService interface {
CheckAvailability(ctx context.Context, items []OrderItem) error
Reserve(ctx context.Context, items []OrderItem) (string, error) // returns reservationID
ReleaseReservation(ctx context.Context, reservationID string) error
Commit(ctx context.Context, reservationID string) error
}
// DiscountService calculates the price after discounts.
type DiscountService interface {
Calculate(ctx context.Context, userID, couponCode string, items []OrderItem) (float64, error)
}
// PaymentService handles payments.
type PaymentService interface {
Charge(ctx context.Context, userID string, amount float64, method string) (string, error) // returns transactionID
Refund(ctx context.Context, transactionID string, amount float64) error
}
// NotificationService sends notifications to the user.
type NotificationService interface {
SendOrderConfirmation(ctx context.Context, profile *UserProfile, result *CheckoutResult) error
SendOrderCancellation(ctx context.Context, profile *UserProfile, orderID string) error
}
// AuditLogger records all important events for auditing purposes.
type AuditLogger interface {
LogCheckout(ctx context.Context, userID string, result *CheckoutResult)
LogCheckoutFailure(ctx context.Context, userID string, reason string)
LogRefund(ctx context.Context, transactionID string, amount float64)
}
// OrderRepository stores order data.
type OrderRepository interface {
Create(ctx context.Context, req CheckoutRequest, result CheckoutResult) error
UpdateStatus(ctx context.Context, orderID, status string) error
}
Facade Implementation #
package checkout
import (
"context"
"fmt"
"time"
)
// CheckoutFacade is the facade for the entire checkout process.
// The client only interacts with this struct — never directly with subsystems.
type CheckoutFacade struct {
userValidator UserValidator
inventory InventoryService
discount DiscountService
payment PaymentService
notification NotificationService
auditLog AuditLogger
orderRepo OrderRepository
}
// CheckoutFacadeConfig stores all the dependencies the Facade needs.
type CheckoutFacadeConfig struct {
UserValidator UserValidator
Inventory InventoryService
Discount DiscountService
Payment PaymentService
Notification NotificationService
AuditLog AuditLogger
OrderRepo OrderRepository
}
// NewCheckoutFacade creates a Facade instance with configured dependencies.
func NewCheckoutFacade(cfg CheckoutFacadeConfig) *CheckoutFacade {
return &CheckoutFacade{
userValidator: cfg.UserValidator,
inventory: cfg.Inventory,
discount: cfg.Discount,
payment: cfg.Payment,
notification: cfg.Notification,
auditLog: cfg.AuditLog,
orderRepo: cfg.OrderRepo,
}
}
// Checkout orchestrates the entire checkout process from start to finish.
// This is the only method the client needs to know.
func (f *CheckoutFacade) Checkout(ctx context.Context, req CheckoutRequest) (*CheckoutResult, error) {
// Step 1: Validate the user
profile, err := f.validateUser(ctx, req.UserID)
if err != nil {
f.auditLog.LogCheckoutFailure(ctx, req.UserID, fmt.Sprintf("user validation failed: %v", err))
return nil, fmt.Errorf("user validation failed: %w", err)
}
// Step 2: Check and reserve stock
reservationID, err := f.reserveInventory(ctx, req.Items)
if err != nil {
f.auditLog.LogCheckoutFailure(ctx, req.UserID, fmt.Sprintf("inventory check failed: %v", err))
return nil, fmt.Errorf("inventory not available: %w", err)
}
// Step 3: Calculate the final price after discounts
finalPrice, err := f.discount.Calculate(ctx, req.UserID, req.CouponCode, req.Items)
if err != nil {
// Discount calculation failed → release the reservation
_ = f.inventory.ReleaseReservation(ctx, reservationID)
f.auditLog.LogCheckoutFailure(ctx, req.UserID, fmt.Sprintf("discount calculation failed: %v", err))
return nil, fmt.Errorf("failed to calculate price: %w", err)
}
// Step 4: Process the payment
transactionID, err := f.payment.Charge(ctx, req.UserID, finalPrice, req.PaymentMethod)
if err != nil {
// Payment failed → release the reservation
_ = f.inventory.ReleaseReservation(ctx, reservationID)
f.auditLog.LogCheckoutFailure(ctx, req.UserID, fmt.Sprintf("payment failed: %v", err))
return nil, fmt.Errorf("payment failed: %w", err)
}
// Step 5: Commit the stock reservation (cannot be rolled back after this)
if err := f.inventory.Commit(ctx, reservationID); err != nil {
// Commit failed but payment already succeeded — a refund is needed
_ = f.payment.Refund(ctx, transactionID, finalPrice)
f.auditLog.LogCheckoutFailure(ctx, req.UserID, fmt.Sprintf("inventory commit failed: %v", err))
return nil, fmt.Errorf("order processing failed: %w", err)
}
// Step 6: Create the order record
result := &CheckoutResult{
OrderID: generateOrderID(),
TransactionID: transactionID,
FinalPrice: finalPrice,
EstimatedDelivery: time.Now().Add(3 * 24 * time.Hour),
}
if err := f.orderRepo.Create(ctx, req, *result); err != nil {
// Log but do not fail — the order has already happened
f.auditLog.LogCheckoutFailure(ctx, req.UserID, fmt.Sprintf("order record failed: %v", err))
}
// Step 7: Send the notification (best-effort — must not fail the checkout)
go func() {
bgCtx := context.Background() // use a fresh context so it is not cancelled
_ = f.notification.SendOrderConfirmation(bgCtx, profile, result)
}()
// Step 8: Audit log
f.auditLog.LogCheckout(ctx, req.UserID, result)
return result, nil
}
// ProcessRefund orchestrates the refund process for an order.
func (f *CheckoutFacade) ProcessRefund(ctx context.Context, orderID, transactionID string, amount float64, userID string) error {
// Refund the payment
if err := f.payment.Refund(ctx, transactionID, amount); err != nil {
return fmt.Errorf("refund failed: %w", err)
}
// Update the order status
if err := f.orderRepo.UpdateStatus(ctx, orderID, "refunded"); err != nil {
f.auditLog.LogCheckoutFailure(ctx, userID, fmt.Sprintf("order status update failed after refund: %v", err))
// Continue — the refund already succeeded
}
f.auditLog.LogRefund(ctx, transactionID, amount)
// Send the cancellation notification
profile, _ := f.userValidator.GetProfile(ctx, userID)
if profile != nil {
go func() {
_ = f.notification.SendOrderCancellation(context.Background(), profile, orderID)
}()
}
return nil
}
// helper: validateUser validates the user and fetches their profile.
func (f *CheckoutFacade) validateUser(ctx context.Context, userID string) (*UserProfile, error) {
if err := f.userValidator.Validate(ctx, userID); err != nil {
return nil, err
}
return f.userValidator.GetProfile(ctx, userID)
}
// helper: reserveInventory checks availability and reserves stock.
func (f *CheckoutFacade) reserveInventory(ctx context.Context, items []OrderItem) (string, error) {
if err := f.inventory.CheckAvailability(ctx, items); err != nil {
return "", err
}
return f.inventory.Reserve(ctx, items)
}
func generateOrderID() string {
return fmt.Sprintf("ORD-%d", time.Now().UnixNano())
}
Client Code: Very Simple #
// HTTP Handler — only knows the Facade
func (h *CheckoutHandler) Handle(w http.ResponseWriter, r *http.Request) {
var req checkout.CheckoutRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "invalid request body", 400)
return
}
req.UserID = getUserIDFromContext(r.Context())
result, err := h.facade.Checkout(r.Context(), req)
if err != nil {
writeError(w, err)
return
}
writeJSON(w, result)
}
// Background Job — also only knows the Facade
func (j *RetryCheckoutJob) Run(ctx context.Context) {
pendingOrders := j.orderRepo.GetPending()
for _, order := range pendingOrders {
_, err := j.facade.Checkout(ctx, order.AsRequest())
if err != nil {
log.Printf("retry checkout failed for order %s: %v", order.ID, err)
}
}
}
// CLI Command — also only knows the Facade
func runCheckoutCommand(cmd *cobra.Command, args []string) {
req := buildRequestFromArgs(args)
result, err := facade.Checkout(context.Background(), req)
if err != nil {
fmt.Fprintf(os.Stderr, "checkout failed: %v\n", err)
os.Exit(1)
}
fmt.Printf("Order created: %s (Rp %.0f)\n", result.OrderID, result.FinalPrice)
}
Second Case Study: User Onboarding #
The Facade is not only useful for checkout — any business process involving many subsystems is a strong candidate. User onboarding is another very common example.
// OnboardingFacade orchestrates the entire new-user registration process.
type OnboardingFacade struct {
userRepo UserRepository
profileService ProfileService
emailVerifier EmailVerificationService
welcomeMailer WelcomeEmailService
analyticsTrack AnalyticsService
roleAssigner RoleService
}
func NewOnboardingFacade(
userRepo UserRepository,
profile ProfileService,
email EmailVerificationService,
mailer WelcomeEmailService,
analytics AnalyticsService,
roles RoleService,
) *OnboardingFacade {
return &OnboardingFacade{
userRepo: userRepo,
profileService: profile,
emailVerifier: email,
welcomeMailer: mailer,
analyticsTrack: analytics,
roleAssigner: roles,
}
}
// RegisterUser orchestrates the entire new-user registration process.
// The client just calls this one method.
func (f *OnboardingFacade) RegisterUser(ctx context.Context, req RegistrationRequest) (*User, error) {
// 1. Validate the input
if err := req.Validate(); err != nil {
return nil, fmt.Errorf("invalid registration data: %w", err)
}
// 2. Create the user account
user, err := f.userRepo.Create(ctx, req.Name, req.Email, hashPassword(req.Password))
if err != nil {
return nil, fmt.Errorf("failed to create account: %w", err)
}
// 3. Create the full profile
if err := f.profileService.Initialize(ctx, user.ID, req.ProfileData); err != nil {
_ = f.userRepo.Delete(ctx, user.ID) // rollback
return nil, fmt.Errorf("failed to initialize profile: %w", err)
}
// 4. Send the verification email
verificationToken, err := f.emailVerifier.SendVerification(ctx, user.Email)
if err != nil {
// Non-fatal: the account is still created, verification can be retried
log.Printf("warning: failed to send verification email to %s: %v", user.Email, err)
}
_ = verificationToken
// 5. Assign the default role
_ = f.roleAssigner.AssignDefault(ctx, user.ID)
// 6. Send the welcome email (best-effort)
go func() {
_ = f.welcomeMailer.Send(context.Background(), user.Email, user.Name)
}()
// 7. Track the event in analytics (best-effort)
go func() {
f.analyticsTrack.Track(context.Background(), "user_registered", map[string]string{
"user_id": user.ID,
"source": req.RegistrationSource,
})
}()
return user, nil
}
Rollback and Error Handling in a Facade #
One of the Facade’s most important responsibilities is managing rollback when one step in the orchestration fails. This is complexity hidden from the client.
flowchart TD
A[Validate User] --> B{OK?}
B -- No --> FAIL1[Return Error]
B -- Yes --> C[Reserve Inventory]
C --> D{OK?}
D -- No --> FAIL2[Return Error]
D -- Yes --> E[Calculate Discount]
E --> F{OK?}
F -- No --> G[Release Reservation]
G --> FAIL3[Return Error]
F -- Yes --> H[Charge Payment]
H --> I{OK?}
I -- No --> J[Release Reservation]
J --> FAIL4[Return Error]
I -- Yes --> K[Commit Inventory]
K --> L{OK?}
L -- No --> M[Refund Payment]
M --> FAIL5[Return Error]
L -- Yes --> N[Create Order Record]
N --> O[Send Notification]
O --> P[Audit Log]
P --> SUCCESS[Return Result ✓]
style FAIL1 fill:#ff6b6b
style FAIL2 fill:#ff6b6b
style FAIL3 fill:#ff6b6b
style FAIL4 fill:#ff6b6b
style FAIL5 fill:#ff6b6b
style SUCCESS fill:#51cf66This rollback flow complexity is one of the strongest reasons a Facade is needed — without it, every client would have to implement this flow itself, and someone would almost certainly miss a rollback somewhere.
Testing a Facade #
Because every Facade dependency is an interface, testing becomes very clean with mocks.
// Mock for testing — a simple implementation that records calls
type MockInventoryService struct {
CheckAvailabilityFn func(ctx context.Context, items []OrderItem) error
ReserveFn func(ctx context.Context, items []OrderItem) (string, error)
ReleaseReservationFn func(ctx context.Context, reservationID string) error
CommitFn func(ctx context.Context, reservationID string) error
ReserveCallCount int
ReleaseCallCount int
}
func (m *MockInventoryService) CheckAvailability(ctx context.Context, items []OrderItem) error {
if m.CheckAvailabilityFn != nil {
return m.CheckAvailabilityFn(ctx, items)
}
return nil
}
func (m *MockInventoryService) Reserve(ctx context.Context, items []OrderItem) (string, error) {
m.ReserveCallCount++
if m.ReserveFn != nil {
return m.ReserveFn(ctx, items)
}
return "reservation-001", nil
}
func (m *MockInventoryService) ReleaseReservation(ctx context.Context, reservationID string) error {
m.ReleaseCallCount++
if m.ReleaseReservationFn != nil {
return m.ReleaseReservationFn(ctx, reservationID)
}
return nil
}
func (m *MockInventoryService) Commit(ctx context.Context, reservationID string) error {
if m.CommitFn != nil {
return m.CommitFn(ctx, reservationID)
}
return nil
}
func buildTestFacade(inventory InventoryService, payment PaymentService) *CheckoutFacade {
return NewCheckoutFacade(CheckoutFacadeConfig{
UserValidator: &MockUserValidator{},
Inventory: inventory,
Discount: &MockDiscountService{},
Payment: payment,
Notification: &MockNotificationService{},
AuditLog: &MockAuditLogger{},
OrderRepo: &MockOrderRepository{},
})
}
func TestCheckoutFacade_Success(t *testing.T) {
facade := buildTestFacade(
&MockInventoryService{},
&MockPaymentService{TransactionIDToReturn: "txn-123"},
)
req := CheckoutRequest{
UserID: "user-1",
Items: []OrderItem{{ProductID: "prod-1", Quantity: 2, UnitPrice: 50000}},
}
result, err := facade.Checkout(context.Background(), req)
if err != nil {
t.Fatalf("expected no error, got: %v", err)
}
if result.TransactionID != "txn-123" {
t.Errorf("unexpected transaction ID: %s", result.TransactionID)
}
if result.OrderID == "" {
t.Error("expected non-empty order ID")
}
}
func TestCheckoutFacade_ReleasesReservationWhenPaymentFails(t *testing.T) {
inventory := &MockInventoryService{}
payment := &MockPaymentService{
ChargeFn: func(ctx context.Context, userID string, amount float64, method string) (string, error) {
return "", fmt.Errorf("insufficient balance")
},
}
facade := buildTestFacade(inventory, payment)
_, err := facade.Checkout(context.Background(), CheckoutRequest{
UserID: "user-1",
Items: []OrderItem{{ProductID: "prod-1", Quantity: 1, UnitPrice: 100000}},
})
if err == nil {
t.Error("expected error when payment fails")
}
// Verify the rollback: the reservation must be released
if inventory.ReleaseCallCount != 1 {
t.Errorf("expected ReleaseReservation to be called once, got %d", inventory.ReleaseCallCount)
}
}
func TestCheckoutFacade_UserValidationFailure(t *testing.T) {
facade := NewCheckoutFacade(CheckoutFacadeConfig{
UserValidator: &MockUserValidator{
ValidateFn: func(ctx context.Context, userID string) error {
return fmt.Errorf("user is banned")
},
},
Inventory: &MockInventoryService{},
Discount: &MockDiscountService{},
Payment: &MockPaymentService{},
Notification: &MockNotificationService{},
AuditLog: &MockAuditLogger{},
OrderRepo: &MockOrderRepository{},
})
_, err := facade.Checkout(context.Background(), CheckoutRequest{UserID: "banned-user"})
if err == nil {
t.Error("expected error for banned user")
}
// Verify no further subsystem was called (no reservation happened)
}
A Facade Is Not a God Object #
The most common mistake with the Facade Pattern is letting it grow into a God Object — a single struct that knows and does everything.
// ANTI-PATTERN: a Facade containing business logic
func (f *CheckoutFacade) Checkout(ctx context.Context, req CheckoutRequest) (*CheckoutResult, error) {
// Discount logic directly in the Facade — wrong place
var discount float64
if req.CouponCode == "SALE50" {
discount = req.TotalPrice * 0.5
} else if req.CouponCode == "NEWUSER" {
discount = 10000
}
finalPrice := req.TotalPrice - discount
// Validation logic directly in the Facade — wrong place
if finalPrice < 0 {
return nil, errors.New("negative price not allowed")
}
// The Facade keeps growing and gets harder to test
}
// CORRECT: the Facade delegates all logic to the subsystem responsible for it
func (f *CheckoutFacade) Checkout(ctx context.Context, req CheckoutRequest) (*CheckoutResult, error) {
// Discounts? → DiscountService knows how
finalPrice, err := f.discount.Calculate(ctx, req.UserID, req.CouponCode, req.Items)
if err != nil {
return nil, fmt.Errorf("discount calculation failed: %w", err)
}
// The Facade has no idea about the discount algorithm at all
}
Guidelines for keeping a Facade clean:
A Facade MAY:
✓ Call subsystems in a specific order
✓ Handle errors and orchestrate rollbacks
✓ Decide whether a step is fatal or best-effort
✓ Pass data from one subsystem to the next
A Facade MUST NOT:
✗ Contain business calculations (prices, discounts, taxes)
✗ Contain domain validation (is the email valid, is the quantity positive)
✗ Access the database directly
✗ Contain if/else based on business rules
Facade vs Other Patterns #
Facade is often confused with Adapter and Mediator because all three deal with connecting components.
| Aspect | Facade | Adapter | Mediator |
|---|---|---|---|
| Purpose | Simplify a complex subsystem | Adapt an incompatible interface | Coordinate communication between components |
| Communication direction | One-way: client → subsystem | One-way: client → adaptee | Two-way: component ↔ mediator ↔ component |
| New interface? | Yes — simpler than the subsystem | Yes — matches what the client expects | Yes — centralized at the mediator |
| Do subsystems know the Facade? | No | No | Components know the mediator |
| Example | CheckoutFacade | MidtransAdapter | EventBus, ChatRoom |
When to Use and When Not to #
USE Facade if:
✓ Several subsystems must be called in a specific order for one use case
✓ Client code is full of technical details it should not know about
✓ The same orchestration logic is repeated in many places (handlers, jobs, CLI)
✓ You want to hide a legacy system behind a more modern API
✓ You are building an SDK or library for other teams to use
AVOID Facade if:
✗ There is only one or two subsystems — not complex enough to justify it
✗ Every client needs very different orchestration — a Facade cannot serve them all
✗ The Facade starts containing business logic — refactor that logic into the right subsystem
✗ The Facade grows into a God Object — a sign it already has too many responsibilities
Facade in the Application Service Layer
In layered architectures, the
Application ServiceorUse Caseclass often acts as a Facade — they orchestrate domain objects, repositories, and infrastructure services to execute one use case. If you already follow Clean Architecture or Hexagonal Architecture, you are already using the Facade Pattern, even if you do not call it that.
Facade Review Checklist #
DESIGN:
□ The Facade provides methods that match business use cases, not technical subsystems
□ All Facade dependencies are interfaces — no concrete types in the constructor
□ The Facade contains no business logic — only orchestration and error handling
□ Rollback is correctly implemented for every step that can fail
IMPLEMENTATION:
□ Notifications and non-critical operations run as goroutines (best-effort)
□ The context is propagated to all subsystems
□ Subsystem errors are wrapped with informative context messages
□ Idempotent operations are clearly marked
FACADE SIZE:
□ The Facade has no more than 5-7 public methods
□ Every Facade method can be described in one sentence without saying "and"
□ No internal state changes after initialization
TESTING:
□ All subsystems are mocked through interfaces
□ Rollback scenarios are tested (payment fails → inventory is released)
□ Best-effort scenarios are tested (notification failure does not fail the checkout)
□ Both the happy path and every failure path are tested
Summary #
- Facade hides complexity, it does not remove it — the subsystems stay complex; the Facade just ensures the client does not need to know about that complexity.
- One entry point per use case —
Checkout(),RegisterUser(),ProcessRefund()are examples of a Facade API focused on what the client needs, not on what the subsystems offer.- The Facade only orchestrates, never implements — business logic must stay in the respective subsystems; the Facade only manages call order and rollback.
- Rollback is the Facade’s responsibility — one of the Facade’s biggest values is centralizing rollback logic in one place, avoiding bugs where the client forgets to release resources after a failure.
- All dependencies must be interfaces — this is what enables clean unit testing; without interfaces, testing a Facade requires all real subsystems.
- Best-effort operations as goroutines — notifications, analytics tracking, and non-critical operations that must not fail the main transaction can run asynchronously.
- The Facade is an Application Service — in Clean Architecture and Hexagonal Architecture, the Application Service layer plays exactly the same role; this is not a coincidence.
- Beware the God Object — if the Facade starts having more than 7 methods or starts containing if/else based on business rules, that is a signal the Facade has grown too large and needs to be split.