Strategy Pattern no Angular: trocando regras sem espalhar switches

Strategy merece existir quando uma regra realmente varia por tenant, plano, rota, permissão ou política de runtime, e essa variação começa a vazar pela feature. A Gang of Four definiu Strategy em 1994 como 'uma família de algoritmos intercambiáveis, selecionados em runtime'. No Angular, o ErrorStateMatcher no Material e o multi-provider HTTP_INTERCEPTORS entregam o pattern via DI token, sem switch espalhado pela base.

Comece pela variação repetida

Não comece pelo nome do pattern. Comece pelo cheiro de review: a mesma decisão aparece no componente, template, validação, analytics e testes. Cada novo tenant ou plano adiciona outro switch, e cada switch fica um pouco diferente.

Eu costumo usar esse formato quando uma regra de produto começa a aparecer no componente, no template e nos testes com branches parecidos, mas não idênticos.

É aí que Strategy pode ajudar. A regra varia, o chamador quer um contrato estável e o DI do Angular pode selecionar a implementação em uma fronteira de rota, componente ou aplicação.

Se a regra aparece uma vez só, uma função provavelmente basta. Se a regra muda a cada tecla digitada, um computed talvez baste. Strategy é mais forte quando uma política estável precisa de implementações intercambiáveis.

VariaçãoCandidato a Strategy?Motivo
Preço por tipo de contaSimMesmo contrato de quote, regras e mensagens diferentes
Política de permissão por tenantSimMesmas checagens de ação, fonte de política diferente
Fluxo de checkout por rotaSimA rota pode selecionar a política antes da página rodar
Um condicional em um componenteNãoUm branch local é mais barato que indireção com DI
Formatação visual puraNormalmente nãoUm pipe ou formatter costuma ser mais claro

O cheiro: switches se espalham com a regra

Esta página de checkout tem uma fonte real de complexidade: política de preço. O componente calcula descontos, mensagens de revisão e totais. Parece inofensivo até o próximo plano adicionar uma nova exceção, ou o fluxo enterprise precisar de outra regra de revisão.

A duplicação não é só visual. O switch aparece em mais de um computed, então um branch pode ser atualizado enquanto outro fica com o comportamento antigo. Os testes também precisam saber qual branch aparece em qual computed.

O pattern se justifica quando o time consegue nomear a regra que varia. Aqui a regra não é estado de checkout em geral. É a política de preço para um tipo de conta selecionado.

Política espalhada pela páginats
type AccountKind = 'selfServe' | 'enterprise' | 'education';
type BillingCycle = 'monthly' | 'annual';

@Component({
  selector: 'app-checkout-page',
  templateUrl: './checkout-page.html',
})
export class CheckoutPage {
  readonly accountKind = input.required<AccountKind>();
  readonly seats = signal(12);
  readonly billingCycle = signal<BillingCycle>('annual');

  readonly subtotalCents = computed(() => this.seats() * 2900);

  readonly discountCents = computed(() => {
    switch (this.accountKind()) {
      case 'selfServe':
        return this.billingCycle() === 'annual'
          ? Math.round(this.subtotalCents() * 0.1)
          : 0;
      case 'enterprise':
        return Math.round(this.subtotalCents() * 0.2);
      case 'education':
        return Math.round(this.subtotalCents() * 0.5);
    }
  });

  readonly reviewMessage = computed(() => {
    switch (this.accountKind()) {
      case 'selfServe':
        return null;
      case 'enterprise':
        return 'A contract review is required before payment.';
      case 'education':
        return 'Eligibility must be verified before activation.';
    }
  });

  readonly totalCents = computed(() =>
    this.subtotalCents() - this.discountCents()
  );
}

Extraia o contrato da política

Um contrato de strategy deve ser pequeno o suficiente para o chamador não precisar saber qual implementação recebeu. No checkout, a página envia um CheckoutContext e recebe um CheckoutQuote.

Neste artigo, política de preço é a regra de negócio que varia; strategy é o formato de implementação usado para trocar essa política com segurança.

Mantenha o contrato compartilhado sem drama. A variação fica dentro de cada strategy, não no chamador. Self-serve, enterprise e education agora podem mudar de forma independente sem adicionar outro branch ao componente.

As classes não são valiosas por serem classes. Elas são valiosas porque cada uma assume uma política que produto, QA e code review conseguem discutir pelo nome.

Contrato da strategy de preçots
type BillingCycle = 'monthly' | 'annual';

interface CheckoutContext {
  seats: number;
  billingCycle: BillingCycle;
}

interface CheckoutQuote {
  subtotalCents: number;
  discountCents: number;
  totalCents: number;
  reviewMessage: string | null;
}

interface CheckoutPricingStrategy {
  quote(context: CheckoutContext): CheckoutQuote;
}

const seatPriceCents = 2900;

function baseQuote(context: CheckoutContext): CheckoutQuote {
  const subtotalCents = context.seats * seatPriceCents;

  return {
    subtotalCents,
    discountCents: 0,
    totalCents: subtotalCents,
    reviewMessage: null,
  };
}

