Facade Pattern in Angular: When Components Know Too Much

A facade earns its place when a component stops describing UI and starts coordinating API, permissions, route state, loading, errors, and commands. The Gang of Four defined Facade in 1994 as providing 'a simplified interface to a large body of code'. In Angular, the pattern got popular through NgRx: Thomas Burleson's 'NgRx + Facades' (2018) framed it as 'a simpler public interface to mask a composition of internal, more-complex, component usages'.

Start with the smell, not the pattern name

Do not start with a textbook definition of the Facade Pattern. That is not how the problem appears in a mature Angular app.

The problem appears when a component stops describing UI and starts coordinating a feature workflow. It reads route state, calls the API, checks permissions, maps loading and error states, decides whether a command is allowed, shows operational feedback, and still has to keep the template readable.

At that point, a facade has a job. It gives the page one explicit feature API to consume. It does not exist because every feature needs a class named Facade.

Signal in reviewWhat it usually meansFacade decision
Component injects 4+ servicesIt is coordinating feature contractsConsider a facade
Template repeats permission and status checksBusiness state leaked into markupMove derived state behind the facade
Action handler mixes validation, API call, error, and reloadCommand workflow lives in the componentMove the command to the facade
Facade only forwards api.get()No real complexity is hiddenDo not add the facade yet
Facade becomes shared by unrelated screensIt is turning into a service layerSplit by feature or route

The component that knows too much

This example is not risky because it is long. It is risky because every new product rule has to land in the same place: route input, request state, permission logic, action handling, and UI feedback all compete inside one component.

The first version often ships because it is faster than introducing a boundary. That is fine. The review question is when the next change makes the component harder to reason about than the feature itself.

Do not copy this as the target design. It collapses load failures into null, leaves the command subscription in the component, and makes permission logic part of the page class. Those are the symptoms the facade should remove.

Component doing coordinationts
@Component({
  selector: 'app-order-approval-page',
  templateUrl: './order-approval-page.html',
})
export class OrderApprovalPage {
  private readonly route = inject(ActivatedRoute);
  private readonly api = inject(OrdersApi);
  private readonly permissions = inject(PermissionsService);
  private readonly toast = inject(ToastService);

  readonly orderId = toSignal(
    this.route.paramMap.pipe(map((params) => params.get('id')!)),
    { initialValue: null }
  );
  readonly canApproveOrders = toSignal(
    this.permissions.canApproveOrders$,
    { initialValue: false }
  );

  readonly order = toSignal(
    toObservable(this.orderId).pipe(
      switchMap((id) => {
        if (!id) return of(null);
        return this.api.getOrder(id);
      }),
      catchError(() => of(null))
    ),
    { initialValue: null }
  );

  readonly canApprove = computed(() =>
    this.order()?.status === 'pending' &&
    this.canApproveOrders()
  );

  approve() {
    const order = this.order();
    if (!order || !this.canApprove()) return;

    this.api.approveOrder(order.id).subscribe({
      next: () => this.toast.success('Order approved'),
      error: () => this.toast.error('Could not approve order'),
    });
  }
}

What the facade should own

A feature facade should own the feature-facing contract. For this page, that means the selected order, request state, permission-derived state, command state, reload behavior, and operational error from the approval action.

In this article, the feature workflow is the coordination problem; the facade is the implementation boundary that gives that workflow one page-facing API.

It should not own the backend contract. OrdersApi still owns HTTP details. It should not own generic authorization rules either. PermissionsService remains the source of permission data. The facade coordinates those contracts for one screen.

This keeps the component small without pretending the complexity disappeared. The coordination still exists; it has a named owner.

Feature facadets
type ApprovalState =
  | { status: 'idle'; order: null; error: null }
  | { status: 'loading'; order: null; error: null }
  | { status: 'ready'; order: Order; error: null }
  | { status: 'error'; order: null; error: string };

