Facade vs Adapter for Modern API Design: Patterns, Trade-offs, and Practical Examples

Facade vs Adapter in API design: when to use each, code examples, pitfalls, testing, and evolution strategies for robust integrations.

ASOasis
8 min read
Facade vs Adapter for Modern API Design: Patterns, Trade-offs, and Practical Examples

Image used for representation purposes only.

Overview

APIs rarely live in isolation. They evolve, integrate with legacy systems, and must present clear contracts to diverse clients. Two classic object-oriented patterns—Facade and Adapter—remain highly effective for shaping clean, stable API boundaries. This article demystifies both patterns, shows when to use each, and provides practical code examples and checklists you can apply to your next integration.

Quick definitions

  • Facade: Provides a simplified, cohesive interface over a complex subsystem. It organizes and orchestrates calls, hides complexity, and presents a task-focused API.
  • Adapter: Translates one interface into another expected by the client. It focuses on compatibility and contract conversion, not simplification of a whole subsystem.

Facade vs. Adapter—mental model

Think about “who needs to change to fit whom.”

  • Use a Facade when your clients need a cleaner, higher-level entry point to your own complex internals (multiple services, workflows, policies). A Facade can orchestrate and apply cross-cutting concerns (auth, retries, caching) and shield clients from churn.
  • Use an Adapter when you must consume or expose a component with an incompatible interface or protocol. Adapters map methods, parameters, and data models without forcing either side to change.

Key distinctions:

  • Primary goal
    • Facade: Simplify and unify.
    • Adapter: Translate and compatibilize.
  • Scope
    • Facade: Often spans multiple components and concerns.
    • Adapter: Typically wraps one component.
  • Data/behavior
    • Facade: May aggregate, validate, orchestrate, and apply policy.
    • Adapter: Minimizes behavior—maps calls and data shapes.

Where they fit in modern API architectures

  • REST and HTTP APIs
    • Facade: A single endpoint that coordinates inventory, pricing, shipping, and notifications for a ‘placeOrder’ action.
    • Adapter: Wrap a legacy SOAP or XML service so your REST handlers can call it using simple JSON DTOs.
  • GraphQL
    • Facade: A GraphQL gateway that stitches multiple services into a single schema, applying authorization and caching.
    • Adapter: Field resolvers that adapt various backends (gRPC, REST, SQL) into GraphQL types.
  • gRPC and service meshes
    • Facade: A gateway exposing a simpler gRPC service that orchestrates multiple internal RPCs.
    • Adapter: An HTTP-to-gRPC shim that converts protocols and data models.
  • Event-driven systems
    • Facade: A command API that validates, enriches, and emits domain events to multiple topics.
    • Adapter: A consumer that translates third-party event formats into your canonical event schema.

Example: Building a Facade over microservices (TypeScript/Node)

Goal: Provide a single, stable API for checkout, orchestrating inventory, pricing, and shipping services.

// shipping-facade.ts
import { InventoryClient } from './inventory-client';
import { PricingClient } from './pricing-client';
import { CarrierClient } from './carrier-client';

export class CheckoutFacade {
  constructor(
    private inventory: InventoryClient,
    private pricing: PricingClient,
    private carrier: CarrierClient
  ) {}

  // High-level operation for clients
  async placeOrder(req: {
    userId: string;
    items: Array<{ sku: string; qty: number }>;
    address: { line1: string; city: string; zip: string };
  }) {
    // 1) Validate and reserve inventory
    await this.inventory.reserve(req.items);

    // 2) Price the basket
    const total = await this.pricing.quote(req.items, req.userId);

    // 3) Create shipment
    const label = await this.carrier.createShipment({
      address: req.address,
      items: req.items,
    });

    // 4) Return a clean, task-focused response
    return {
      status: 'confirmed',
      total,
      shippingLabelUrl: label.url,
    };
  }
}

// http-handler.ts (Express-style)
import express from 'express';
import { CheckoutFacade } from './shipping-facade';

const app = express();
const facade = new CheckoutFacade(new InventoryClient(), new PricingClient(), new CarrierClient());