@Injectable()
export class SelfServePricingStrategy implements CheckoutPricingStrategy {
  quote(context: CheckoutContext): CheckoutQuote {
    const quote = baseQuote(context);
    const discountCents = context.billingCycle === 'annual'
      ? Math.round(quote.subtotalCents * 0.1)
      : 0;

    return {
      ...quote,
      discountCents,
      totalCents: quote.subtotalCents - discountCents,
    };
  }
}

@Injectable()
export class EnterprisePricingStrategy implements CheckoutPricingStrategy {
  quote(context: CheckoutContext): CheckoutQuote {
    const quote = baseQuote(context);
    const discountCents = Math.round(quote.subtotalCents * 0.2);

    return {
      ...quote,
      discountCents,
      totalCents: quote.subtotalCents - discountCents,
      reviewMessage: 'A contract review is required before payment.',
    };
  }
}

@Injectable()
export class EducationPricingStrategy implements CheckoutPricingStrategy {
  quote(context: CheckoutContext): CheckoutQuote {
    const quote = baseQuote(context);
    const discountCents = Math.round(quote.subtotalCents * 0.5);

    return {
      ...quote,
      discountCents,
      totalCents: quote.subtotalCents - discountCents,
      reviewMessage: 'Eligibility must be verified before activation.',
    };
  }
}

Selecione a strategy com DI do Angular

Interfaces TypeScript somem em runtime, então o Angular não consegue injetar CheckoutPricingStrategy diretamente. Use um InjectionToken como chave de runtime e forneça a implementação selecionada pelo sistema de providers do Angular.

Isso mantém a seleção fora do componente. A página pede CHECKOUT_PRICING_STRATEGY; a rota, componente ou configuração da app decide qual implementação satisfaz esse token.

A factory deve injetar apenas a implementação selecionada. Listar as classes em providers registra os provider records; o switch abaixo evita pedir ao Angular strategies que aquela rota não vai usar.

Não esconda estado de negócio dinâmico dentro do provider. Seleção por rota funciona bem quando rota, tenant, plano ou shell já decidem a política. Se o usuário pode trocar política dentro da tela, modele esse estado explicitamente em vez de fingir que DI é estado reativo.

Provider da strategyts
type AccountKind = 'selfServe' | 'enterprise' | 'education';

export const CHECKOUT_PRICING_STRATEGY =
  new InjectionToken<CheckoutPricingStrategy>(
    'checkout.pricing.strategy'
  );

export function provideCheckoutPricingStrategy(
  kind: AccountKind
): Provider[] {
  return [
    SelfServePricingStrategy,
    EnterprisePricingStrategy,
    EducationPricingStrategy,
    {
      provide: CHECKOUT_PRICING_STRATEGY,
      useFactory: () => {
        switch (kind) {
          case 'selfServe':
            return inject(SelfServePricingStrategy);
          case 'enterprise':
            return inject(EnterprisePricingStrategy);
          case 'education':
            return inject(EducationPricingStrategy);
        }
      },
    },
  ];
}
DI não é estado de telats
const selectedKind = signal<AccountKind>('selfServe');

export const routes: Routes = [
  {
    path: 'checkout',
    // Avoid this when selectedKind can change inside the screen.
    providers: provideCheckoutPricingStrategy(selectedKind()),
    loadComponent: () =>
      import('./checkout/checkout-page.component')
        .then((m) => m.CheckoutPage),
  },
];

Deixe a rota assumir política específica

Rotas Angular podem ter providers próprios, o que as torna uma boa fronteira para política que muda por rota. O componente de checkout continua igual; a rota decide se a página roda com preço self-serve, enterprise ou education.

Essa é a diferença entre Strategy e um switch espalhado. A decisão acontece uma vez na fronteira onde a variação entra na feature.

Strategy selecionada pela rotats
export const checkoutRoutes: Routes = [
  {
    path: 'checkout/self-serve',
    providers: provideCheckoutPricingStrategy('selfServe'),
    loadComponent: () =>
      import('./checkout/checkout-page.component')
        .then((m) => m.CheckoutPage),
  },
  {
    path: 'checkout/enterprise',
    providers: provideCheckoutPricingStrategy('enterprise'),
    loadComponent: () =>
      import('./checkout/checkout-page.component')
        .then((m) => m.CheckoutPage),
  },
  {
    path: 'checkout/education',
    providers: provideCheckoutPricingStrategy('education'),
    loadComponent: () =>
      import('./checkout/checkout-page.component')
        .then((m) => m.CheckoutPage),
  },
];

O componente consome um contrato

Depois que a strategy é selecionada, o componente fica menor sem perder comportamento. Ele ainda assume estado atual de UI: seats e billing cycle. Ele não assume política de preço.