const idleState: ApprovalState = {
  status: 'idle',
  order: null,
  error: null,
};

@Injectable()
export class OrderApprovalFacade {
  private readonly api = inject(OrdersApi);
  private readonly permissions = inject(PermissionsService);
  private readonly destroyRef = inject(DestroyRef);

  private readonly orderId = signal<string | null>(null);
  private readonly reloadVersion = signal(0);

  readonly actionError = signal<string | null>(null);
  readonly saving = signal(false);

  readonly canApproveOrders = toSignal(
    this.permissions.canApproveOrders$,
    { initialValue: false }
  );

  private readonly state$ = toObservable(
    computed(() => ({
      id: this.orderId(),
      reloadVersion: this.reloadVersion(),
    }))
  ).pipe(
    switchMap(({ id }) => {
      if (!id) return of(idleState);

      return this.api.getOrder(id).pipe(
        map((order) => ({
          status: 'ready',
          order,
          error: null,
        }) satisfies ApprovalState),
        startWith({
          status: 'loading',
          order: null,
          error: null,
        } satisfies ApprovalState),
        catchError(() => of({
          status: 'error',
          order: null,
          error: 'Could not load order',
        } satisfies ApprovalState))
      );
    }),
    shareReplay({ bufferSize: 1, refCount: true })
  );

  readonly state = toSignal(this.state$, { initialValue: idleState });

  readonly canApprove = computed(() => {
    const state = this.state();
    return state.status === 'ready' &&
      state.order.status === 'pending' &&
      this.canApproveOrders();
  });

  selectOrder(id: string) {
    this.orderId.set(id);
    this.actionError.set(null);
  }

  reload() {
    this.reloadVersion.update((value) => value + 1);
  }

  approve() {
    const state = this.state();
    if (state.status !== 'ready' || !this.canApprove() || this.saving()) return;

    this.saving.set(true);
    this.actionError.set(null);

    this.api.approveOrder(state.order.id).pipe(
      takeUntilDestroyed(this.destroyRef),
      finalize(() => this.saving.set(false))
    ).subscribe({
      next: () => this.reload(),
      error: () => this.actionError.set('Could not approve order'),
    });
  }
}

The component becomes a thin page boundary

After the facade exists, the component has a narrower job: connect the selected id input to the facade and render the contract. The template still shows real states. It stops rebuilding product rules inline.

The template uses @let to name the current state and current action error once. That keeps the discriminated state readable and avoids non-null assertions in the markup.

The providers array matters. Providing the facade at the page boundary gives this route its own instance and prevents a temporary approval workflow from becoming accidental global state.

Page boundaryts
@Component({
  selector: 'app-order-approval-page',
  providers: [OrderApprovalFacade],
  templateUrl: './order-approval-page.html',
})
export class OrderApprovalPage {
  readonly facade = inject(OrderApprovalFacade);
  readonly orderId = input.required<string>();

  constructor() {
    effect(() => {
      this.facade.selectOrder(this.orderId());
    });
  }
}
Template contracthtml
@let state = facade.state();
@let actionError = facade.actionError();

@switch (state.status) {
  @case ('loading') {
    <app-order-approval-skeleton />
  }
  @case ('error') {
    <app-inline-error
      [message]="state.error"
      (retry)="facade.reload()"
    />
  }
  @case ('ready') {
    <app-order-summary [order]="state.order" />

    @if (actionError; as message) {
      <app-inline-error [message]="message" />
    }

    <button
      type="button"
      [disabled]="!facade.canApprove() || facade.saving()"
      (click)="facade.approve()"
    >
      Approve order
    </button>
  }
}

A facade still needs a boundary

The failure mode is predictable: once the team likes facades, every method moves there. That produces a service with a cleaner name and the same lack of boundaries.

Reject a facade that only renames an API call. Reject a facade that is shared by unrelated features because it feels convenient. Reject a facade that makes simple template state harder to trace.

