Retour au Blog
Angular Development12 janvier 202610 min de lecture

Structurer une application Angular à grande échelle

DR

Doug Ribeiro

10 min de lecture

Chez GFT au Brésil, on construisait des tableaux de bord en temps réel pour des sociétés d'investissement sur un codebase qui avait commencé avec un module partagé. Tout ce qui n'était pas un composant de fonctionnalité allait dans SharedModule. Rapidement, SharedModule importait et exportait des dizaines de choses, et personne ne pouvait dire ce qui appartenait là versus dans une fonctionnalité. Le démêler nous a coûté du vrai temps de sprint. Chez Wonderlabs, on est allé dans l'autre direction et on a mis trop de choses dans les feature modules, y compris des choses dont plusieurs fonctionnalités avaient besoin. On s'est retrouvé avec des composants quasi-dupliqués. La structure que j'utilise maintenant est le résultat de ces deux échecs. Une note de périmètre avant les détails : la plateforme de demande de prêt sur laquelle j'ai travaillé chez BMO était découpée en micro-frontends, ce qui est un problème différent avec ses propres règles. Cet article porte sur la structuration d'une seule application Angular, comme les produits autonomes de GFT et de Wonderlabs. Les noms de dossiers et de fonctionnalités dans les exemples sont illustratifs, ils ne proviennent du codebase d'aucun employeur.

Le modèle à trois couches

Core, Shared, Features. Ces trois couches ont des règles différentes et des objectifs différents. Avoir les bonnes règles est ce qui fait que la structure passe à l'échelle.

  • Core : services singletons, intercepteurs HTTP, guards d'authentification, état au niveau application, shell de layout. Instancié une fois. Jamais importé par Shared, et jamais re-fourni par les Features.
  • Shared : composants présentationnels, pipes, directives et utilitaires sans connaissance du domaine métier. Réutilisable dans n'importe quel projet. Pas de services avec état.
  • Features : tranches verticales de fonctionnalité métier. Chaque fonctionnalité possède ses composants, services, état, modèles et routes. Les fonctionnalités n'importent pas directement les unes des autres.
Modules de fonctionnalitéPeuvent importer depuis Shared et Core. Jamais importés directement par… 1 Couche SharedComposants, pipes et directives réutilisables. Pas de logique spécifiqu… 2 Couche CoreServices singletons, intercepteurs, guards. Fournis une seule fois, à l… 3
text
src/app/
  core/
    auth/
      auth.service.ts
      auth.guard.ts
      auth.interceptor.ts
    http/
      api.service.ts
      error.interceptor.ts
      loading.interceptor.ts
    layout/
      shell/
      header/
      sidebar/
    store/              # Global NgRx state (app-level only)
      app.state.ts
      app.effects.ts
  shared/
    components/
      button/
      data-table/
      modal/
      form-field/
    pipes/
      currency-format.pipe.ts
      date-locale.pipe.ts
    directives/
      tooltip.directive.ts
  features/
    applications/
    lending/
    profile/

Structure des dossiers de fonctionnalité

Chaque fonctionnalité est une tranche verticale autonome. Un développeur qui n'a jamais vu le codebase devrait pouvoir ouvrir le dossier applications et comprendre tout sur la fonctionnalité applications. La structure est cohérente entre toutes les fonctionnalités.

text
features/
  applications/
    components/
      application-list/
        application-list.component.ts
        application-list.component.html
        application-list.component.scss
        application-list.component.spec.ts
      application-detail/
      application-form/
    services/
      applications.service.ts
      applications-api.service.ts
    store/
      applications.store.ts    # NgRx Signal Store
    models/
      application.model.ts
      application-status.enum.ts
    applications.routes.ts
    index.ts                   # Public API for cross-feature imports

Le index.ts à la racine de la fonctionnalité est une frontière d'API publique délibérée. Il n'exporte que ce dont les autres fonctionnalités ou core ont légitimement besoin. Tout le reste est interne à la fonctionnalité. Quand vous avez besoin de refactoriser les internals, vous connaissez le rayon d'impact.

Fichiers barrel : les utiliser aux frontières, nulle part ailleurs

Les fichiers barrel à chaque niveau de dossier semblent propres en théorie. En pratique, ils créent des chaînes de dépendances circulaires que TypeScript résout de manières non évidentes, et ils ralentissent la compilation sur les gros projets parce que le compilateur TypeScript doit résoudre plus de fichiers. Ma règle : un seul barrel à la racine de la fonctionnalité pour l'API publique, et rien d'autre.

typescript
// features/applications/index.ts
// Only export what genuinely crosses the feature boundary
export { ApplicationSummaryComponent } from './components/application-summary/application-summary.component';
export { Application } from './models/application.model';
export { ApplicationStatus } from './models/application-status.enum';
export { ApplicationsService } from './services/applications.service';

// Consuming from another feature: use the barrel
import { Application } from '@features/applications';

// Do NOT do this from outside the feature:
import { ApplicationFormComponent } from '@features/applications/components/application-form/application-form.component';

Alias de chemins

Configurez les alias de chemins tsconfig.json dès le premier jour. Les imports relatifs qui remontent plus de deux niveaux dans l'arbre de répertoires sont le signe que la structure a besoin d'être corrigée, mais tant que vous ne la corrigez pas, les imports relatifs rendent le refactoring pénible. Les alias sont aussi auto-documentés : @core/auth se lit mieux que ../../../core/auth.

json
{
  "compilerOptions": {
    "paths": {
      "@core/*": ["src/app/core/*"],
      "@shared/*": ["src/app/shared/*"],
      "@features/*": ["src/app/features/*"],
      "@env/*": ["src/environments/*"]
    }
  }
}

Imposer les frontières avec ESLint

Une structure sans enforcement n'est que de la décoration. Les développeurs sous pression de deadline importeront directement depuis les internals d'une autre fonctionnalité si le chemin fonctionne. La règle ESLint @nx/enforce-module-boundaries empêche ça, mais elle s'appuie sur le workspace et le graphe de projets Nx : elle ne fonctionne pas en standalone hors d'un monorepo Nx. Si vous n'êtes pas sur Nx, utilisez eslint-plugin-boundaries, qui offre le même type de contrôle d'imports via des règles basées sur des patterns de chemin.

Les frontières que j'impose : les fonctionnalités ne peuvent pas importer directement depuis d'autres fonctionnalités (elles doivent passer par index.ts), shared ne peut pas importer depuis features ou core, core ne peut pas importer depuis les composants shared. Les violations font échouer le lint en CI avant que la PR ne soit revue. Le câblage n'a rien de spectaculaire : les règles de frontières sont réglées sur error plutôt que warn, et le pipeline exécute ng lint comme étape distincte avant le build. Une frontière violée fait sortir ESLint avec un code non nul, l'étape passe au rouge, et la pull request est bloquée par la protection de branche. Pas de reporter maison, pas de plugin supplémentaire, et rien qui ne fonctionne que chez un seul fournisseur de CI.

Ce qui casse sans cette structure

  • Un méga module partagé qui gonfle jusqu'à ce que personne ne sache ce qu'il contient : séparer shared en ui-components, utilities et models
  • Des composants nommés d'après leur rôle visuel plutôt que leur domaine : TransactionCard, pas CardWithBadge
  • Des services fournis dans les fonctionnalités qui sont instanciés plusieurs fois parce qu'ils ne sont pas fournis au root
  • La configuration des routes dispersée dans app.module ou app.config au lieu d'être co-localisée avec la fonctionnalité
  • Les fichiers de test dans un dossier tests séparé à la racine au lieu d'être co-localisés avec le fichier qu'ils testent

Ce que je ferais différemment en commençant aujourd'hui

Avec les composants standalone par défaut (pour les nouveaux projets depuis Angular 17, et dans le décorateur lui-même depuis Angular 19), la couche NgModule a disparu. Le modèle à trois couches tient, mais la couche shared est maintenant juste des composants standalone, des pipes et des directives, pas un module. Les routes de fonctionnalités sont juste des fichiers de routes, pas des NgModules. La structure est la même, le boilerplate est moindre.

Je configurerais aussi les règles de frontières ESLint dès le premier jour, avant qu'il y ait du code pour les violer. Retrouver l'enforcement des frontières sur un codebase qui n'en a jamais eu signifie corriger des centaines de violations d'import avant que la règle puisse passer en error. Configurez-la au premier jour et ça ne coûte rien.

Le test d'une structure est de savoir si un développeur qui n'a jamais vu le codebase peut naviguer au bon endroit en moins de deux minutes. S'il ne peut pas, la structure ne vaut pas son coût.

Le modèle à trois couches avec des frontières imposées, des alias de chemins et des tests co-localisés a tenu sur chaque projet où je l'ai appliqué. Ce n'est pas excitant. C'est juste cohérent, et la cohérence est ce qui rend les grands codebases Angular maintenables quand l'équipe et le nombre de fonctionnalités croissent.

Références

AngularArchitectureTypeScriptBest Practices