Modern Angular
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ção | Candidato a Strategy? | Motivo |
|---|---|---|
| Preço por tipo de conta | Sim | Mesmo contrato de quote, regras e mensagens diferentes |
| Política de permissão por tenant | Sim | Mesmas checagens de ação, fonte de política diferente |
| Fluxo de checkout por rota | Sim | A rota pode selecionar a política antes da página rodar |
| Um condicional em um componente | Não | Um branch local é mais barato que indireção com DI |
| Formatação visual pura | Normalmente não | Um 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.
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.
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.
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);
}
},
},
];
}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.
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.
@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())
);
}@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ódigo | Dono melhor | Motivo |
|---|---|---|
| Preço, permissão ou workflow por tenant | Strategy selecionada por DI | O chamador precisa de um contrato com regras intercambiáveis |
| Um branch local de UI | Componente ou template | A variação não é uma política reutilizável |
| Valor derivado síncrono de UI | computed | Signals já expressam derivação local |
| Formatação de valor para exibição | Pipe ou formatter | A regra é de apresentação, não uma fronteira de política |
| Tradução de DTO do backend | Adapter | O 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.
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
- https://angular.dev/guide/di
- https://angular.dev/guide/di/dependency-injection-providers
- https://angular.dev/api/core/InjectionToken
- https://angular.dev/guide/http/interceptors
- https://github.com/angular/components/blob/main/src/material/core/error/error-options.ts
- https://www.angularspace.com/strategy-pattern-the-angular-way-di-and-runtime-flexibility/
- https://en.wikipedia.org/wiki/Design_Patterns
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.
André Ramosdisponível para vagas remotas, UTC−3Entre em contato →