Signals funcionam bem aqui porque a quote é estado derivado síncrono. O componente pode recomputar a quote quando inputs locais mudam, enquanto a strategy selecionada continua sendo uma dependência estável daquela instância de rota.

O template lê essa quote com @let, então o markup tem um nome local para o valor derivado e não precisa de non-null assertion na mensagem de aviso.

Contrato da página de checkoutts
@Component({
  selector: 'app-checkout-page',
  templateUrl: './checkout-page.html',
})
export class CheckoutPage {
  private readonly pricing = inject(CHECKOUT_PRICING_STRATEGY);

  readonly seats = signal(12);
  readonly billingCycle = signal<BillingCycle>('annual');

  private readonly context = computed(() => ({
    seats: this.seats(),
    billingCycle: this.billingCycle(),
  }));

  readonly quote = computed(() =>
    this.pricing.quote(this.context())
  );
}
Contrato do templatehtml
@let currentQuote = quote();

<section class="checkout-summary">
  <p>Subtotal: {{ currentQuote.subtotalCents | cents }}</p>
  <p>Discount: {{ currentQuote.discountCents | cents }}</p>
  <strong>Total: {{ currentQuote.totalCents | cents }}</strong>

  @if (currentQuote.reviewMessage; as message) {
    <app-inline-warning [message]="message" />
  }
</section>

Não transforme todo branch em strategy

O modo de falha é previsível: depois que o time vê Strategy funcionando, todo if começa a parecer pattern. Isso cria mais arquivos, mais providers e menos leitura direta.

Use Strategy quando ela dá uma casa explícita para uma regra variável. Evite quando a variação é local, temporária ou mais fácil de entender como expressão direta.

Formato do códigoDono melhorMotivo
Preço, permissão ou workflow por tenantStrategy selecionada por DIO chamador precisa de um contrato com regras intercambiáveis
Um branch local de UIComponente ou templateA variação não é uma política reutilizável
Valor derivado síncrono de UIcomputedSignals já expressam derivação local
Formatação de valor para exibiçãoPipe ou formatterA regra é de apresentação, não uma fronteira de política
Tradução de DTO do backendAdapterO problema é tradução de contrato, não comportamento intercambiável

Teste a regra selecionada

Uma strategy deve baratear testes. Você pode testar a implementação selecionada pelo mesmo token que o componente injeta, ou substituir por uma strategy fake ao testar a página.

Este teste não renderiza checkout. Ele prova que a configuração de route/provider consegue selecionar a regra education e que a regra produz a quote esperada. Testes de componente podem focar em renderizar o contrato.

Teste de seleção da strategyts
describe('checkout pricing strategy', () => {
  beforeEach(() => {
    TestBed.configureTestingModule({
      providers: [provideCheckoutPricingStrategy('education')],
    });
  });

  it('applies the selected pricing rule through DI', () => {
    const strategy = TestBed.inject(CHECKOUT_PRICING_STRATEGY);

    const quote = strategy.quote({
      seats: 10,
      billingCycle: 'annual',
    });

    expect(quote.subtotalCents).toBe(29000);
    expect(quote.discountCents).toBe(14500);
    expect(quote.totalCents).toBe(14500);
    expect(quote.reviewMessage).toContain('Eligibility');
  });
});

Artefato para reaproveitar

Checklist de review para Strategy

  • Crie uma strategy apenas quando uma regra real varia por trás de um contrato estável.
  • Nomeie a política que varia antes de adicionar interfaces, tokens ou providers.
  • Use um InjectionToken quando o chamador precisa injetar um contrato em formato de interface.
  • Selecione strategies em fronteiras de rota, componente ou app quando a política é estável naquela fronteira.
  • Não use DI como estado reativo quando o usuário pode trocar a política dentro da tela.
  • Mantenha derivação local de UI em computed em vez de empurrar todo branch para DI.
  • Teste a regra selecionada pelo mesmo token de provider que a feature consome.

Fontes consultadas

Modern Angular Playbook

Este artigo é um play.

O Playbook do Angular Moderno reúne o diagnóstico, a matriz de adoção, onze jogadas e o plano de 30 dias. Grátis, em inglês e português.

Você recebe os dois PDFs por email, pela lista do Dojo IA.

Abrir playbook →

André Ramosdisponível para vagas remotas, UTC−3Entre em contato →

Leia também

Guia · 9 min

Adapter Pattern no Angular: isolando APIs e bibliotecas browser-only

Um guia de produção sobre Adapter Pattern no Angular: onde traduzir contratos externos, como manter componentes limpos e quando um wrapper não compensa.

Ler artigo →

Guia · 9 min

Facade Pattern no Angular: quando o componente sabe demais

Um guia de produção sobre Facade Pattern no Angular: quando criar, o que ela deve assumir, o que não deve esconder e como testar esse limite.

Ler artigo →

Guia · 7 min

Standalone sem transformar NgModules em vilões

Uma regra prática para usar componentes standalone, providers por rota e NgModules remanescentes em aplicações Angular maduras.

Ler artigo →