Skip to content
ExponaBLOG
DESIGN PATTERNS IN PRACTICE · DAY 020 / 100

Facade pattern: Design Patterns best practices explained

One simple entry point to a complex subsystem.

Expona Admin7 min read

Facade pattern: Design Patterns best practices explained

Picture your checkout flow: a customer clicks "Place Order" and suddenly five systems need to talk to each other—inventory, pricing, payment, shipping, and email. Without structure, your controller becomes a conductor trying to orchestrate a chaotic symphony. The facade pattern solves this by creating one clean entry point that hides the complexity underneath, letting callers focus on their job instead of juggling multiple services.

In this article, we'll walk through how the facade pattern keeps your code readable and maintainable, particularly in complex workflows where multiple subsystems need to collaborate.

The problem: Checkout touches inventory, pricing, payment, shipping and email

Let's ground this in reality. Your e-commerce platform processes an order, and here's what needs to happen:

  • Check if items are in stock (inventory service)
  • Calculate the final price with discounts and tax (pricing service)
  • Process the payment card (payment service)
  • Reserve a slot with the courier (shipping service)
  • Send a confirmation email (email service)

Without a facade, your order controller might look like this:

typescript
// Controllers/OrderController.ts - WITHOUT facade
async placeOrder(req: Request, res: Response) {
  const { userId, items, shippingAddress } = req.body;

  // Check inventory
  const inventoryCheck = await this.inventoryService.checkStock(items);
  if (!inventoryCheck.available) {
    return res.status(400).json({ error: 'Out of stock' });
  }

  // Calculate pricing
  const pricing = await this.pricingService.calculate(
    items,
    userId,
    shippingAddress
  );

  // Process payment
  const payment = await this.paymentService.charge(
    userId,
    pricing.total,
    req.body.cardToken
  );
  if (!payment.success) {
    return res.status(402).json({ error: 'Payment failed' });
  }

  // Reserve shipping
  const shipment = await this.shippingService.reserve(
    shippingAddress,
    items,
    pricing.total
  );

  // Send confirmation
  await this.emailService.sendOrderConfirmation(userId, shipment.trackingId);

  res.json({ orderId: payment.orderId, trackingId: shipment.trackingId });
}

This controller is now responsible for orchestrating five different services, understanding their APIs, handling their individual failures, and managing the order flow. It's tightly coupled, hard to test, and any change to the workflow means updating the controller. If you add subscription logic, loyalty points, or fraud checks, this method explodes.

A facade: CheckoutService.placeOrder() coordinates all of them

The facade pattern gives us a single, unified interface that coordinates all these subsystems internally. The facade doesn't do the business logic itself—it delegates to the right services in the right order, handles coordination concerns, and presents one clean method to the outside world.

Here's our CheckoutService facade:

typescript
// Services/CheckoutService.ts - THE FACADE
import { Injectable, BadRequestException, ConflictException } from '@nestjs/common';
import { InventoryService } from './InventoryService';
import { PricingService } from './PricingService';
import { PaymentService } from './PaymentService';
import { ShippingService } from './ShippingService';
import { EmailService } from './EmailService';

@Injectable()
export class CheckoutService {
  constructor(
    private inventoryService: InventoryService,
    private pricingService: PricingService,
    private paymentService: PaymentService,
    private shippingService: ShippingService,
    private emailService: EmailService,
  ) {}

  async placeOrder(userId: string, items: OrderItem[], shippingAddress: Address, cardToken: string) {
    // Step 1: Verify stock
    const inventoryCheck = await this.inventoryService.checkStock(items);
    if (!inventoryCheck.available) {
      throw new ConflictException('Items out of stock');
    }

    // Step 2: Calculate final price
    const pricing = await this.pricingService.calculate(items, userId, shippingAddress);

    // Step 3: Charge the card
    let payment;
    try {
      payment = await this.paymentService.charge(userId, pricing.total, cardToken);
    } catch (error) {
      throw new BadRequestException('Payment declined');
    }

    // Step 4: Reserve shipping
    let shipment;
    try {
      shipment = await this.shippingService.reserve(shippingAddress, items, pricing.total);
    } catch (error) {
      // Refund if shipping fails
      await this.paymentService.refund(payment.transactionId);
      throw new ConflictException('Unable to arrange shipping');
    }

    // Step 5: Notify customer
    await this.emailService.sendOrderConfirmation(userId, shipment.trackingId, payment.orderId);

    // Return a simple order summary
    return {
      orderId: payment.orderId,
      trackingId: shipment.trackingId,
      total: pricing.total,
      estimatedDelivery: shipment.estimatedDelivery,
    };
  }
}

