Skip to content

Vous souhaitez recevoir de l'aide sur ce sujet ? rejoignez la communauté Angular.fr sur Discord.

Patterns de conception pour le routing Angular

Le routing Angular peut rapidement devenir un cauchemar si on ne suit pas certaines bonnes pratiques. Cette page présente les patterns de conception qui permettent de garder votre code propre, maintenable et scalable, en évitant le fameux "code spaghetti".

Problème courant

Sans structure appropriée, on se retrouve rapidement avec des composants qui "comprennent" l'URL en utilisant des conditions comme if (route.url.includes('me')), des pages dupliquées comme /user-me, /user-current, /user-detail, des logiques métier dispersées dans les composants, et du code difficile à tester et maintenir.

Pattern 1 — Route = Stratégie (le plus scalable)

Concept

La route choisit une stratégie de chargement, pas la donnée directement. C'est le pattern le plus puissant pour les gros projets.

Flux : URL → Route → Strategy → Service → Page

Structure

plaintext
app/
├── routes/
│   ├── user.routes.ts
│   └── strategies/
│       ├── load-current-user.strategy.ts
│       └── load-user-by-id.strategy.ts
├── services/
│   └── user.service.ts
└── pages/
    └── user-detail.component.ts

Exemple complet

ts
import { Routes } from '@angular/router';
import { loadCurrentUserStrategy } from './strategies/load-current-user.strategy';
import { loadUserByIdStrategy } from './strategies/load-user-by-id.strategy';

export const userRoutes: Routes = [
  {
    path: 'me',
    component: UserDetailComponent,
    data: { strategy: loadCurrentUserStrategy }
  },
  {
    path: ':id',
    component: UserDetailComponent,
    data: { strategy: loadUserByIdStrategy }
  }
];
ts
import { inject } from '@angular/core';
import { ResolveFn } from '@angular/router';
import { UserService } from '../../services/user.service';
import { User } from '../../models/user.model';

/**
 * Strategy to load the current authenticated user.
 * 
 * This strategy is used when navigating to `/user/me` to load
 * the currently authenticated user's profile data.
 * 
 * @example
 * ```ts
 * {
 *   path: 'me',
 *   component: UserDetailComponent,
 *   data: { strategy: loadCurrentUserStrategy }
 * }
 * ```
 */
export const loadCurrentUserStrategy: ResolveFn<User> = () => {
  const userService = inject(UserService);
  return userService.getCurrentUser();
};
ts
import { inject } from '@angular/core';
import { ResolveFn } from '@angular/router';
import { UserService } from '../../services/user.service';
import { User } from '../../models/user.model';

/**
 * Strategy to load a specific user by ID from route parameters.
 * 
 * This strategy extracts the user ID from the route parameters
 * and fetches the corresponding user data from the service.
 * 
 * @example
 * ```ts
 * {
 *   path: ':id',
 *   component: UserDetailComponent,
 *   data: { strategy: loadUserByIdStrategy }
 * }
 * ```
 */
export const loadUserByIdStrategy: ResolveFn<User> = (route) => {
  const userService = inject(UserService);
  const userId = route.paramMap.get('id')!;
  return userService.getUserById(userId);
};
ts
import { Component, Input } from '@angular/core';
import { User } from '../models/user.model';

@Component({
  selector: 'app-user-detail',
  standalone: true,
  template: `
    <div class="user-detail">
      <h1>{{ user.name }}</h1>
      <p>{{ user.email }}</p>
    </div>
  `
})
export class UserDetailComponent {
  @Input() user!: User;
}
ts
import { Routes } from '@angular/router';
import { ResolveFn } from '@angular/router';
import { User } from '../models/user.model';
import { loadCurrentUserStrategy } from './strategies/load-current-user.strategy';
import { loadUserByIdStrategy } from './strategies/load-user-by-id.strategy';

/**
 * Generic resolver that uses the strategy pattern.
 * Extracts the strategy from route data and executes it.
 */
const userResolver: ResolveFn<User> = (route) => {
  const strategy = route.data['strategy'] as ResolveFn<User>;
  if (!strategy) {
    throw new Error('No strategy provided for user route');
  }
  return strategy(route);
};

