Écrire son propre injecteur de dépendances en TypeScript
Cet article est libre d'accès pour tous grâce à ceux qui soutiennent notre blog indépendant.
Pourquoi donc vouloir écrire soi-même son injecteur de dépendances ? L’idée m’est venue après une énième discussion avec un développeur sur l’injection de dépendance. Encore une fois, je me suis rendu compte que pour beaucoup, l’injection de dépendance, c’est magique.
Excusez-moi de vous décevoir — et au risque de me répéter — rien n’est magique en informatique. Derrière, il n’y a que des règles de logique et un peu de récursivité.
Alors plutôt que de la voir comme une boîte noire fournie par un framework, voyons ensemble ce qu’il y a dedans. On va écrire notre propre injecteur de dépendances en TypeScript, étape par étape.
Le but de cet article est de vous aider à comprendre le principe de l'injection de dépendances et de vous montrer qu'un injecteur de dépendance, c'est pas si compliqué que ça (surtout si l'on ne gère que les cas classiques comme ici).
Qu’est-ce que l’injection de dépendances ?
C’est l’idée d’externaliser la création des classes et de leurs dépendances. Autrement dit, on ne veut plus instancier manuellement avec new à l’intérieur de nos classes métier.
Un injecteur de dépendances, c’est un service qui sait instancier les objets à notre place et qui s’occupe de stocker les instances. Quand on a besoin d’un objet, on le lui demande et il le fournit, déjà construit avec tout ce qu’il faut.
Mais rapidement deux questions se posent :
- Pourquoi appelle-t-on ça un “injecteur de dépendances” si, au fond, il fait juste des new ?
- Comment sait-il instancier une classe si chaque constructeur attend des paramètres différents ?
Réponse : parce que les objets qu’il crée sont souvent eux-mêmes des dépendances d’autres objets. L’injecteur assemble automatiquement les pièces du puzzle pour qu’on n’ait pas à le faire à la main.
Exemple simple
Imaginons que l’injecteur doive créer B : pas de dépendances, simple. Ensuite il doit créer A, mais A attend B et C dans son constructeur. L’injecteur constate qu’il a déjà B, mais pas C. Il crée donc C, revient à A, puis injecte B et C dans le constructeur.
C’est ça l’injection de dépendances : une mécanique pour ne pas répéter sans cesse la même corvée.
Une histoire de responsabilité
Instancier une classe est une responsabilité à part entière. Si on mélange cette responsabilité dans toutes nos classes, on finit par écrire plus de code d’instanciation que de vrai code métier.
Cela pose aussi un problème de couplage entre les différents éléments de notre base de code. Lorsqu'une classe A instancie une classe B avec un new dans son code, on dit que ces deux classes sont fortement couplées. Lorsqu'une classe A attend qu'un injecteur de dépendances injecte la classe B via son constructeur, on dit qu'ils sont faiblement couplées. Donc utiliser un injecteur reduit le couplage entre deux classe.
On pourrait même aller plus loins grâce a l'utilisation de "contrat" entre les classes pour les découpler totalement. C'est ce que fait par exemple le pattern Port & Adapters.
Confier cette tâche à un injecteur, c’est donc centraliser la logique, réduire la duplication, et clarifier le rôle de chaque morceau de code.
Construisons notre propre injecteur
On va le faire progressivement, en partant d’un simple container jusqu’à une API unifiée proche de ce que proposent les frameworks comme NestJS.
Étape 1 — Un container basique
Problème pratique : aujourd’hui, si on écrit un Service qui dépend d’un Logger, on doit instancier Logger à la main avant de construire Service. C’est verbeux et répétitif parce qu'il faudrait faire la même chose dans chaque classe où on aura besoin du Logger. On veut aussi donner cette responsabilité à une autre classe. C'est ce que l'on va faire.
Notre première version de l’injecteur va simplement :
- enregistrer une classe,
- retrouver les paramètres de son constructeur,
- instancier récursivement.
On aura besoin de deux méthodes : une pour déclarer une classe et l'autre pour demander l'instance d'une classe.
import { Reflect } from 'reflect-metadata';
type Constructor<T = unknown> = new (...args: unknown[]) => T;
export class Container {
private instances = new Map<unknown, unknown>();
private registry = new Map<unknown, Constructor>();
register<T>(ctor: Constructor<T>): void {
this.registry.set(ctor.name, ctor);
}
resolve<T>(ctor: Constructor<T>): T {
if (this.instances.has(ctor)) {
return this.instances.get(ctor) as T
}
const target = this.registry.get(ctor);
if (!target) {
throw new Error(`Classe non enregistrée: ${ctor}`);
}
const deps = Reflect.getMetadata('design:paramtypes', target) || [];
const args = deps.map((dep: Constructor) => this.resolve(dep));
const instance = new target(...args);
this.instances.set(ctor, instance);
return instance as T;
}
}Cette logique nous permette de résoudre le principal problème de l'injecteur de dépendance : que fait-on si pour instancier une classe on a besoin de lui passer en paramètre un objet que l'injecteur de dépendance ne connait pas ?
Dans notre code, on part du principe que l'utilisateur va d'abord déclarer toutes ces classes et ensuite seulement essayer d'en instancier une. Du coup si on instancie les dépendances qu'au moment où l'utilisateur les demandes on est sur des les connaitre.
Regardons comment notre injecteur de dépendance s'utilise :
class Logger {
log(m: string) {
console.log('[LOG]', m);
}
}
class Service {
constructor(private logger: Logger) {}
run() {
this.logger.log('Hello !');
}
}
const container = new Container();
container.register(Logger);
container.register(Service);
container.resolve(Service).run(); // → [LOG] Hello !Ici, on a déjà gagné : on n’écrit plus de new Logger() ni de new Service(logger).
Mais… c'est très limité. Essayons de rendre notre injecteur un peu plus flexible.
Étape 2 — Gérer les échanges de classes
Problème pratique : un avantage d'avoir déplacer les dépendances dans le constructeur c'est que maintenant c'est qu'ils sont maintenant accessible. J'aimerais donc pouvoir remplacer mes classes par leur version mocké et facilité ainsi mes tests.
Pour faire cela, il faudrait que je puisse indiquer que pour une classe donnée, je veux en injecter une autre. On a déjà tout pour le faire dans l'injecteur mais la signature du register n'est pas bonne.
import { Reflect } from 'reflect-metadata';
type Constructor<T = unknown> = new (...args: any[]) => T;
export class Container {
private instances = new Map<unknown, unknown>();
private registry = new Map<unknown, Constructor>();
register<T>(ctor: Constructor<T>): void;
register<T>(token: Constructor<T>, ctor: Constructor<T>): void;
register<T>(tokenOrCtor: Constructor<T>, maybeCtor?: Constructor<T>): void {
if (maybeCtor) {
this.registry.set(tokenOrCtor, maybeCtor);
return;
}
this.registry.set(tokenOrCtor, tokenOrCtor);
}
resolve<T>(ctor: Constructor<T>): T {
if (this.instances.has(ctor)) {
return this.instances.get(ctor) as T
}
const target = this.registry.get(ctor);
if (!target) {
throw new Error(`Classe non enregistrée: ${ctor}`);
}
const deps = Reflect.getMetadata('design:paramtypes', target) || [];
const args = deps.map((dep: Constructor) => this.resolve(dep));
const instance = new target(...args);
this.instances.set(ctor, instance);
return instance as T;
}
}On a donc modifié la logique de la méthode register et on lui a ajouté deux signature alternative. Par contre nous n'avons pas modifié la méthode resolve car la logique ne change pas.
Voici comment on utilise maintenant l'injecteur de dépendances :
class Logger {
log(m: string) {
console.log('[LOG]', m);
}
}
class MockLogger {
log() {}
}
class Service {
constructor(private logger: Logger) {}
run() {
this.logger.log('Hello !');
}
}
const testContainer = new Container();
testContainer.register(Logger, MockLogger);
testContainer.register(Service);
testContainer.resolve(Service).run();Étape 3 — Gérer les interfaces avec des tokens
Voilà qui laisse entrevoir une nouvelle possibilité intéressante. Et si, au lieu de remplacer une classe par une autre, je remplaçais une interface par une classe ?
Pourquoi je voudrais faire ça ? Dans beaucoup de langage, on utilise les interfaces comme des contrats pour découpler totalement ses classes. Cela permet a une classe qui va consommer un service d'indiquer exactement ce dont elle a besoin tout en permettant à plus de service de convenir.
Par exemple, reprenons la dernière version de notre code, Service a besoin d'un logger mais il s'en fou de quel logger il s'agit tant qu'il peut logger. On pourrait donc transformer la classe Logger en interface. De cette façon,
Problème pratique : en TypeScript, les interfaces disparaissent après compilation. Impossible de dire “donne-moi un Repo” si Repo est juste une interface.
C’est LE cas d’usage qui rend les tokens nécessaires.
On introduit donc des tokens (constructeur, string ou mieux : Symbol). Ils servent d’identifiants uniques au runtime.
import 'reflect-metadata';
export type Constructor<T = any> = new (...args: any[]) => T;
export type Token<T = any> = Constructor<T> | symbol | string;
export class Container {
private instances = new Map<Token, any>();
private registry = new Map<Token, Constructor>();
register<T>(ctor: Constructor<T>): void;
register<T>(token: Token<T>, ctor: Constructor<T>): void;
register(tokenOrCtor: any, maybeCtor?: any): void {
if (maybeCtor) this.registry.set(tokenOrCtor, maybeCtor);
else this.registry.set(tokenOrCtor, tokenOrCtor);
}
resolve<T>(token: Token<T>): T {
if (this.instances.has(token)) return this.instances.get(token);
const ctor = this.registry.get(token);
if (!ctor) throw new Error(`Aucune classe pour "${String(token)}"`);
const deps = Reflect.getMetadata('design:paramtypes', ctor) || [];
const args = deps.map((dep: Token) => this.resolve(dep));
const instance = new ctor(...args);
this.instances.set(token, instance);
return instance;
}
}Exemple avec un port et un adapter :
// tokens.ts
export const TOKENS = { Repo: Symbol('Repo') };
// domain/ports.ts
export interface Repo { findAll(): string[]; }
// infra/in-memory-repo.ts
export class InMemoryRepo implements Repo { findAll(){ return ['Alice', 'Bob']; } }
// app/logger.ts
export class Logger { log(m: string){ console.log('[LOG]', m); } }
// app/service.ts
import { TOKENS } from '../tokens';
import type { Repo } from '../domain/ports';
import { Logger } from './logger';
export class Service {
constructor(private logger: Logger, private repo: Repo) {}
run(){ this.logger.log('Data: ' + this.repo.findAll().join(', ')); }
}
// main.ts
const c = new Container();
c.register(Logger);
c.register(Service);
c.register(TOKENS.Repo, InMemoryRepo);
c.resolve(Service).run(); // → [LOG] Data: Alice, BobGrâce aux tokens, on peut brancher n’importe quelle implémentation (InMemoryRepo, PostgresRepo, FakeRepo) sur le port Repo.
C’est exactement ce que demande l’architecture hexagonale.
Étape 3 — Injecter aussi des valeurs
Problème pratique : on ne veut pas injecter que des classes. On a souvent besoin d’une config (API_URL, ENV) ou d’une constante (MAX_RETRY).
On ajoute donc registerValue.
class Container {
// … même code qu’avant …
registerValue<T>(token: Token<T>, value: T): void {
this.instances.set(token, value);
}
}Exemple :
export const TOKENS = { Config: Symbol('Config') };
c.registerValue(TOKENS.Config, { API_URL: 'https://api.example.com', ENV: 'dev' });Et dans une classe :
class Service {
constructor(private logger: Logger, private repo: Repo, private config: { API_URL: string }) {}
run(){ this.logger.log(`App sur ${this.config.API_URL}`); }
}Étape 4 — Cas non triviaux avec registerFactory
Problème pratique : certaines dépendances ne se créent pas avec un simple new.
Exemples : un client HTTP qu’on veut configurer, ou choisir dynamiquement une implémentation selon l’environnement.
On ajoute registerFactory.
class Container {
// … même code qu’avant …
registerFactory<T>(token: Token<T>, deps: Token[], factory: (...args: any[]) => T): void {
const resolvedDeps = deps.map((t) => this.resolve(t));
this.instances.set(token, factory(...resolvedDeps));
}
}Exemple :
c.registerFactory(TOKENS.HttpClient, [TOKENS.Config], (cfg) => {
return {
get: (path: string) => fetch(cfg.API_URL + path).then(r => r.json())
};
});Étape 5 — Une API unifiée (façon NestJS)
Problème pratique : avoir plusieurs méthodes (register, registerValue, registerFactory) complique l’API.
On simplifie tout en une seule méthode register, avec une forme objet qui dit explicitement l’intention :
useClass→ instancier une classeuseValue→ enregistrer une valeuruseFactory→ appeler une factory
container.register(Logger); // équivaut à { provide: Logger, useClass: Logger }
container.register({ provide: TOKENS.Repo, useClass: InMemoryRepo });
container.register({ provide: TOKENS.Config, useValue: { API_URL: 'https://api.example.com' } });
container.register({
provide: TOKENS.HttpClient,
useFactory: (cfg) => makeHttp(cfg),
inject: [TOKENS.Config]
});Avec ça, on couvre tous les cas. C’est propre, lisible, et familier si on a déjà vu du code NestJS.
L’injection de dépendances et l’architecture hexagonale
À ce stade, on a un injecteur qui fonctionne très bien. Mais la vraie question est : « À quoi ça sert vraiment ? »
C’est là que l’architecture hexagonale prend tout son sens.
Son principe :
- au centre, le domaine (notre logique métier),
- autour, des ports (interfaces),
- et encore plus loin, des adapters (implémentations concrètes).
Le domaine ne doit jamais dépendre de l’infrastructure. C’est l’infrastructure qui s’adapte au domaine, pas l’inverse.
Et c’est notre injecteur qui rend ça possible. On définit les contrats côté domaine, on branche les implémentations côté infra, et c’est l’injecteur qui assemble le tout.
👉 C’est le cas d’usage principal de l’injection de dépendances : rendre praticable au quotidien cette séparation nette entre métier et infrastructure.
Next steps pour progresser
Notre injecteur est déjà fonctionnel, mais on peut aller plus loin si on veut s’amuser :
- Scopes : aujourd’hui, tout est singleton. Essayons d’ajouter un “scope transient” (nouvelle instance à chaque fois) ou “scoped” (lié à une requête).
- Détection de cycles : comment éviter que A → B → A boucle à l’infini ?
- Async factories : que faire si une dépendance doit être initialisée avec await ?
Ces exercices sont une excellente manière de comprendre pourquoi les frameworks ajoutent ces fonctionnalités, et surtout d’apprendre en construisant.
Conclusion
On a commencé avec une idée simple : arrêter de multiplier les new.
Étape après étape, on a vu comment transformer cette idée en un injecteur complet, capable de gérer :
- des classes,
- des interfaces via des tokens,
- des valeurs de configuration,
- des factories pour les cas complexes,
- et une API unifiée lisible.
L’injection de dépendances n’est donc pas une magie, mais un outil au service de l’architecture. Elle nous aide à garder notre code clair, testable et évolutif.
Alors, est-ce qu’il faut réécrire son injecteur dans chaque projet ? Non. Mais comprendre son fonctionnement change la manière dont on lit et utilise un framework : on sait ce qu’il fait pour nous, et surtout pourquoi.
Et si un jour vous devez poser des bases solides pour un projet — que ce soit une petite app, un MVP ou un futur SaaS — vous aurez déjà les bons réflexes pour structurer votre code proprement.
Rejoins 250+ développeurs de notre liste de diffusion et sois reçois les articles directement dans ta boite mail.
Aucun spam. Désabonnes-toi en un seul clic à tout moment.
Si vous avez des questions ou des remarques/conseils, n'hésitez pas à laisser un commentaire plus bas ! Je serais ravis de vous lire. Et si vous aimez l'article, n'oubliez pas de le partager avec vos amis.