The facade encapsulates the entire workflow. It knows the sequence, handles failures gracefully (like refunding if shipping fails), and presents a single, clear contract: give me user, items, address, and card token; I'll return you an order summary or throw an exception.

Callers stay simple: Controllers call one method instead of five services

Now your controller is clean and focused:

typescript
// Controllers/OrderController.ts - WITH facade
import { Controller, Post, Body, Req, HttpCode } from '@nestjs/common';
import { CheckoutService } from '../services/CheckoutService';

@Controller('/orders')
export class OrderController {
  constructor(private checkoutService: CheckoutService) {}

  @Post()
  @HttpCode(201)
  async placeOrder(@Body() dto: PlaceOrderDto, @Req() req: any) {
    const order = await this.checkoutService.placeOrder(
      req.user.id,
      dto.items,
      dto.shippingAddress,
      dto.cardToken,
    );
    return order;
  }
}

That's it. The controller doesn't import five services; it depends on one facade. It doesn't know about payment refunds, inventory checks, or shipping edge cases. The coupling is minimal, the intent is clear, and testing becomes straightforward: mock the facade and verify the controller passes the right data to it.

Callers benefit from this simplicity everywhere they use checkout. A mobile app, a scheduled order service, or an admin panel—they all use the same placeOrder method. If we need to add fraud detection, subscription renewal, or a loyalty point calculation, we update the facade, not every caller.

Not a god object: The facade coordinates; logic stays in the subsystems

A common mistake is turning the facade into a dumping ground for business logic. The facade should coordinate and orchestrate, not implement core rules.

In our example, the CheckoutService doesn't calculate discounts—the PricingService does. It doesn't decide shipping costs—the ShippingService does. The facade calls these services in the right order and handles cross-cutting concerns like refunds and error recovery.

Here's what stays in the subsystems:

Service Responsibility
InventoryService Stock levels, reservations, deductions
PricingService Discounts, tax calculation, promotions
PaymentService Card validation, charging, refunds, tokenization
ShippingService Rate calculation, courier selection, tracking
EmailService Template rendering, delivery, retry logic
CheckoutService (Facade) Orchestration, sequencing, error handling

When a refund is needed because shipping fails, the facade knows to call paymentService.refund(). When email sending is delayed, the facade doesn't retry—it lets the email service handle that. The facade is the conductor, not the orchestra.

Common mistakes

Mistake 1: Treating the facade as a utility class. Facades aren't just static helpers that delegate calls. They're stateful, injectable services that understand the workflow. If you find yourself creating a facade with twenty static methods that just forward to other services, you're building a wrapper, not a facade. A facade models a specific high-level operation (checkout, reporting, notifications).

Mistake 2: Hiding all complexity without understanding it. The facade pattern works when the underlying subsystems are genuinely complex and deserve hiding. If you're wrapping a single service call in a facade just to "have a facade," you're adding indirection without value. Use facades for workflows that naturally involve multiple collaborators.

Mistake 3: Leaking subsystem details into the facade contract. If your facade returns the internal state of five services, callers have to understand those services anyway. Keep the facade's return type simple and focused—in our case, just an order summary, not a bundle of raw responses from inventory, pricing, and shipping.

Mistake 4: Making the facade too smart about error recovery. A facade can coordinate error handling (like refunding on shipping failure), but it shouldn't contain domain logic that belongs in subsystems. If determining whether a refund is eligible depends on customer tier or purchase history, that logic belongs in the payment or policy service, not the facade.

Key takeaways

  • The facade pattern creates one entry point for a complex workflow, hiding coordination details from callers.
  • Use a facade when multiple subsystems need to collaborate in a specific order, like checkout, reporting, or account onboarding.
  • The facade orchestrates and sequences; it doesn't duplicate logic that belongs in subsystems.
  • Facades reduce coupling in controllers, tests, and other clients—they depend on one service, not five.
  • A well-designed facade is easy to extend: adding fraud checks, subscriptions, or loyalty logic means modifying the facade, not every caller.
  • Avoid turning facades into god objects; keep subsystem logic in subsystems and use the facade for orchestration and error handling.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles