Clean code & architecture

Pattern Strategy en Angular : validation dynamique de formulaires avec FormGroup

Validation de formulaires Angular selon le rôle ou le contexte : appliquez le pattern Strategy avec FormRecord, un resolver multi-provider et une factory qui réapplique les règles à chaud. Code Angular 17+ et tests.

Illustration : un formulaire Angular relié à des stratégies de validation interchangeables

En bref

Quand les règles de validation d’un formulaire Angular changent selon le rôle, le produit ou le pays, les if/else dans le composant deviennent ingérables. Le pattern Strategy isole chaque jeu de règles dans une classe dédiée, un resolver choisit la bonne stratégie, et une factory l’applique au formulaire — y compris à chaud, sans recréer le formulaire. Résultat : un composant qui ne connaît plus aucune règle métier, et des règles testables en trois lignes.

Lexique
Pattern Strategy

Design pattern (Gang of Four) qui encapsule des règles interchangeables derrière une interface commune, pour les choisir à l’exécution sans if/else.

FormGroup

Classe des Reactive Forms Angular qui regroupe plusieurs contrôles de formulaire et calcule leur validité globale.

FormRecord

Variante typée de FormGroup (Angular 14+) dont les clés sont dynamiques : idéale pour ajouter ou retirer des champs à l’exécution.

ValidatorFn

Signature d’une fonction de validation Angular : elle reçoit un contrôle et renvoie null si la valeur est valide, sinon un objet d’erreurs.

Validator custom

ValidatorFn écrit pour une règle métier précise (email d’entreprise, SIRET…), réutilisable dans plusieurs stratégies.

InjectionToken

Clé d’injection Angular pour fournir une valeur qui n’est pas une classe, comme une liste de stratégies.

Multi-provider

Option multi: true de l’injection de dépendances : plusieurs providers alimentent un même token, injecté sous forme de tableau.

Resolver de stratégie

Service qui choisit la bonne stratégie selon le contexte (rôle, pays, feature flag), à la place d’une cascade de conditions.

Factory

Objet chargé de construire et de réappliquer le formulaire à partir d’une stratégie, pour que le composant n’ait pas à le faire.

input() et effect()

API à base de signals (Angular 17+) : input() expose une propriété réactive, effect() réexécute du code à chaque changement de signal.

Signal Forms

Nouvelle API de formulaires à base de signals, introduite en expérimental dans Angular 21.

Principe Open/Closed (OCP)

Principe SOLID : un module est ouvert à l’extension mais fermé à la modification. Ajouter un profil = ajouter une classe.

Principe SRP

Single Responsibility Principle : une classe ne doit avoir qu’une seule raison de changer.

Introduction

Dans une application Angular riche en formulaires, la validation finit toujours par dépendre du contexte : un administrateur doit saisir des permissions, un partenaire un SIRET, un client particulier ni l’un ni l’autre. Un simple Validators.required ne suffit plus.

La tentation est de gérer ces variantes directement dans le composant. Ça fonctionne au début, puis chaque nouveau profil ajoute une branche, chaque branche un risque de régression. Le pattern Strategy (Gang of Four) règle ce problème proprement : on encapsule chaque jeu de règles dans une stratégie interchangeable, et le formulaire se contente de l’appliquer.

Ce guide montre une implémentation moderne (Angular 17+ : composants standalone, inject(), input() signal, contrôle de flux @if, formulaires typés avec FormRecord).


Problématique observée

  • Les validations sont codées en dur dans les composants, mêlées à la logique d’affichage.
  • Chaque nouveau cas d’usage ajoute des conditions imbriquées (if/else, switch) qui nuisent à la lisibilité.
  • Une règle métier qui change (ex. « l’email admin doit être un email d’entreprise ») oblige à modifier plusieurs composants.
  • Les tests unitaires doivent monter tout le composant pour vérifier une simple règle.
Avant / après le pattern Strategy À gauche, les règles de validation codées en if/else dans le composant. À droite, un resolver choisit une stratégie par contexte, appliquée par une factory au formulaire. AVANT Règles codées dans le composant ngOnInit() { if (role === ‘admin’) { email → required, corporate permissions → required } else if (role === ‘partner’) { siret → required, pattern } else { … } } Couplage fort if/else infini Tests fragiles APRÈS Une stratégie par contexte UserFormComponent StrategyResolver Default Admin Partner DynamicFormFactory.apply() Open/Closed 1 règle / classe Testable seule
Avant / après : le composant délègue le choix des règles à un resolver de stratégies.

Tentatives ratées (et pourquoi)

  1. Un service unique qui centralise toutes les règles : il grossit à chaque profil et viole le principe de responsabilité unique (SRP). On a déplacé le if/else, pas supprimé le problème.
  2. Uniquement des validators custom : parfaits pour une règle ponctuelle (corporateEmail, siret), mais ils ne décident pas quel ensemble de règles appliquer selon le contexte.
  3. Des directives de validation : bonnes pour la réutilisation dans le template, mais le choix contextuel reste dispersé dans le HTML.

La bonne réponse combine les deux niveaux : des validators atomiques réutilisables, et des stratégies qui les assemblent par contexte.


Solution technique : le pattern Strategy

Le pattern repose sur quatre pièces : une interface commune, des stratégies concrètes, un resolver qui choisit la stratégie selon le contexte, et une factory qui l’applique au formulaire.

Strategy fait partie des patterns comportementaux du Gang of Four. Pour situer les trois familles de patterns et savoir quand les utiliser, lisez Design patterns : la base de tout développement logiciel robuste.

Diagramme de classes du pattern Strategy appliqué aux formulaires Angular L’interface ValidationStrategy est implémentée par DefaultUserStrategy, AdminUserStrategy et PartnerStrategy. Le resolver reçoit toutes les stratégies par injection, le composant demande la stratégie au resolver puis l’applique via la DynamicFormFactory. «interface» ValidationStrategy + role: UserRole + controls(): Record<…> + groupValidators?() DefaultUserStrategyusername, email AdminUserStrategy+ permissions, corporateEmail PartnerStrategy+ siret implémente StrategyResolverresolve(role) DynamicFormFactorycreate() · apply() UserFormComponentrole = input() multi-provider ② ① resolve(role) → stratégie · ② apply(form, stratégie)
Diagramme de classes : ajouter un profil = ajouter une classe, sans toucher au reste.

Étape 1 — L’interface et le token d’injection

On type les validators avec ValidatorFn (et non any[]), et on prévoit des validators de groupe pour les règles transverses (ex. confirmation d’email).

// validation-strategy.ts
import { InjectionToken } from '@angular/core';
import { ValidatorFn } from '@angular/forms';

export type UserRole = 'default' | 'admin' | 'partner';

export interface ValidationStrategy {
  readonly role: UserRole;
  controls(): Record<string, ValidatorFn[]>;
  groupValidators?(): ValidatorFn[];
}

export const VALIDATION_STRATEGIES =
  new InjectionToken<ValidationStrategy[]>('VALIDATION_STRATEGIES');

Étape 2 — Des validators atomiques réutilisables

// validators.ts
import { AbstractControl, ValidationErrors, ValidatorFn } from '@angular/forms';

export function corporateEmail(domain: string): ValidatorFn {
  return (control: AbstractControl): ValidationErrors | null => {
    const value = (control.value ?? '') as string;
    return !value || value.endsWith('@' + domain)
      ? null
      : { corporateEmail: { domain } };
  };
}

Étape 3 — Les stratégies concrètes

Chaque stratégie assemble les validators pour un contexte. Elle ne connaît ni le composant ni le template.

// strategies.ts
import { Validators } from '@angular/forms';
import { ValidationStrategy } from './validation-strategy';
import { corporateEmail } from './validators';

const base = {
  username: [Validators.required, Validators.minLength(3)],
  email: [Validators.required, Validators.email],
};

export class DefaultUserStrategy implements ValidationStrategy {
  readonly role = 'default' as const;
  controls() { return { ...base }; }
}

export class AdminUserStrategy implements ValidationStrategy {
  readonly role = 'admin' as const;
  controls() {
    return {
      ...base,
      email: [...base.email, corporateEmail('cleancodecraft.com')],
      permissions: [Validators.required],
    };
  }
}

export class PartnerStrategy implements ValidationStrategy {
  readonly role = 'partner' as const;
  controls() {
    return {
      ...base,
      siret: [Validators.required, Validators.pattern(/^\d{14}$/)],
    };
  }
}

Étape 4 — Le resolver : choisir la stratégie sans if/else

