Configurez votre projet et ses dépendances

La configuration est l'un des aspects les plus critiques d'une application Angular moderne. Dans ce chapitre, nous allons explorer comment structurer efficacement vos providers, orchestrer l'initialisation de votre application, et intégrer vos dépendances externes de manière robuste.

Mais avant de passer à la pratique, parlons rapidement du principe de l'inversion de dépendances, et pourquoi c'est un concept essentiel pour la gestion des providers.

Qu'est-ce que le DIP (Dependency Inversion Principle) ?

Le principe d'inversion de dépendances (le "D" de SOLID) dit qu'une classe concrète ne doit pas dépendre d'une autre classe concrète : les deux doivent dépendre d'une abstraction.

Souvent, lorsque le mot abstraction apparaît, ça fait peur ; on entend des cris de "over-engineering !" surgir de l'open-space, alors qu'en vrai c'est très simple et très puissant.

Prenons un cas concret d'une application Angular basique, avec un component smart  ProductList  et un service  Productsqui récupère les produits depuis l'API. Traditionnellement, on fait :

export class ProductList {
    private readonly products = inject(Products);
}

Ce qui crée une dépendance directe :

Diagramme montrant une dépendance directe de ProductList vers Products, illustrant une architecture avant l’inversion des dépendances.
Avant l'inversion de dépendances

Avec l'inversion de dépendances (et grâce à la magie du système d'injection de dépendances d'Angular), on peut changer cette situation pour donner :

Diagramme illustrant l’inversion des dépendances : ProductList dépend d’une abstraction (Products) implémentée par ApiProducts, réduisant le couplage direct.
Après l'inversion de dépendances

Ici,  Products  devient une abstraction (on verra comment la créer dans quelques instants).  ProductList  dépend de cette abstraction, et  ApiProducts  en est l'implémentation concrète

Comment on fait ?

Voici le setup :

export abstract class Products {
    abstract allProducts(): Observable<Product[]>;
}

export class ApiProducts implements Products {
    private readonly http = inject(HttpClient);
    
    allProducts(): Observable<Product[]> {
        return this.http.get<Product[]>('/api/products');
    }
}

export class ProductList {
    private readonly products = inject(Products);
    
    products$: Observable<Product[]> = this.products.allProducts();
}
  • Products  est une classe abstraite qui déclare les méthodes dont dépendront les components

  • ApiProducts  utilise le mot-clef  implements  et non  extends  pour traiter  Products  comme une interface et non comme une classe parent

  • ProductsList  injecte la classe abstraite et non l'implémentation

Pour tout faire fonctionner, on crée le provider comme ceci :

{ provide: Products, useClass: ApiProducts }

Vous voyez ? C'est plutôt facile finalement !

Oui d'accord, mais…pourquoi faire ça ?

L'inversion de dépendances nous offre plusieurs avantages :

  • découplage entre composants — lorsqu'on pense aux abstractions comme les rôles joués par un objet, l'inversion de dépendances permet de coupler un objet aux rôles de ses dépendances plutôt qu'à leurs implémentations

  • testabilité améliorée — remplacer les dépendances d'un objet pour l'isoler devient trivial, donc écrire d'excellents tests unitaires devient très simple

  • flexibilité de configuration — toujours grâce à la facilité de changer l'implémentation des dépendances, on peut, par exemple, avoir une implémentation spécifique pour le développement : imaginez une situation où le backend n'est pas prêt, vous pourrez quand même travailler avec un service qui simule un serveur fonctionnel et simplement remplacer par la vraie implémentation quand l'API est terminé

Garder le DIP en tête vous aidera à prendre de meilleures décisions dans la gestion de vos dépendances.

Gérez vos providers

Les providers contiennent généralement toute la logique métier de vos applications, donc bien les gérer est une priorité. Voici quelques pratiques intéressantes pour cela :

Injection tokens

Les injection tokens permettent d'utiliser l'injection de dépendances d'Angular pour fournir autre chose que des objets issus de classes, notamment pour injecter des primitives. Ils sont particulièrement utiles pour injecter des valeurs ou des objets de configuration.

Par exemple, si vous souhaitez établir une valeur par défaut pour la pagination dans votre application, vous pouvez créer un injection token :

export const DEFAULT_PAGE_SIZE = new InjectionToken<number>('Default page size for pagination');

Vous pouvez ensuite lui fournir une valeur dans vos providers :

providers: [
    { provide: DEFAULT_PAGE_SIZE, useValue: 10 },
]

Et l'exploiter en utilisant inject() :

import { DEFAULT_PAGE_SIZE } from 'core/injection-tokens/default-page-size';

export class StoreProductFacade {
    private defaultPageSize = inject(DEFAULT_PAGE_SIZE);
}

Cette approche permet notamment de centraliser la configuration, rendant votre application plus facile à maintenir.

Créez vos provider functions

Depuis le passage en standalone, Angular fournit ce type de fonction pour plusieurs fonctionnalités :  provideHttpClient()  ,  provideZonelessChangeDetection()  ,  provideRouter(routes)  … pour faciliter la gestion de vos providers, vous pouvez en faire de même en créant vos propres provider functions !

Imaginez une feature Products (comme ci-dessus), où on aurait un service  Products  , une façade  ProductsFacade  , et un store  ProductsStore  . Vous pouvez créer une fonction comme celle-ci :

export const provideProducts = (): Provider[] => {
    return [
        { provide: Products, useClass: ApiProducts },
        { provide: ProductFacade, useClass: StoreProductFacade },
        { provide: ProductStore, useClass: SignalProductStore }
    ];
}

Ensuite, dans le tableau des providers approprié, vous avez juste à appeler :

providers: [
    provideProducts()
]

Pour les providers au niveau de l'application (dans AppConfig), il faut utiliser une fonction helper fournie par Angular :

export const provideAppCore = (): EnvironmentProviders => {
  return makeEnvironmentProviders([
    provideZonelessChangeDetection(),
    provideRouter(routes),
    provideHttpClient(),
  ]);
}

Comme avant, cela permet de faire :

providers: [
    provideAppCore()
]

Vos providers peuvent également être configurables grâce aux provider factories. Voici un exemple pour la gestion de la configuration de l'application :

type AppConfiguration = {
  defaultPageSize: number,
  language: 'en' | 'fr'
}

const provideConfiguration = (config: AppConfiguration): Provider[] => {
  const providers: Provider[] = [
      { provide: DEFAULT_PAGE_SIZE, useValue: config.defaultPageSize }
  ];
  if (config.language === 'fr') {
    providers.push(
        { provide: MatPaginatorIntl, useClass: MatPaginatorIntlFr }
    );
  }
  return providers;
}

export const appConfig: ApplicationConfig = {
    providers: [
        provideConfiguration({ defaultPageSize: 10, language: 'fr' }),
    ]
}

Ainsi vous pouvez non seulement centraliser la configuration des valeurs de votre application, vous pouvez également configurer les dépendances qui sont utilisées. Dans l'extrait ci-dessus :

  • on configure une injection token avec une valeur primitive

  • si la langue sélectionnée est l'anglais, il n'y a pas de dépendance à ajouter

  • si la langue sélectionnée est le français, il faut ajouter la dépendance d'internationalisation

Si vous avez des méthodes que vous devez absolument appeler au démarrage de l'application, comme l'initialisation d'un service par exemple, Angular fournit la fonction  provideAppInitializer  (qui remplace l'ancien  APP_INITIALIZER  ). Vous pouvez donc, par exemple, faire ceci :

@Injectable()
export class Config {
  init(): void {
    // do init here
  }
}

export const appConfig: ApplicationConfig = {
    providers: [
        provideAppInitializer(() => {
          const config = inject(Config);
          config.init();
        }),
        Config
    ]
}

Ainsi avant un quelconque chargement de component, la méthode  init()  sera appelée.

L'inversion de dépendances montre particulièrement son utilité lorsqu'on souhaite remplacer une implémentation par un test double pour mieux isoler le système que l'on teste. Par exemple, si vous avez un component  ProductsList  qui appelle un  ProductsFacade  pour récupérer la liste de produits, vous pouvez simplifier les tests de votre component en lui fournissant un stub :

export class FakeProductsFacade implements ProductsFacade {
    productList(): Signal<Product[]> {
        return signal([
            // fake list of products
        ]);
    }
}

TestBed.configureTestingModule({
    imports: [
        ProductsList
    ],
    providers: [
        { provide: ProductsFacade, useClass: FakeProductsFacade }
    ]
})

Sans oublier que dans ProductsList, c'est l'abstraction qui y est injectée et non l'implémentation :

export class ProductsList {
    private productsFacade = inject(ProductsFacade);
}

Du coup, à l'intérieur de notre fichier de test, c'est le stub qui sera injectée et non l'implémentation de production.

Gérez les dépendances tierces

Un chapitre sur les dépendances ne serait jamais complet sans parler des dépendances tierces : ces libraries que nous utilisons pour accélérer le développement et éviter de réinventer la roue MAIS qui sont souvent source de problèmes : mises à jour compliquées, breaking changes, évolution de nos besoins…

On peut éviter un certain nombre de ces problèmes en créant des adapters (ou wrappers) autour de ces dépendances. Ça nous permet de modifier l'implémentation à tout moment (et même de changer de library) sans casser quoique ce soit dans nos applications.

Prenons un exemple : les  Dialog  d'Angular Material. Plutôt qu'injecter  Dialog  directement dans vos components, vous utilisez l'inversion de dépendances et créez une classe abstraite et son implémentation Material :

export interface DialogBox {
    get closed(): Observable<void>;
}

export abstract class Dialogs {
    abstract open(component: any): DialogBox {}
}

@Injectable()
export class MaterialDialogs implements Dialogs {
    
    private matDialog = inject(Dialog); // Material dependency
    
    open(component: any): Observable<DialogBox> {
        return this.dialog.open(component);
    }
}

Et pour le provider, vous commencez à en avoir l'habitude :

providers: [
    { provide: Dialogs, useClass: MaterialDialogs }
]

Comme ça, le jour où Material modifie son implémentation, vous n'avez qu'à modifier celle de MaterialDialogs et le reste de votre application continuera de fonctionner, tout comme si un jour vous souhaitez changer de library.

À vous de jouer

Contexte

Vous héritez d'un projet Angular où un componentUserProfileinjecte directement les servicesHttpClient,AuthService, etNotificationService. Le component fait des appels HTTP, vérifie les permissions, et affiche des messages à l'utilisateur. L'équipe se plaint que les tests sont difficiles à écrire et que le code est rigide.

Consigne

Identifiez les problèmes d'architecture dans cette approche et proposez une refactorisation en appliquant le principe d'inversion de dépendances. Décrivez les abstractions que vous créeriez, les providers nécessaires, et expliquez comment cette approche améliorerait la testabilité et la flexibilité du code.

En résumé

  • La principe d'inversion de dépendances : dépendez d'abstractions plutôt que d'implémentations concrètes pour découpler vos composants et améliorer la testabilité

  • Utilisez des provider functions et des injection tokens pour centraliser et structurer la configuration de vos dépendances

  • ExploitezprovideAppInitializer()pour orchestrer le démarrage de votre application et gérer les dépendances critiques

  • Créez des adapters autour des librairies externes pour éviter le couplage fort et faciliter les évolutions futures

Maintenant que votre application est solidement configurée avec des dépendances bien structurées, il est temps de passer à l'étape suivante : mettre en place un workflow de développement efficace et déployer votre application en production de manière robuste et automatisée.

Ever considered an OpenClassrooms diploma?
  • Up to 100% of your training program funded
  • Flexible start date
  • Career-focused projects
  • Individual mentoring
Find the training program and funding option that suits you best