app.post('/checkout', async (req, res) => {
  try {
    const result = await facade.placeOrder(req.body);
    res.status(200).json(result);
  } catch (e: any) {
    // The facade centralizes error translation for consistent API errors
    res.status(400).json({ error: e.message });
  }
});

What this buys you:

  • Simpler client integration: one call vs. a choreography of calls.
  • A stability shield: you can refactor internals without breaking the public contract.
  • A single place for policies: auth, rate limits, idempotency, retries, and caching.

Example: Implementing an Adapter for a third-party provider (Python)

Goal: Standardize how your code talks to payment providers. Your application expects a uniform IPaymentGateway, but each provider has different methods and payloads.

# contracts.py
from typing import Protocol, Dict

class IPaymentGateway(Protocol):
    def charge(self, amount_cents: int, currency: str, token: str) -> Dict:
        ...
# stripe_adapter.py
import stripe
from contracts import IPaymentGateway

class StripeAdapter(IPaymentGateway):
    def __init__(self, api_key: str):
        stripe.api_key = api_key

    def charge(self, amount_cents: int, currency: str, token: str):
        intent = stripe.PaymentIntent.create(
            amount=amount_cents,
            currency=currency,
            payment_method=token,
            confirm=True
        )
        return {
            'status': intent.status,
            'id': intent.id,
            'approved': intent.status == 'succeeded'
        }
# legacy_bank_adapter.py
from contracts import IPaymentGateway
from bank_sdk import BankClient  # hypothetical, with very different API

class LegacyBankAdapter(IPaymentGateway):
    def __init__(self, client: BankClient):
        self.client = client

    def charge(self, amount_cents: int, currency: str, token: str):
        # Map to whatever the bank expects
        result = self.client.make_debit(
            cents=amount_cents, iso=currency, pan_token=token
        )
        return {
            'status': result.state,
            'id': result.reference,
            'approved': result.state == 'OK'
        }

Your application code uses IPaymentGateway and remains agnostic to which provider is active. Swapping providers becomes a configuration change, not a refactor.

Adapter as a protocol bridge: HTTP-to-gRPC shim (TypeScript)

// grpc-adapter.ts — converts an HTTP request into a gRPC client call
import { Request, Response } from 'express';
import { GreeterClient } from './generated/greeter_grpc_pb';
import { HelloRequest } from './generated/greeter_pb';

const client = new GreeterClient('greeter:50051', /* creds */ undefined as any);

export async function httpToGrpc(req: Request, res: Response) {
  const hello = new HelloRequest();
  hello.setName(String(req.query.name || 'world'));
  client.sayHello(hello, (err, reply) => {
    if (err) return res.status(502).json({ error: err.message });
    res.json({ message: reply.getMessage() });
  });
}

This adapter lets HTTP clients consume a gRPC service without learning gRPC. The HTTP surface area remains stable even if the gRPC schema evolves behind the adapter.

Choosing the right pattern

Use a Facade when:

  • Clients need a simpler, task-focused entry point over multiple steps/services.
  • You want to centralize cross-cutting concerns: retries, timeouts, authZ, caching.
  • Internal components will churn, but you want a stable public API.

Use an Adapter when:

  • You must integrate with a component whose interface you do not control (vendor, legacy).
  • You need to translate protocols (SOAP → REST, HTTP → gRPC, JSON ↔ XML) or data models.
  • You want to maintain a canonical internal contract while supporting many external ones.

Sometimes you need both: a Facade exposes a cohesive API to clients, and within it you use Adapters to talk to each downstream dependency.

Pitfalls and how to avoid them

  • God Facade
    • Smell: One mega-class that knows too much and changes too often.
    • Fix: Split by domain capability (CheckoutFacade, ReturnsFacade). Keep orchestration thin and delegate to domain services.
  • Leaky Adapter
    • Smell: External semantics bleed through (foreign error codes, field names).
    • Fix: Normalize errors and DTOs. Maintain a strict canonical model at adapter boundaries.
  • Hidden coupling
    • Smell: Facade or Adapter hardcodes assumptions about downstream SLAs or data quirks.
    • Fix: Isolate assumptions behind config and feature flags; document invariants; add contract tests.
  • Performance regressions
    • Smell: Facade adds extra network hops and serialization.
    • Fix: Batch requests, add read-through caches, enforce timeouts, prefer parallelism where safe.
  • Reliability issues
    • Smell: Orchestrated flows fail half-way.
    • Fix: Use idempotency keys, sagas/compensations, and circuit breakers.