Les stratégies sont enregistrées en multi-provider. Ajouter un profil revient à ajouter une ligne de configuration : le resolver, la factory et le composant restent inchangés (principe Open/Closed).

// strategy-resolver.ts
import { Injectable, inject } from '@angular/core';
import { UserRole, VALIDATION_STRATEGIES, ValidationStrategy } from './validation-strategy';

@Injectable({ providedIn: 'root' })
export class StrategyResolver {
  private readonly strategies = inject(VALIDATION_STRATEGIES);

  resolve(role: UserRole): ValidationStrategy {
    const strategy = this.strategies.find(s => s.role === role);
    if (!strategy) throw new Error(`Aucune stratégie pour le rôle "${role}"`);
    return strategy;
  }
}

// app.config.ts
export const appConfig: ApplicationConfig = {
  providers: [
    { provide: VALIDATION_STRATEGIES, useClass: DefaultUserStrategy, multi: true },
    { provide: VALIDATION_STRATEGIES, useClass: AdminUserStrategy, multi: true },
    { provide: VALIDATION_STRATEGIES, useClass: PartnerStrategy, multi: true },
  ],
};

Étape 5 — La factory : créer et réappliquer à chaud

FormRecord (Angular 14+) est fait pour les formulaires aux clés dynamiques. La méthode apply() ajoute les champs manquants, retire ceux qui ne s’appliquent plus et met à jour les validators sans perdre les valeurs déjà saisies.

// dynamic-form.factory.ts
import { Injectable, inject } from '@angular/core';
import { FormControl, FormRecord, NonNullableFormBuilder } from '@angular/forms';
import { ValidationStrategy } from './validation-strategy';

@Injectable({ providedIn: 'root' })
export class DynamicFormFactory {
  private readonly fb = inject(NonNullableFormBuilder);

  create(strategy: ValidationStrategy): FormRecord<FormControl<string>> {
    const form = this.fb.record<string>({});
    this.apply(form, strategy);
    return form;
  }

  apply(form: FormRecord<FormControl<string>>, strategy: ValidationStrategy): void {
    const rules = strategy.controls();

    Object.keys(form.controls)
      .filter(name => !(name in rules))
      .forEach(name => form.removeControl(name, { emitEvent: false }));

    for (const [name, validators] of Object.entries(rules)) {
      const control = form.controls[name] ?? this.fb.control('');
      control.setValidators(validators);
      if (!form.contains(name)) form.addControl(name, control, { emitEvent: false });
    }

    form.setValidators(strategy.groupValidators?.() ?? null);
    Object.values(form.controls).forEach(c => c.updateValueAndValidity({ emitEvent: false }));
    form.updateValueAndValidity();
  }
}

Étape 6 — Le composant ne connaît plus aucune règle

Le rôle arrive en input() signal ; un effect() réapplique la stratégie à chaque changement. Le template utilise le contrôle de flux @if (Angular 17+).

// user-form.component.ts
import { Component, effect, inject, input } from '@angular/core';
import { ReactiveFormsModule } from '@angular/forms';

@Component({
  selector: 'app-user-form',
  imports: [ReactiveFormsModule],
  template: `
    <form [formGroup]="form" (ngSubmit)="submit()">
      <input formControlName="username" placeholder="Nom d'utilisateur" />
      <input formControlName="email" type="email" placeholder="Email" />
      @if (form.contains('permissions')) {
        <input formControlName="permissions" placeholder="Permissions" />
      }
      @if (form.contains('siret')) {
        <input formControlName="siret" placeholder="SIRET (14 chiffres)" />
      }
      <button type="submit" [disabled]="form.invalid">Enregistrer</button>
    </form>
  `,
})
export class UserFormComponent {
  private readonly factory = inject(DynamicFormFactory);
  private readonly resolver = inject(StrategyResolver);

  readonly role = input<UserRole>('default');
  readonly form = this.factory.create(this.resolver.resolve('default'));

  constructor() {
    effect(() => this.factory.apply(this.form, this.resolver.resolve(this.role())));
  }

  submit(): void {
    if (this.form.valid) console.log(this.form.getRawValue());
  }
}

Note : depuis Angular 19, les composants sont standalone par défaut. Sur une version antérieure, ajoutez standalone: true.


Tester une stratégie en trois lignes

Chaque stratégie est une classe pure : pas de TestBed, pas de composant à monter. Ces tests fonctionnent avec Jasmine, Jest ou Vitest.