export const userRoutes: Routes = [
  {
    path: 'me',
    component: UserDetailComponent,
    resolve: { user: userResolver },
    data: { strategy: loadCurrentUserStrategy }
  },
  {
    path: ':id',
    component: UserDetailComponent,
    resolve: { user: userResolver },
    data: { strategy: loadUserByIdStrategy }
  }
];

Quand l'utiliser

Ce pattern est idéal pour les gros projets avec beaucoup de routes et une logique métier complexe. Il fonctionne particulièrement bien lorsque vous avez la même interface utilisateur mais avec des sources de données différentes. C'est également le choix parfait pour les applications à long terme qui vont évoluer, et il est parfaitement adapté au SSR (Server-Side Rendering).

Avantages

Ce pattern est ultra scalable car vous pouvez ajouter de nouvelles stratégies sans toucher au composant. Il élimine toute duplication de code puisque une seule page suffit pour toutes les variantes. Chaque stratégie peut être testée indépendamment, ce qui facilite grandement les tests. Les resolvers fonctionnent parfaitement avec le SSR, ce qui en fait un choix adapté pour les applications nécessitant un rendu côté serveur. Enfin, il offre une excellente séparation des responsabilités en gardant la logique métier hors du composant.

Inconvénients

Ce pattern est plus abstrait et nécessite que toute l'équipe comprenne et suive le pattern. Il implique également une structure de fichiers plus complexe avec plus de fichiers à gérer, ce qui peut sembler lourd pour des projets plus petits.


Pattern 2 — Resolver conditionnel simple

Concept

Le resolver décide en fonction de l'URL ou des paramètres de route. C'est un pattern simple et efficace pour les petits/moyens projets.

Exemple

ts
import { inject } from '@angular/core';
import { ResolveFn } from '@angular/router';
import { UserService } from './user.service';
import { User } from './user.model';

/**
 * Conditional resolver that loads either current user or user by ID.
 * 
 * This resolver checks if the route has an 'id' parameter:
 * - If 'id' exists: loads the user with that ID
 * - Otherwise: loads the current authenticated user
 * 
 * @example
 * Used in routes without needing separate paths:
 * ```ts
 * {
 *   path: 'user/:id?',
 *   component: UserDetailComponent,
 *   resolve: { user: userResolver }
 * }
 * ```
 */
export const userResolver: ResolveFn<User> = (route) => {
  const userService = inject(UserService);
  const id = route.paramMap.get('id');
  
  return id 
    ? userService.getUserById(id)
    : userService.getCurrentUser();
};
ts
import { Routes } from '@angular/router';
import { userResolver } from './user.resolver';

export const userRoutes: Routes = [
  {
    path: 'user/:id',
    component: UserDetailComponent,
    resolve: { user: userResolver }
  },
  {
    path: 'user',
    component: UserDetailComponent,
    resolve: { user: userResolver }
  }
];

Quand l'utiliser

Ce pattern convient particulièrement aux petits et moyens projets avec une logique simple qui ne dépasse pas deux ou trois conditions. Il est parfait pour les cas où il y a peu de variations dans le chargement des données et pour le prototypage rapide.

Avantages

Ce pattern est rapide à mettre en place car tout se fait dans un seul fichier. La structure reste simple avec peu de fichiers à gérer. La logique est évidente au premier coup d'œil, ce qui facilite la compréhension du code.

Limites

Les structures if/else ont tendance à grossir rapidement et deviennent ingérables lorsqu'il y a trop de conditions. Ce pattern est moins scalable car il devient difficile d'ajouter de nouvelles variantes. Il peut également conduire à de la duplication si la logique devient complexe.

Quand ça devient problématique

Si vous avez plus de 3 conditions dans votre resolver, passez au Pattern 1 (Route = Stratégie).


Pattern 3 — Service façade basé sur la route

Concept

Le composant délègue au service la lecture de la route. Pas de resolver, chargement dynamique dans le composant.

Exemple

ts
import { Injectable, inject } from '@angular/core';
import { ActivatedRoute } from '@angular/router';
import { switchMap } from 'rxjs/operators';
import { UserService } from './user.service';
import { User } from './user.model';

/**
 * Facade service that handles user data loading based on route parameters.
 * 
 * This service observes route parameter changes and automatically loads
 * the appropriate user data. It's useful for highly interactive pages
 * where data needs to refresh dynamically without blocking navigation.
 * 
 * @example
 * ```ts
 * // In component
 * export class UserDetailComponent {
 *   userFacade = inject(UserFacade);
 *   user$ = this.userFacade.user$;
 * }
 * ```
 */
@Injectable({ providedIn: 'root' })
export class UserFacade {
  private route = inject(ActivatedRoute);
  private userService = inject(UserService);

  user$ = this.route.paramMap.pipe(
    switchMap(params => {
      const id = params.get('id');
      return id
        ? this.userService.getUserById(id)
        : this.userService.getCurrentUser();
    })
  );
}
ts
import { Component, inject } from '@angular/core';
import { AsyncPipe } from '@angular/common';
import { UserFacade } from './user.facade';

@Component({
  selector: 'app-user-detail',
  standalone: true,
  imports: [AsyncPipe],
  template: `
    @if (user$ | async; as user) {
      <div class="user-detail">
        <h1>{{ user.name }}</h1>
        <p>{{ user.email }}</p>
      </div>
    } @else {
      <div>Loading...</div>
    }
  `
})
export class UserDetailComponent {
  userFacade = inject(UserFacade);
  user$ = this.userFacade.user$;
}
ts
import { Routes } from '@angular/router';

export const userRoutes: Routes = [
  {
    path: 'user/:id',
    component: UserDetailComponent
  },
  {
    path: 'user',
    component: UserDetailComponent
  }
];

Version avec Signals (Angular 17+)

ts
import { Injectable, inject, computed, effect } from '@angular/core';
import { ActivatedRoute, Router } from '@angular/router';
import { toSignal } from '@angular/core/rxjs-interop';
import { switchMap } from 'rxjs/operators';
import { UserService } from './user.service';

/**
 * Facade service using Angular Signals for reactive user data management.
 * 
 * This implementation uses signals for better performance and simpler
 * reactive updates. The user data automatically updates when route
 * parameters change.
 * 
 * @example
 * ```ts
 * export class UserDetailComponent {
 *   userFacade = inject(UserFacade);
 *   user = this.userFacade.user(); // Signal value
 * }
 * ```
 */
@Injectable({ providedIn: 'root' })
export class UserFacade {
  private route = inject(ActivatedRoute);
  private userService = inject(UserService);

  private params = toSignal(this.route.paramMap);
  
  private userId = computed(() => this.params()?.get('id'));

  user = toSignal(
    this.route.paramMap.pipe(
      switchMap(params => {
        const id = params.get('id');
        return id
          ? this.userService.getUserById(id)
          : this.userService.getCurrentUser();
      })
    )
  );
}
ts
import { Component, inject } from '@angular/core';
import { UserFacade } from './user.facade';

@Component({
  selector: 'app-user-detail',
  standalone: true,
  template: `
    @if (user(); as userData) {
      <div class="user-detail">
        <h1>{{ userData.name }}</h1>
        <p>{{ userData.email }}</p>
      </div>
    } @else {
      <div>Loading...</div>
    }
  `
})
export class UserDetailComponent {
  userFacade = inject(UserFacade);
  user = this.userFacade.user;
}

Quand l'utiliser

Ce pattern est idéal pour les pages très interactives où les données changent fréquemment. Il convient parfaitement lorsqu'il n'y a pas besoin de bloquer la navigation, dans les applications utilisant Signals, et lorsque vous avez besoin d'une expérience utilisateur fluide.

Avantages

Ce pattern permet une navigation immédiate car il n'utilise pas de resolver. Les données se mettent à jour automatiquement, offrant un refresh dynamique. Avec Signals, l'implémentation devient très élégante et s'intègre parfaitement dans les applications Angular modernes. La façade est réutilisable et peut être utilisée dans plusieurs composants.

Inconvénients

Ce pattern donne plus de responsabilités au composant qui doit gérer l'état de chargement. Il est moins optimal pour le SSR car il n'y a pas de préchargement des données avant le rendu. La gestion de l'état devient plus complexe pour des workflows qui s'étendent sur plusieurs pages.


Pattern 4 — Route Data (configuration-only)

Concept

La route passe un mode ou une intention dans data, pas une donnée. La logique métier reste dans le resolver ou service.

Exemple

ts
import { Routes } from '@angular/router';
import { userResolver } from './user.resolver';

export const userRoutes: Routes = [
  {
    path: 'me',
    component: UserDetailComponent,
    resolve: { user: userResolver },
    data: { mode: 'CURRENT_USER' }
  },
  {
    path: ':id',
    component: UserDetailComponent,
    resolve: { user: userResolver },
    data: { mode: 'BY_ID' }
  }
];
ts
import { inject } from '@angular/core';
import { ResolveFn } from '@angular/router';
import { UserService } from './user.service';
import { User } from './user.model';

/**
 * Resolver that uses route data mode to determine loading strategy.
 * 
 * This resolver checks the 'mode' in route.data to decide how to load
 * the user data. Simple and readable for straightforward cases.
 * 
 * @example
 * Routes define the mode in data:
 * ```ts
 * { path: 'me', data: { mode: 'CURRENT_USER' } }
 * { path: ':id', data: { mode: 'BY_ID' } }
 * ```
 */
export const userResolver: ResolveFn<User> = (route) => {
  const userService = inject(UserService);
  const mode = route.data['mode'] as 'CURRENT_USER' | 'BY_ID';

  if (mode === 'CURRENT_USER') {
    return userService.getCurrentUser();
  }

  if (mode === 'BY_ID') {
    const id = route.paramMap.get('id')!;
    return userService.getUserById(id);
  }

  throw new Error(`Unknown mode: ${mode}`);
};

Quand l'utiliser

Ce pattern est idéal lorsque l'intention est simple et claire, avec peu de variantes (deux à trois modes maximum). Il produit un code de configuration très lisible.

Avantages

Le mode est évident directement dans la route, ce qui rend le code très lisible. L'implémentation reste très simple sans complexité inutile. L'intention est explicite, ce qui facilite la compréhension du code par d'autres développeurs.

Limites

La logique métier reste dans le resolver, ce qui peut devenir complexe si elle grandit. Ce pattern n'est pas très extensible et il devient difficile d'ajouter beaucoup de modes différents. Il peut également conduire à de la duplication si plusieurs routes utilisent le même mode.


Pattern 5 — Store global (NgRx / SignalStore)

Concept

La route déclenche une action, le store gère tout. Idéal pour des applications avec state management global.

Exemple avec NgRx

ts
import { createAction, props } from '@ngrx/store';
import { User } from './user.model';

export const loadUser = createAction(
  '[User] Load User',
  props<{ userId?: string }>()
);

export const loadUserSuccess = createAction(
  '[User] Load User Success',
  props<{ user: User }>()
);

export const loadUserFailure = createAction(
  '[User] Load User Failure',
  props<{ error: string }>()
);
ts
import { Injectable, inject } from '@angular/core';
import { Actions, createEffect, ofType } from '@ngrx/effects';
import { Router, ActivatedRoute } from '@angular/router';
import { map, switchMap, catchError } from 'rxjs/operators';
import { of } from 'rxjs';
import { UserService } from './user.service';
import * as UserActions from './user.actions';

/**
 * NgRx effects that handle user loading based on route navigation.
 * 
 * This effect listens to route parameter changes and triggers
 * user loading actions. It's part of the store-driven routing pattern.
 * 
 * @example
 * When user navigates to /user/123, the effect automatically
 * dispatches loadUser action with userId: '123'
 */
@Injectable()
export class UserEffects {
  private actions$ = inject(Actions);
  private userService = inject(UserService);
  private router = inject(Router);

  loadUser$ = createEffect(() =>
    this.actions$.pipe(
      ofType(UserActions.loadUser),
      switchMap(({ userId }) =>
        (userId 
          ? this.userService.getUserById(userId)
          : this.userService.getCurrentUser()
        ).pipe(
          map(user => UserActions.loadUserSuccess({ user })),
          catchError(error => of(UserActions.loadUserFailure({ 
            error: error.message 
          })))
        )
      )
    )
  );
}
ts
import { Component, OnInit, inject } from '@angular/core';
import { Store } from '@ngrx/store';
import { ActivatedRoute } from '@angular/router';
import { Observable } from 'rxjs';
import { selectCurrentUser } from './user.selectors';
import { loadUser } from './user.actions';

@Component({
  selector: 'app-user-detail',
  standalone: true,
  template: `
    @if (user$ | async; as user) {
      <div class="user-detail">
        <h1>{{ user.name }}</h1>
        <p>{{ user.email }}</p>
      </div>
    }
  `
})
export class UserDetailComponent implements OnInit {
  private store = inject(Store);
  private route = inject(ActivatedRoute);