A good facade removes coordination from the component while keeping ownership clear. API services own transport. Stores own shared domain state. Adapters own translation from external contracts. The facade owns the page's feature workflow.

LogicBetter ownerReason
HTTP URL, headers, DTO parsingOrdersApi or an adapterThe backend contract should stay outside the page workflow
Cross-screen cart/auth stateStore or domain serviceThe state outlives one page
Permission value exposed by backend/authPermissionsServiceThe facade should consume permission data, not invent it
Can this specific order be approved now?Feature facadeIt combines order state and permission state for this screen
Button disabled state and retry actionComponent reads facade contractThe template should render decisions, not rebuild them

Test the decision, not the DOM

A facade gives the team a cheaper test target. You can test the approval rule, command guard, reload behavior, and dependency substitution without rendering the full page.

That matters because the most expensive component tests are often trying to prove feature rules that do not need the DOM. Angular's TestBed can wire the facade through DI and substitute slow or unpredictable dependencies with controlled providers.

The example avoids fakeAsync so it stays aligned with a Vitest or zoneless-friendly direction. If a legacy suite still uses Karma/Jasmine helpers, label that as stack-specific instead of making it the new default.

This is not a replacement for component tests. It is a way to keep feature rules from being tested only through DOM assertions.

Facade rule testts
describe('OrderApprovalFacade', () => {
  const order: Order = {
    id: 'A-100',
    status: 'pending',
    total: 420,
  };

  const api = {
    getOrder: vi.fn(() => of(order)),
    approveOrder: vi.fn(() => of(void 0)),
  };

  beforeEach(() => {
    api.getOrder.mockClear();
    api.approveOrder.mockClear();

    TestBed.configureTestingModule({
      providers: [
        OrderApprovalFacade,
        { provide: OrdersApi, useValue: api },
        {
          provide: PermissionsService,
          useValue: { canApproveOrders$: of(false) },
        },
      ],
    });
  });

  it('does not approve without permission', async () => {
    const facade = TestBed.inject(OrderApprovalFacade);

    facade.selectOrder('A-100');

    await TestBed.runInInjectionContext(() =>
      firstValueFrom(
        toObservable(facade.state).pipe(
          filter((state) => state.status === 'ready'),
          take(1)
        )
      )
    );

    expect(facade.state().status).toBe('ready');
    expect(facade.canApprove()).toBe(false);
    facade.approve();

    expect(api.approveOrder).not.toHaveBeenCalled();
  });
});

Reusable artifact

Facade review checklist

  • Create a facade when a component coordinates multiple feature contracts, not because every page needs one.
  • Keep API services responsible for backend transport and DTO details.
  • Expose a small page contract: state, derived decisions, commands, and reload/error behavior.
  • Provide the facade at the route or page boundary when the workflow should not become global state.
  • Reject facades that only proxy API calls or grow across unrelated features.
  • Test feature rules at the facade boundary before relying on full DOM tests.

Sources checked

Modern Angular Playbook

This article is one play.

The Modern Angular Playbook collects the diagnostic, the adoption matrix, eleven plays, and the 30-day plan. Free, in English and Portuguese.

You get both PDFs by email, through the Dojo IA list.

Open playbook →

André Ramosavailable for remote roles, UTC−3Get in touch →

Related

Guide · 9 min

Adapter Pattern in Angular: Isolating APIs and Browser-Only Libraries

A production Angular guide to the Adapter Pattern: where to translate external contracts, how to keep components clean, and when a wrapper is not worth creating.

Read article →

Guide · 10 min

Strategy Pattern in Angular: Swapping Rules Without Spreading Switches

A production Angular guide to the Strategy Pattern: when a switch becomes a boundary, how to select implementations with DI, and how to keep the pattern from becoming decorative indirection.

Read article →

Guide · 7 min

Standalone Without Turning NgModules Into a Villain

A practical rule for using standalone components, route providers, and remaining NgModules in mature Angular applications.

Read article →