Accueil / Articles

Injection de dépendances dans un monorepo TypeScript : InversifyJS pour React et Angular

Injection de dépendances pour un monorepo TypeScript : choisir un conteneur, définir des tokens sécurisés par type, assembler une racine de composition, et la relier à React, Vue et Angular.

Éditeur : REACTAPP.TOP14 min de lecture3191 mots

Sources: Frontend Dependency Injection: From Context API to Composition Root

Les grands ensembles de code frontend échouent rarement à cause d’un seul composant défectueux ; ils échouent parce que les règles métier s’entremêlent progressivement avec les hooks React, les clients HTTP et les API de stockage, au point qu’il devient impossible de tester ou de réutiliser quoi que ce soit de manière indépendante. L’injection de dépendances est le mécanisme de bas niveau qui permet de maintenir ces règles séparées, mais uniquement si la connexion entre les éléments est conçue intentionnellement. Ce guide explique comment choisir un conteneur pour un monorepo TypeScript multi-domaine, définir des tokens sécurisés par rapport aux types, isoler chaque domaine derrière un seul module, assembler le graphe dans une racine de composition, et l’exposer à React, Vue et Angular au moyen de ponts composés chacun de une douzaine de lignes.

Pourquoi l’isolation doit être décidée avant de modéliser le domaine

Les conseils classiques du Domain-Driven Design recommandent de reporter les détails d’implémentation : modéliser d’abord les entités et les cas d’utilisation, puis choisir les outils par la suite. En pratique, une équipe qui entame un grand changement architectural tire avantage de résoudre dès le début une question technique, à savoir comment l’isolation des modules sera réellement assurée. Sans mécanisme convenu, les premiers domaines sont conçus de manière ad hoc et les conventions ne sont jamais rétablies.

Le principe sous-jacent est le principe d’inversion des dépendances : les politiques de haut niveau doivent dépendre d’abstractions, et les détails concrets doivent dépendre de ces mêmes abstractions. L’injection de dépendances est la technique pratique qui le met en œuvre. Les classes demandent des tokens abstraits plutôt que des implémentations concrètes, et un élément extérieur décide de ce à quoi ces tokens se résolvent. Dans le DDD et l’architecture propre, il ne s’agit pas d’une touche décorative optionnelle ; le domaine ne doit pas savoir avec quel framework UI, client HTTP ou couche de persistance il fonctionne.

La plupart des équipes frontend recourent en premier lieu à React Context (ou à provide/inject dans Vue). Cela semble suffisant : il suffit de déclarer une valeur près de la racine pour pouvoir la lire n’importe où en dessous. Cependant, les limites apparaissent rapidement. Il n’y a pas de gestion du cycle de vie, pas de notion de factories ou de scopes, et chaque consommateur de cette valeur est désormais lié au runtime de React, de sorte que la logique ne peut pas s’exécuter ni être testée sans lui. Le chemin menant à une véritable racine de composition est ce que couvre le reste de ce guide. Écrire un conteneur sur mesure est rarement justifié ; par conséquent, la première étape consiste à évaluer honnêtement les options d’IoC existantes.

Critères pour un conteneur d’injection de dépendances frontend

Les frameworks backend tels que NestJS, Spring et .NET intègrent déjà la conception orientée dépendances comme fonctionnalité standard résolue. Le code frontend présente des contraintes supplémentaires : coût en temps de exécution, limites de taille des fichiers compilés, et nécessité de fonctionner avec plusieurs frameworks d’interface utilisateur. Cinq critères guident l’évaluation :

  1. Agnosticisme vis-à-vis des frameworks. Le conteneur doit pouvoir fonctionner avec du TypeScript pur, sans dépendance en temps de exécution envers React, Vue ou Angular, et doit être compatible avec le navigateur, Node.js pour le SSR, ainsi que React Native.
  2. Modularité. Chaque domaine doit pouvoir fournir un module prédéfini dont les liaisons sont scellées à l’intérieur, afin que l’application ne charge que ce dernier. Cela permet d’éviter un fichier main.ts de 500 lignes où chaque service serait enregistré manuellement.
  3. Gestion du cycle de vie. Les singleton, les instances temporaires et la résolution par scoping doivent être pris en charge de manière optimale.
  4. Testabilité. Remplacer une dépendance par un mock ou un faux objet ne devrait nécessiter qu’une seule ligne de code, sans avoir à reconstruire le conteneur.
  5. Impact sur le bundle. La charge supplémentaire en environnement de production doit être faible, que ce soit pour les versions brutes, minifiées ou compressées en gzip.