  user$ = this.store.select(selectCurrentUser);

  ngOnInit() {
    const userId = this.route.snapshot.paramMap.get('id');
    this.store.dispatch(loadUser({ userId: userId || undefined }));
  }
}

Exemple avec SignalStore (Angular 17+)

ts
import { inject } from '@angular/core';
import { rxMethod } from '@ngrx/signals';
import { patchState, signalStore, withMethods, withState } from '@ngrx/signals';
import { pipe, switchMap } from 'rxjs';
import { UserService } from './user.service';
import { User } from './user.model';

interface UserState {
  user: User | null;
  loading: boolean;
  error: string | null;
}

/**
 * SignalStore for user management with routing integration.
 * 
 * This store handles user loading based on route parameters.
 * It provides reactive signals and methods for user management.
 * 
 * @example
 * ```ts
 * const store = inject(UserStore);
 * store.loadUser('123'); // Load user by ID
 * store.loadCurrentUser(); // Load current user
 * const user = store.user(); // Signal value
 * ```
 */
export const UserStore = signalStore(
  { providedIn: 'root' },
  withState<UserState>({
    user: null,
    loading: false,
    error: null
  }),
  withMethods((store, userService = inject(UserService)) => ({
    loadUser: rxMethod<string | null>(
      pipe(
        switchMap((userId) => {
          patchState(store, { loading: true, error: null });
          
          const user$ = userId
            ? userService.getUserById(userId)
            : userService.getCurrentUser();

          return user$.pipe(
            switchMap((user) => {
              patchState(store, { user, loading: false });
              return [];
            })
          );
        })
      )
    ),
    
    loadCurrentUser: rxMethod<void>(
      pipe(
        switchMap(() => {
          patchState(store, { loading: true, error: null });
          return userService.getCurrentUser().pipe(
            switchMap((user) => {
              patchState(store, { user, loading: false });
              return [];
            })
          );
        })
      )
    )
  }))
);
ts
import { Component, effect, inject } from '@angular/core';
import { ActivatedRoute } from '@angular/router';
import { UserStore } from './user.store';

@Component({
  selector: 'app-user-detail',
  standalone: true,
  template: `
    @if (userStore.user(); as user) {
      <div class="user-detail">
        <h1>{{ user.name }}</h1>
        <p>{{ user.email }}</p>
      </div>
    } @else if (userStore.loading()) {
      <div>Loading...</div>
    }
  `
})
export class UserDetailComponent {
  route = inject(ActivatedRoute);
  userStore = inject(UserStore);

  constructor() {
    // Load user when route params change
    effect(() => {
      const userId = this.route.snapshot.paramMap.get('id');
      this.userStore.loadUser(userId);
    });
  }
}

Quand l'utiliser

Ce pattern est idéal pour les applications avec des données partagées entre plusieurs pages, pour des workflows complexes avec plusieurs étapes, et pour les applications qui utilisent déjà un store comme NgRx ou SignalStore. Il convient également lorsque vous avez besoin d'un état global accessible dans toute l'application.

Avantages

Le store constitue une source de vérité unique où toutes les données sont centralisées. Cette approche est très robuste grâce à la gestion d'état centralisée qui facilite le débogage et la maintenance. Les données sont réutilisables et accessibles partout dans l'application. Le store est facilement testable, ce qui améliore la qualité du code.

Inconvénients

Ce pattern nécessite plus de code à écrire, ce qui peut sembler du boilerplate pour des cas simples. Il peut être surdimensionné si vous n'avez besoin que d'une simple page sans partage de données. Il nécessite également une courbe d'apprentissage pour comprendre comment fonctionne le store et ses concepts.


Anti-patterns à éviter

ts
// ❌ MAUVAIS - Navigation state
router.navigate(['/user'], { 
  state: { user } // Perdu au refresh, pas SEO friendly
});

Ce pattern pose plusieurs problèmes. Les données sont perdues lors d'un refresh de page, ce qui crée une mauvaise expérience utilisateur. L'URL n'est pas partageable car les données ne sont pas dans l'URL. Il n'est pas SEO friendly car les moteurs de recherche ne peuvent pas accéder aux données. Enfin, il est difficile à déboguer car les données ne sont pas visibles dans l'URL.