Testing strategies

  • Unit tests
    • Adapters: Validate field mapping, error translation, and edge cases (nulls, unknowns).
    • Facades: Mock downstreams to test orchestration logic and failure paths.
  • Contract tests
    • Consumer-driven contracts (e.g., Pact) to ensure the Facade’s public API stays compatible with clients.
    • Provider contracts for Adapters to detect third-party API drift early.
  • Integration smoke tests
    • Periodically exercise real downstreams with synthetic transactions.
  • Golden tests for mapping
    • Freeze expected payloads and error shapes; diff on every change.

Observability and governance

  • Metrics to track
    • Facade: request rate, P95 latency, success/error rates per operation, retries, cache hit ratio.
    • Adapter: mapping failures, external error code distribution, schema drift alerts.
  • Logging
    • Correlate with request IDs across facade/adapters/downstreams.
    • Redact PII at the boundary. Standardize error structures.
  • Tracing
    • Instrument spans for each orchestration step. Tag retries and fallbacks.

Security considerations

  • Facade
    • Centralize authN/authZ and rate limiting. Validate inputs early; reject at the edge.
    • Normalize error messages to avoid leaking internals.
  • Adapter
    • Translate and enforce scopes/claims when crossing trust boundaries.
    • Ensure secure transport when protocol-bridging (mTLS for RPC, TLS for HTTP). Map and minimize sensitive fields.

Versioning and evolution

  • Use adapters to maintain backward compatibility during provider migrations. Keep the internal interface stable while adding new adapters.
  • Use a facade as a long-lived public contract while you refactor internals. Introduce new operations behind feature flags, then promote.
  • Deprecation plan
    • Mark old endpoints/fields as deprecated.
    • Provide migration guides and sunset dates.
    • Meter usage; notify heavy consumers directly.

Deployment patterns

  • Canary releases
    • Route a small slice of traffic to a new facade or adapter, observe metrics, then ramp up.
  • Shadow traffic for adapters
    • Send read-only mirrors to a new adapter to validate mappings without impacting users.
  • Rollback-friendly design
    • Keep old and new adapters side-by-side until confidence is high.

Implementation checklists

Facade checklist:

  • Define task-focused operations and DTOs.
  • Orchestrate with clear steps; parallelize safely.
  • Centralize cross-cutting concerns (authN/Z, timeouts, retries, idempotency, caching).
  • Normalize errors; avoid leaking internal service details.
  • Expose coarse-grained endpoints with thoughtful pagination and filtering.

Adapter checklist:

  • Pin a canonical internal interface (Protocol/Interface) first.
  • Map fields, types, and error semantics explicitly; document edge cases.
  • Handle protocol conversion and content negotiation.
  • Guard against provider drift with schema/contract checks.
  • Keep logic thin—no orchestration; avoid business decisions.

Real-world signals you need a Facade, an Adapter, or both

  • “Clients call 4 services in sequence and we keep breaking them” → Add a Facade.
  • “Vendor changed an endpoint name and we’re refactoring everywhere” → Add an Adapter.
  • “We expose 6 similar endpoints for different partners” → One canonical API + partner-specific Adapters.
  • “We’re migrating from XML/SOAP to JSON/HTTP but must keep old clients working” → Dual-surface with Adapters, sunset on a timeline.

Conclusion

Facade and Adapter are complementary tools for robust API design. Facades simplify and stabilize the experience for clients; Adapters insulate you from incompatibilities and external churn. Use Facades to present cohesive capabilities and concentrate cross-cutting policies, and use Adapters to keep your internal model canonical while speaking many dialects at the edges. Together, they allow you to evolve aggressively inside while remaining dependable outside—a hallmark of sustainable API architectures.

Related Posts