Les candidats

InversifyJS

InversifyJS est depuis longtemps le choix par défaut pour les grands projets TypeScript et constitue le conteneur IoC le plus complet de l’écosystème. Il est indépendant de tout framework et déclare les dépendances à l’aide de décorateurs. Son ContainerModule regroupe les liens en unités logiques, et il prend en charge inSingletonScope, inTransientScope et inRequestScope, des conteneurs enfants, le chargement et le déchargement de modules en temps de exécution, ainsi que des hooks d’activation. Le prix à payer est son poids : c’était l’option la plus lourde mesurée, et qualifier les versions actuelles de légères serait trompeur.

TSyringe

TSyringe, géré par Microsoft, privilégie la simplicité au détriment de la richesse fonctionnelle. Il résout automatiquement les dépendances de constructeur à partir des métadonnées de TypeScript, propose un ensemble complet de décorateurs et prend en charge les modes de vie habituels (Singleton, Transient, ResolutionScoped, ContainerScoped). Pour une application de taille moyenne, c’est une solution attractive et peu contraignante. Cependant, deux points négatifs se font sentir dans une architecture multi-domaine : il n’existe pas de concept de module de première classe, ce qui oblige à construire manuellement la composition à l’aide de fonctions d’enregistrement ou de createChildContainer(), et il dépend du polyfill reflect-metadata, ce qui ajoute une charge non négligeable.

Awilix

Awilix évite complètement les décorateurs. Il utilise l’API ES6 Proxy pour associer les dépendances aux noms des paramètres du constructeur ou aux clés de l’objet d’arguments d’une factory. Cela en fait un choix idéal pour les équipes qui ne souhaitent pas utiliser de décorateurs, et c’était le conteneur autonome le plus compact évalué. Il prend en charge les modes de vie SINGLETON, SCOPED et TRANSIENT ; les modules sont organisés à l’aide de fonctions de registration personnalisées.

Un piège spécifique aux frontends mérite attention. Dans le mode d’injection CLASSIC, Awilix lit les noms des arguments du constructeur en tant que chaînes de caractères, et les minificateurs renomment ces arguments, ce qui perturbe la résolution en production. Seul le mode par défaut PROXY résiste à un traitement agressif des noms.

L’injecteur intégré d’Angular

Angular dispose de l’un des systèmes d’injection dépendante hiérarchique les plus performants dans le développement frontend, avec une injection basée sur des tokens, des fournisseurs ciblés et un chargement différé intégrés. S’il pouvait être utilisé sans le runtime d’Angular, il serait un concurrent sérieux pour la couche de domaine. Or, ce n’est pas possible, et c’est là le problème.

React Context

Context n’est pas vraiment un conteneur d’injection dépendante ; c’est plutôt un moyen d’éviter le problème du « prop drilling ». Un fournisseur est placé près de la racine et les composants y accèdent à l’aide de useContext(). Toute modification de la valeur fournie force à re-render tous les composants qui l’utilisent, et il n’y a ni gestion du cycle de vie, ni liaison via des factories, ni isolation des scopes. Son seul véritable avantage est qu’il ne coûte rien de supplémentaire dans le bundle.

Vue provide et inject

Le mécanisme de Vue transmet les valeurs le long de l’arbre des composants et partage les limites architecturales du Context : il ne constitue pas une base pratique pour un graphe de services de domaine. Il présente cependant un avantage ergonomique, puisque les dépendances peuvent être enregistrées globalement via un plugin au lieu d’envelopper l’arbre dans des fournisseurs imbriqués. La fonction moderne inject() d’Angular repose sur une idée similaire de contexte d’injection actif.