✅ BON : Utiliser des paramètres de route ou des resolvers

ts
// ✅ BON - Paramètre de route
router.navigate(['/user', user.id]);

// ✅ BON - Query params si nécessaire
router.navigate(['/users'], { 
  queryParams: { id: user.id } 
});

Objets dans query params

ts
// ❌ MAUVAIS - Objet sérialisé dans l'URL
router.navigate(['/user'], { 
  queryParams: { user: JSON.stringify(user) } 
});
// URL: /user?user={"id":1,"name":"John"}

Ce pattern crée des URLs laides et très longues qui deviennent difficiles à lire. Les URLs ont une limite de taille, ce qui peut poser problème avec des objets complexes. Il n'est pas sécurisé car mettre des données sensibles dans l'URL expose ces informations. Enfin, les URLs deviennent difficiles à lire et à comprendre.

✅ BON : Utiliser uniquement des IDs ou des valeurs simples

ts
// ✅ BON - ID simple
router.navigate(['/user', userId]);

// ✅ BON - Query params simples
router.navigate(['/users'], { 
  queryParams: { page: 1, limit: 10 } 
});

🚫 Pages dupliquées

ts
// ❌ MAUVAIS - Pages séparées
const routes: Routes = [
  { path: 'user-me', component: UserMeComponent },
  { path: 'user-current', component: UserCurrentComponent },
  { path: 'user-detail/:id', component: UserDetailComponent }
];

Problèmes :

  • ❌ Code dupliqué
  • ❌ Maintenance difficile
  • ❌ Incohérences possibles
  • ❌ Plus de tests à écrire

✅ BON : Une seule page avec un resolver/stratégie

ts
// ✅ BON - Une seule page, plusieurs stratégies
const routes: Routes = [
  { 
    path: 'me', 
    component: UserDetailComponent,
    data: { strategy: loadCurrentUserStrategy }
  },
  { 
    path: ':id', 
    component: UserDetailComponent,
    data: { strategy: loadUserByIdStrategy }
  }
];

🚫 Composant qui "comprend" l'URL

ts
// ❌ MAUVAIS - Logique dans le composant
@Component({...})
export class UserComponent {
  route = inject(ActivatedRoute);
  user$ = this.route.url.pipe(
    map(segments => {
      if (segments.some(s => s.path === 'me')) {
        return this.userService.getCurrentUser();
      } else if (segments.some(s => s.path.includes('user'))) {
        const id = this.route.snapshot.paramMap.get('id');
        return this.userService.getUserById(id!);
      }
      // ... plus de conditions ...
    })
  );
}

Problèmes :

  • ❌ Composant trop intelligent
  • ❌ Difficile à tester
  • ❌ Violation du principe de responsabilité unique
  • ❌ Impossible à réutiliser

✅ BON : Déléguer à un resolver ou une façade

ts
// ✅ BON - Composant simple
@Component({...})
export class UserComponent {
  @Input() user!: User; // Données via resolver
}

// ✅ BON - Ou via façade
@Component({...})
export class UserComponent {
  userFacade = inject(UserFacade);
  user$ = this.userFacade.user$;
}

Comment choisir ? (Guide rapide)

SituationPattern recommandé
Gros projet, long terme🥇 Route = Stratégie
Cas simple (2-3 variantes)🥈 Resolver conditionnel
Page très interactive🥉 Service façade
App avec store (NgRx/SignalStore)5️⃣ Store-driven
Prototypage rapide4️⃣ Route data simple

Questions à se poser

  1. Taille du projet ?

    • Petit → Pattern 2 ou 4
    • Moyen → Pattern 1 ou 3
    • Gros → Pattern 1 ou 5
  2. Complexité de la logique ?

    • Simple (< 3 conditions) → Pattern 2 ou 4
    • Moyenne → Pattern 1 ou 3
    • Complexe → Pattern 1 ou 5
  3. Utilisez-vous un store ?

    • Oui → Pattern 5
    • Non → Autres patterns
  4. SSR nécessaire ?

    • Oui → Pattern 1 ou 2 (avec resolvers)
    • Non → Tous les patterns possibles
  5. Données partagées entre pages ?

    • Oui → Pattern 5
    • Non → Autres patterns