// admin-user.strategy.spec.ts
import { FormControl } from '@angular/forms';
import { AdminUserStrategy } from './strategies';

describe('AdminUserStrategy', () => {
  const rules = new AdminUserStrategy().controls();

  it('rend les permissions obligatoires', () => {
    const control = new FormControl('', rules['permissions']);
    expect(control.hasError('required')).toBe(true);
  });

  it('refuse un email hors du domaine de l\'entreprise', () => {
    const control = new FormControl('jane@gmail.com', rules['email']);
    expect(control.hasError('corporateEmail')).toBe(true);
  });
});

Gains concrets

CritèreAvant (if/else)Après (Strategy)
Ajouter un profilModifier le composant et ses testsAjouter une classe + une ligne de provider
Lieu des règles métierDispersé dans les composantsUne classe par contexte
Tester une règleMonter le composant avec TestBedInstancier la stratégie
Changer de rôle à chaudRecréer le formulaireapply() conserve les valeurs saisies
Principes SOLIDSRP et OCP violésSRP, OCP et DIP respectés

Côté performance, l’impact est neutre : le gain se situe dans la maintenabilité et la réduction du risque de régression, pas dans le temps d’exécution.

Les principes Open/Closed et d’inversion des dépendances sont détaillés, exemple avant/après à l’appui, dans SOLID en JavaScript : les 5 principes expliqués avec du code.


Bonnes pratiques à appliquer ailleurs

  • Strategy + Factory : le resolver choisit la stratégie à partir du contexte (rôle, feature flag, pays, type de produit).
  • Rendu dynamique : le même pattern s’applique aux colonnes d’un tableau Angular Material ou aux étapes d’un stepper.
  • Pilotage par l’état : avec NgRx ou un store à base de signals, le rôle provient d’un sélecteur et l’effect() fait le reste.
  • Règles transverses : utilisez groupValidators() pour les validations multi-champs (dates de début/fin, confirmation d’email).
  • Gardez les stratégies sans état : elles décrivent des règles, elles ne stockent pas de données.

Et les Signal Forms ?

Angular 21 a introduit les Signal Forms en mode expérimental. Le principe reste identique : vos stratégies peuvent produire un schéma de validation au lieu d’une liste de ValidatorFn. Pour une application en production, les Reactive Forms restent la valeur sûre tant que l’API n’est pas stabilisée.

Pour que ces formulaires dynamiques restent rapides, combinez-les avec la stratégie OnPush et des composants qui ne dépendent que de leurs inputs : voir Angular performance et clean code.


Références


FAQ

Pourquoi ne pas utiliser uniquement des validators custom ?

Un validator custom répond à une règle ponctuelle (un email d’entreprise, un SIRET). Il ne décide pas quel ensemble de règles appliquer selon le contexte. Les deux se complètent : les validators sont les briques, les stratégies les assemblent.

Peut-on changer de stratégie sans recréer le formulaire ?

Oui. La méthode apply() de la factory ajoute ou retire les champs, met à jour les validators puis appelle updateValueAndValidity(). Les valeurs déjà saisies sont conservées.

Peut-on combiner le pattern Strategy avec NgRx ou les signals ?

Oui. Le rôle ou le feature flag provient d’un sélecteur NgRx ou d’un signal, et un effect() réapplique la stratégie correspondante à chaque changement.

Comment tester une stratégie ?

Instanciez la stratégie, créez un FormControl avec ses validators et vérifiez hasError(). Aucun TestBed ni composant n’est nécessaire.

Est-ce compatible avec les FormArray ?

Oui. Étendez l’interface avec une méthode qui décrit les tableaux (par exemple une liste d’adresses) et laissez la factory créer les FormArray correspondants.

Le pattern Strategy a-t-il sa place dans une architecture DDD côté frontend ?

Oui. Chaque stratégie rend explicite une règle métier d’un contexte donné, ce qui s’aligne naturellement avec la notion de bounded context.


Pour aller plus loin

Ce pattern est un exemple concret du principe Open/Closed. Retrouvez-le, avec les autres principes SOLID appliqués à des cas e-commerce, dans nos formations Angular et Clean Code.

Une leçon, un kata, chaque mois

Les nouveaux articles et un exercice de refactoring, directement par e-mail.

S'inscrire à la newsletter