Injection par constructeur simple

La méthode à zéro bibliothèque transmet explicitement chaque dépendance via des constructeurs. Elle est entièrement sécurisée du point de vue des types et ne présente aucun surcoût en temps de exécution. L’inconvénient réside dans l’échelle : une fois qu’une application compte environ 10 à 15 services de domaine, le câblage manuel du graphe au point d’entrée se transforme en un fichier volumineux et fragile que tout le monde évite de modifier.

Ce que montre la comparaison

La taille des bundles a été comparée en regroupant un fichier d’entrée qui importe l’API publique de chaque package à l’aide de esbuild --bundle --minify --platform=browser, puis en compressant le résultat avec gzip -9, ce qui isole la charge propre au conteneur du code de l’application. Quatre conclusions en ont découlé :

  1. Le DI de framework relève de l’interface utilisateur, et non du domaine métier. L’inclusion de useContext() ou de @Injectable() d’Angular dans les bibliothèques de domaine lie les règles métier à un seul framework, ce qui ne respecte pas le premier critère.
  2. L’injection manuelle ne convient qu’aux petits projets. Dans un grand monorepo d’entreprise, le point de composition se transforme en des milliers de lignes de connexions écrites manuellement.
  • InversifyJS a gagné grâce à sa modularité. C’est le seul candidat dans lequel un module (ContainerModule) constitue une valeur de première classe que une bibliothèque de domaine peut créer, encapsuler et exporter en tant qu’unité unique.
  • La configuration de compilation est importante. InversifyJS et TSyringe nécessitent experimentalDecorators: true, emitDecoratorMetadata: true ainsi que target: ES2022 dans tsconfig.json. InversifyJS v8 n’a plus besoin d’un polyfill séparé reflect-metadata. Avec des outils basés sur esbuild ou SWC (Vite, Next.js, Bun), assurez-vous que l’étape de transformation prend en charge les métadonnées de décorateurs anciennes, généralement via un plugin, car esbuild ne génère pas automatiquement de métadonnées de décorateurs.
  • Quatre règles pour éviter que le conteneur ne devienne un service locator

    Un conteneur en soi ne met rien en isolation ; s’il est utilisé de manière négligente, il devient un ensemble global de services auxquels n’importe quel fichier peut accéder. L’architecture repose donc sur quatre conventions :

    • Des tokens d’injection de dépendances abstraits créés avec Symbol.for et une propriété $.
    • Chaque bibliothèque de domaine n’exporte que son module de conteneur, jamais ses classes concrètes.
    • Une seule racine de composition assemble le graphe de l’application.
    • Un pont léger par framework UI expose le conteneur aux composants.

    Sécurité de type avec les tokens de Symbol.for et $

    Un conteneur a besoin d’identifiants en temps de exécution pour associer les abstractions aux implémentations. Les interfaces de TypeScript disparaissent au moment de la compilation, ce qui les empêche de remplir directement ce rôle. Le schéma utilisé ici associe chaque interface d’un package @my-app/*-contracts à une constante du même nom dont la propriété $ contient un Symbol.

    // libs/auth/contracts/src/lib/interfaces/auth.facade.ts
    import type { ServiceIdentifier } from 'inversify';
    
    export interface AuthFacade {
      getAccessToken(): Promise<string | null>;
      login(): Promise<void>;
      logout(): Promise<void>;
    }
    export const AuthFacade = {
      $: Symbol.for('AuthFacade') as ServiceIdentifier<AuthFacade>,
    };
    
    // libs/auth/contracts/src/lib/interfaces/pin.facade.ts
    export interface PinFacade {
      setupPin(pin: string): Promise<void>;
      verifyPin(sub: string, pin: string): Promise<PinVerifyResult>;
      changePin(sub: string, currentPin: string, newPin: string): Promise<PinVerifyResult>;
    }
    export const PinFacade = {
      $: Symbol.for('PinFacade') as ServiceIdentifier<PinFacade>,
    };
    

    Ce fragment contient trois décisions importantes :

    1. Un seul nom pour le type et la valeur. TypeScript conserve les types et les valeurs dans des espaces de noms distincts, de sorte que AuthFacade peut à la fois être l’interface et le conteneur de jeton. Le compilateur choisit le sens approprié en fonction du contexte, et personne n’a besoin d’inventer des noms tels que AuthFacadeToken.
    2. Symbol.for au lieu de Symbol(). Chaque appel à Symbol() renvoie une valeur entièrement nouvelle, tandis que Symbol.for() recherche la clé dans le registre global des symboles du runtime. Si un package contract est dupliqué dans deux bundles, ce qui peut arriver dans un monorepo ou avec Module Federation, les deux copies génèrent toujours le même token et les recherches continuent de fonctionner sans échouer mystérieusement. L’inconvénient est que les clés du registre sont des chaînes globales, donc elles doivent être uniques dans toute l’application.
    3. Le cast ServiceIdentifier<AuthFacade>. En spécifiant $ comme ServiceIdentifier<T> d’Inversify, on lie le token à son interface au moment de la compilation, de sorte que les sites de résolution déduisent le bon type sans généricités explicites.

    Le fragment suivant montre les deux cas : une classe qui reçoit la façade par injection via le constructeur, et une appel direct à container.get dont le type de retour est déduit à partir du token.

    import { inject, injectable } from 'inversify';
    import { AuthFacade } from '@my-app/auth-contracts';
    
    @injectable()
    export class LoginPageComponent {
      // Strongly-typed injection via token
      constructor(@inject(AuthFacade.$) private readonly auth: AuthFacade) {}
    }
    
    // Resolution site automatically infers return type as AuthFacade
    const facade = container.get(AuthFacade.$); // AuthFacade
    

    Le consommateur ne fait référence qu’au contrat. Il n’a aucune idée de ce qui se trouve à l’intérieur de @my-app/auth-core, et c’est justement le but.

    Un domaine, un ContainerModule

    La bibliothèque de base de chaque domaine expose une seule chose : son module DI. Les cas d’usage, les entités, les ports et les repositories restent privés. À l’intérieur du module, les cas d’usage internes sont liés entre eux, tandis que la façade publique est liée à son jeton de contrat.

    // libs/auth/core/src/lib/auth-container.module.ts
    import { ContainerModule, type ContainerModuleLoadOptions } from 'inversify';
    import { AuthFacade } from '@my-app/auth-contracts';
    import { CoreAuthFacade } from './facades/core-auth.facade';
    import { LoginUseCase } from './use-cases/login.use-case';
    import { LogoutUseCase } from './use-cases/logout.use-case';
    import { GetAccessTokenUseCase } from './use-cases/get-access-token.use-case';
    
    export const authContainerModule = new ContainerModule((options: ContainerModuleLoadOptions) => {
      // Private use cases (registered to self within domain)
      options.bind(LoginUseCase).toSelf().inSingletonScope();
      options.bind(GetAccessTokenUseCase).toSelf().inSingletonScope();
      options.bind(LogoutUseCase).toSelf().inSingletonScope();
      // Public contract implementation bound to token
      options.bind(AuthFacade.$).to(CoreAuthFacade).inSingletonScope();
    });
    
    // libs/auth/core/src/index.ts - ONLY the ContainerModule is re-exported!
    export * from './lib/auth-container.module';
    

    L’encapsulation est appliquée à deux niveaux :

    1. API publique. Le fichier index.ts de la bibliothèque ne réexporte que authContainerModule. Des classes comme LoginUseCase ou CoreAuthFacade ne sont tout simplement pas accessibles depuis d’autres packages.
    2. Règles de linting. La règle ESLint @nx/enforce-module-boundaries de Nx vérifie les étiquettes des projets ; ainsi, par exemple, un projet marqué type:core ne peut pas importer un projet de type type:core appartenant à un autre domaine. Consultez la documentation sur les limites des modules de Nx pour en savoir plus sur la manière de déclarer les étiquettes et les contraintes.

    Les limites d’export empêchent les imports accidentels ; les règles de linting empêchent les raccourcis intentionnels via des chemins complexes. Ensemble, elles font de l’architecture quelque chose que les systèmes CI vérifient plutôt que quelque chose que les reviewers doivent se rappeler.

    La racine de composition

    La racine de composition est l’endroit où le graphe de dépendances est créé, généralement apps/my-app/src/composition-root.ts ou une fonction appelée depuis main.ts. Une règle la régit :

    La racine de composition est le seul endroit dans le codebase autorisé à importer des implémentations concrètes, des adaptateurs d’infrastructure et des modules centraux du domaine.

    La fonction ci-dessous lie d’abord l’infrastructure globale (un client HTTP basé sur Axios et un générateur de logs construit à partir d’une usine afin qu’il puisse recevoir des transports configurés), puis charge les modules du domaine.

    // apps/my-app/src/composition-root.ts
    import { Container } from 'inversify';
    import { HttpClientPort, LoggerPort } from '@my-app/shared-contracts';
    import { AxiosHttpClient, BrowserLogger, ConsoleLogTransport, FileLogTransport } from '@my-app/shared-infrastructure';
    
    import { authContainerModule } from '@my-app/auth-core';
    import { paymentsContainerModule } from '@my-app/payments-core';
    export const initAppContainer = (): Container => {
      const container = new Container();
    
      // 1. Bind global infrastructure adapters
      container.bind(HttpClientPort.$).to(AxiosHttpClient).inSingletonScope();
      container.bind(LoggerPort.$).toDynamicValue(() => new BrowserLogger({
        transports: [
          new ConsoleLogTransport(),
          new FileLogTransport(),
        ],
      })).inSingletonScope();
    
      // 2. Load domain modules
      container.load(authContainerModule);
      container.load(paymentsContainerModule);
    
      return container;
    };
    

    Par souci de complétude, l’adaptateur BrowserLogger est une classe ordinaire qui implémente LoggerPort et distribue chaque message à ses transports respectifs. Il ne comporte aucun décorateur ; comme il est créé à l’intérieur de toDynamicValue, le conteneur n’a jamais besoin d’examiner son constructeur.

    // libs/shared/infrastructure/src/lib/logging/browser-logger.ts
    import type { LoggerPort } from '@my-app/shared-contracts';
    import type { LogTransport } from './interfaces/log-transport.interface';
    
    export interface BrowserLoggerOptions {
      transports: LogTransport[];
    }
    
    export class BrowserLogger implements LoggerPort {
      constructor(private readonly options: BrowserLoggerOptions) {}
    
      info(message: string, ...args: unknown[]): void {
        this.options.transports.forEach(t => t.write('INFO', message, args));
      }
    }
    

    Plusieurs applications à partir d’un même noyau de domaine

    Comme l’infrastructure est choisie dans la racine de composition, différentes applications peuvent réutiliser les mêmes paquets de domaine avec des adaptateurs différents ainsi qu’une sélection différente de modules. Une application React Native peut utiliser un stockage basé sur AsyncStorage, tandis qu’une application d’administration web fait appel à IndexedDB et charge un domaine d’audit au lieu d’un domaine de paiements.

    // apps/mobile/src/composition-root.ts — React Native target
    container.bind(StoragePort.$).to(AsyncStorageAdapter).inSingletonScope();
    container.bind(LoggerPort.$).to(RnLogger).inSingletonScope();
    container.load(authContainerModule);
    container.load(paymentsContainerModule);
    
    // apps/admin/src/composition-root.ts - Web Admin target
    container.bind(StoragePort.$).to(IndexedDbAdapter).inSingletonScope();
    container.bind(LoggerPort.$).to(BrowserLogger).inSingletonScope();
    container.load(authContainerModule);
    container.load(auditContainerModule); // Payments domain excluded entirely
    

    @my-app/auth-core est identique, caractère par caractère, dans les versions mobile, admin et desktop. Il ne détecte jamais si le stockage est de type AsyncStorage ou IndexedDB.

    Permettre à un domaine d’utiliser un autre

    Supposons que payments-core ait besoin d’un jeton d’accès géré par auth-core. L’importation de @my-app/auth-core est interdite par les règles de délimitation. Par conséquent, le cas d’usage des paiements dépend du jeton de contrat du domaine d’authentification, qui se trouve dans le package contracts et est autorisé.

    // libs/payments/core/src/lib/use-cases/create-payment.use-case.ts
    import { inject, injectable } from 'inversify';
    
    // Import from contracts, NOT core — permitted by boundary rules
    import { AuthFacade } from '@my-app/auth-contracts';
    import { PaymentsGatewayPort } from '../ports/payments-gateway.port';
    
    @injectable()
    export class CreatePaymentUseCase {
      constructor(
        @inject(AuthFacade.$) private readonly auth: AuthFacade,
        @inject(PaymentsGatewayPort.$) private readonly gateway: PaymentsGatewayPort,
      ) {}
    
      async execute(amount: number): Promise<void> {
        const token = await this.auth.getAccessToken();
        return this.gateway.processPayment(amount, token);
      }
    }
    

    paymentsContainerModule ne lie pas AuthFacade.$ ; il le consomme uniquement. Le lien est établi lorsque le point de composition charge les deux modules dans le même conteneur. Conséquence pratique : si une application charge les fonctionnalités de paiement sans authentification, la résolution échoue en temps de exécution, il est donc utile de réaliser un test de base pour chaque cible d’application afin de s’assurer que chaque token public est résolu une fois.

    Relier le conteneur à React, Vue et Angular

    Puisque le conteneur ne connaît rien sur les bibliothèques UI, chaque framework dispose d’un petit adaptateur comptant environ 10 à 15 lignes de code.

    React

    Le conteneur est créé une seule fois au démarrage et n’est jamais remplacé, de sorte que la valeur du contexte ne change jamais. Cela évite la cascade de réaffichage généralement associée à Context : les consommateurs disposent d’une référence stable. useInjection mémorise les recherches par conteneur et par jeton et lance une erreur claire en cas d’absence du fournisseur.

    // libs/shared/react-di/src/lib/di-context.tsx
    import React, { createContext, useContext, useMemo, type ReactNode } from 'react';
    import type { Container, ServiceIdentifier } from 'inversify';
    
    export interface DIProviderProps {
      container: Container;
      children: ReactNode;
    }
    
    const DIContext = createContext<Container | null>(null);
    
    export const DIProvider = ({ container, children }: DIProviderProps) => (
      <DIContext.Provider value={container}>
        {children}
      </DIContext.Provider>
    );
    
    export const useInjection = <T,>(token: ServiceIdentifier<T>): T => {
      const container = useContext(DIContext);
    
      return useMemo(() => {
        if (!container) {
          throw new Error('useInjection must be used within a DIProvider');
        }
    
        return container.get(token);
      }, [container, token]);
    };
    

    Vue

    Le système de plugins de Vue ainsi qu’une InjectionKey typée permettent de rendre l’adaptateur encore plus court. Le plugin fournit le conteneur au niveau de l’application, et un composant y résout les tokens.

    // libs/shared/vue-di/src/lib/di-plugin.ts
    import type { App, InjectionKey } from 'vue';
    import { inject } from 'vue';
    import type { Container, ServiceIdentifier } from 'inversify';
    
    export const DI_CONTAINER: InjectionKey<Container> = Symbol('DI_CONTAINER');
    
    export const diPlugin = {
      install(app: App, { container }: { container: Container }) {
        app.provide(DI_CONTAINER, container);
      },
    };
    
    export const useInjection = <T>(token: ServiceIdentifier<T>): T => {
      const container = inject(DI_CONTAINER);
    
      if (!container) {
        throw new Error('diPlugin is not installed');
      }
    
      return container.get(token);
    };
    

    Angular

    L’injecteur hiérarchique d’Angular peut déléguer au conteneur de domaine via des fournisseurs de type factory. En transmettant le conteneur à l’aide d’un InjectionToken et en en créant un nouveau à chaque démarrage, on empêche les requêtes servies simultanément d’échanger des états.

    // apps/angular-app/src/app/domain.providers.ts
    import { InjectionToken, type Provider } from '@angular/core';
    import type { Container } from 'inversify';
    import { AuthFacade } from '@my-app/auth-contracts';
    import { initAppContainer } from './composition-root';
    
    export const DI_CONTAINER = new InjectionToken<Container>('DI_CONTAINER');
    export const AUTH_FACADE = new InjectionToken<AuthFacade>('AUTH_FACADE');
    
    export const provideDomainContainer = (container: Container): Provider[] => [
      {
        provide: DI_CONTAINER,
        useValue: container,
      },
      {
        provide: AUTH_FACADE,
        useFactory: (c: Container) => c.get(AuthFacade.$),
        deps: [DI_CONTAINER],
      },
    ];
    
    // main.ts - fresh container instance created per bootstrap
    bootstrapApplication(AppComponent, {
      providers: [...provideDomainContainer(initAppContainer())],
    });
    

    Chaque composant Angular supplémentaire nécessite sa propre InjectionToken et son propre fournisseur de factory, de sorte que la liste augmente avec l’API publique ; il est possible de la générer à partir d’un petit tableau lorsque celle-ci devient longue.

    Coûts et moments où l’on peut s’en passer

    La configuration ajoute environ 21 KB après compression et nécessite l’utilisation de emitDecoratorMetadata lors de la compilation. Dans un monorepo d’entreprise comptant dix domaines ou plus, ce coût est rapidement compensé. Pour une application avec deux ou trois écrans, l’isolation représente un surcoût inutile ; l’injection via constructeur simple ou même des singletons au niveau du module suffiront mieux.

    Règles pour l’injection de dépendances côté frontend

    • Conservez l’injection de dépendances du framework (Context, provide/inject, fournisseurs Angular) au niveau de l’interface utilisateur ; le cœur du domaine ne doit dépendre que des tokens de contrat.
    • Choisissez d’abord un conteneur pour l’histoire de son module : un domaine qui exporte un ContainerModule scellé est ce qui maintient le point d’entrée compact.
    • Utilisez des tokens Symbol.for de type ServiceIdentifier<T> afin que les packages dupliqués et l’inférence de type continuent de fonctionner correctement.
    • Imposez des limites grâce aux exports et aux règles de linting, et non uniquement par des conventions.
    • Permettez à un seul fichier, la racine de composition, d’accéder aux implémentations concrètes ; les builds multiplateformes deviennent alors une simple question de remplacement des bindings.
    • N’oubliez pas que l’injection n’est qu’un outil. L’objectif est l’inversion : si vous pouvez remplacer React par Vue, SQLite par IndexedDB ou Axios par Fetch sans modifier la couche de domaine, la stratégie a rempli sa fonction.

    Lectures complémentaires

  • Choisir une structure de dossier React : sept layouts et leurs points de bris : Comparez les layouts plats, basés sur les types, basés sur les fonctionnalités, Atomic Design, DDD, Feature-Sliced Design et monorepo pour React, et découvrez à quel niveau chacun cesse de fonctionner.
  • Déplacer Angular HttpClient vers le backend Fetch avec withFetch() : Comprenez pourquoi Angular HttpClient passe de XMLHttpRequest à fetch, comment withFetch() permet le SSR aux limites et le streaming, et quelles en sont les conséquences pour vos intercepteurs.
  • Comment Angular Dependency Injection résout les services avec inject() : Comprendre comment Angular DI fournit des services via inject(), les providers et les injecteurs hiérarchiques, ainsi que la manière de définir le périmètre d’application des instances et d’éviter les erreurs de fournisseur manquant.
  • SOLID comme question sur le changement : Refactoring d’un service Spring Boot : Apprendre à appliquer chaque principe SOLID dans un backend Spring Boot en se demandant ce qui est susceptible de changer, et comment éviter l’over-engineering que SOLID entraîne souvent.