À un moment donné, chaque développeur se heurte au même mur. Vous voulez écrire un test pour un bout de code, mais le code appelle secrètement new Database() en interne. Quoi que vous fassiez, une vraie connexion de base de données se lance. Vous voulez changer de fournisseur d'e-mail, mais le SDK e-mail est enfoui trois couches plus bas. Vous voulez exécuter la même logique pour une requête HTTP et un job planifié, mais le framework est tellement vissé dedans que c'est pratiquement impossible.
Ce sont les symptômes d'une seule cause profonde : un code qui crée ses propres dépendances. Le remède a un nom formel — Dependency Injection — mais l'idée qu'il cache est d'une simplicité rafraîchissante. Vous arrêtez de laisser le code trouver ses propres collaborateurs, et vous les lui passez depuis l'extérieur. C'est tout. Tout le reste — conteneurs IoC, frameworks DI, tokens d'injection — n'est que la mécanique construite sur cette seule idée.
Ce guide la démonte depuis zéro : le problème, le principe, le pattern et le bénéfice pratique pour les tests. TypeScript est utilisé tout au long car il se lit clairement pour les ingénieurs venant de n'importe quel contexte web ou serveur.
Dépendances codées en dur : quand le code appelle new tout seul
Voici un service qui traite une commande. Il semble parfaitement raisonnable au premier coup d'œil :
// order-service.ts — version « avant »
class OrderService {
async placeOrder(cart: Cart): Promise<Order> {
const db = new PostgresClient(process.env.DATABASE_URL!) // DB codée en dur
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!) // fournisseur codé en dur
const mailer = new SendGridMailer(process.env.SENDGRID_KEY!) // fournisseur codé en dur
const order = Order.fromCart(cart)
await stripe.charges.create({ amount: order.total, source: cart.token })
await db.query('INSERT INTO orders …', [order])
await mailer.send({ to: cart.email, template: 'order-confirmed' })
return order
}
}
Trois problèmes se cristallisent dès que vous essayez de travailler avec ça :
- Impossible à tester de façon isolée. Vous ne pouvez pas exécuter
placeOrderdans un test unitaire sans de vraies credentials, une base de données active et un sandbox Stripe actif. Par défaut, chaque test est un test d'intégration lent et fragile. - Couplé rigidement à des implémentations spécifiques. Si l'entreprise passe de Stripe à Adyen, vous ouvrez ce fichier et faites de la chirurgie. Si le staging a besoin d'un mailer factice, il n'y a pas de moyen propre d'en brancher un.
- Dépendances cachées. Un lecteur de la signature de la classe voit
placeOrder(cart)et n'a aucune idée que la méthode a secrètement besoin de trois services externes. Les dépendances sont invisibles jusqu'à ce que vous lisiez chaque ligne.
Chaque fois que vous voyez new UneTrucExterne() à l'intérieur d'un corps de méthode ou de constructeur — et non à la racine de composition — c'est une dépendance codée en dur. La classe est responsable à la fois de son propre travail et de trouver et construire les outils dont elle a besoin. Ce sont deux missions, et elles tirent dans des directions opposées.
Inversion of Control : qui est responsable de la création ?
Avant de résoudre le problème avec du code, il est utile de nommer le principe en jeu. L'Inversion of Control (IoC) est l'idée qu'un bout de code ne devrait pas être responsable d'obtenir ses propres dépendances. Au contraire, quelque chose d'externe — un framework, un harnais de test, une racine de composition — se charge de créer les choses et de les passer.
Vous avez peut-être entendu parler du soi-disant Principe Hollywood : « Ne nous appelez pas, nous vous appellerons. » Dans le code impératif classique, votre logique métier appelle ses dépendances directement — elle tend la main et attrape une base de données. Avec IoC, la dépendance vient à vous. Le contrôle de cette relation est inversé : vous ne décidez plus quand ni comment obtenir vos collaborateurs ; le monde extérieur décide et les livre.
L'IoC est un principe large, pas une technique spécifique. Les frameworks l'implémentent de différentes façons :
- Dependency Injection (DI) — passer les collaborateurs comme arguments du constructeur ou paramètres de méthode. La forme la plus courante et la plus explicite.
- Service Locator — un registre global depuis lequel le code récupère ses dépendances par leur nom. (Plus de détails sur pourquoi c'est un anti-pattern plus loin.)
- Template Method — une classe de base appelle des méthodes crochet que les sous-classes surchargent, de sorte que la base contrôle le flux et les sous-classes remplissent les blancs.
- Événement / Observateur — les composants publient des événements ; le framework les route vers les abonnés. Aucun côté ne connaît l'autre.
Parmi ceux-ci, la Dependency Injection est la plus transparente et la plus testable. C'est aussi celle qu'on confond le plus souvent avec l'IoC elle-même, ce qui explique pourquoi les deux termes méritent d'être distingués : l'IoC est le principe, la DI est une implémentation de ce principe.
Dependency Injection : passer les dépendances vers l'intérieur
La mécanique de la DI est presque embarrassante de simplicité une fois qu'on la voit. Au lieu de construire les collaborateurs à l'intérieur de votre classe, vous les déclarez comme paramètres et laissez l'appelant les fournir.
Il existe trois saveurs — mais une gagnante claire pour la plupart des situations :
Injection par constructeur (recommandée)
Les dépendances sont déclarées dans le constructeur. C'est le choix standard : chaque dépendance est visible dans la signature de la classe, requise au moment de la construction, et peut être rendue readonly pour qu'rien ne puisse la réassigner plus tard.
// order-service.ts — version « après » avec injection par constructeur
interface PaymentGateway { charge(amount: Money, token: string): Promise<Receipt> }
interface OrderRepository { save(order: Order): Promise<void> }
interface Mailer { send(to: string, template: string): Promise<void> }
class OrderService {
constructor(
private readonly payments: PaymentGateway,
private readonly orders: OrderRepository,
private readonly mailer: Mailer,
) {}
async placeOrder(cart: Cart): Promise<Order> {
const order = Order.fromCart(cart) // règle métier pure
await this.payments.charge(order.total, cart.token)
await this.orders.save(order)
await this.mailer.send(cart.email, 'order-confirmed')
return order
}
}
Lisez cette signature de constructeur à voix haute : « Pour créer un OrderService, vous avez besoin d'une passerelle de paiement, d'un dépôt de commandes et d'un mailer. » C'est un contrat, écrit en code simple, visible sans avoir à lire une seule ligne de méthode.
Injection par setter / méthode (usage occasionnel)
Les dépendances sont définies via une méthode après la construction. Utile pour les collaborateurs optionnels ou pour les frameworks qui construisent les objets en deux phases (par exemple certains frameworks de test, certains conteneurs IoC). À utiliser avec parcimonie — cela permet à un objet d'exister dans un état partiellement configuré, ce qui est une source subtile de bugs.
// injection par setter — à utiliser seulement quand le câblage à la construction est impossible
class ReportGenerator {
private formatter: ReportFormatter = new DefaultFormatter()
setFormatter(f: ReportFormatter) { this.formatter = f } // surcharge optionnelle
generate(data: ReportData): string {
return this.formatter.format(data)
}
}
Préférez l'injection par constructeur par défaut. Chaque dépendance dont la classe a toujours besoin appartient au constructeur. Ne recourez à l'injection par setter que quand une dépendance est réellement optionnelle, ou quand un framework l'impose. N'utilisez jamais l'injection par setter comme échappatoire pour éviter de réfléchir à votre graphe de dépendances.
La racine de composition : un seul endroit pour tout câbler
Une fois que vous injectez des dépendances par les constructeurs, une nouvelle question se pose : qui appelle réellement new et câble les pièces ensemble ? La réponse est la racine de composition : un endroit unique au point d'entrée de votre application où tous les objets réels sont construits et injectés les uns dans les autres.
Pensez-y comme le seul endroit dans tout votre codebase où new ImplémantationConcrète() peut apparaître librement. Tout le reste parle en termes d'interfaces. La racine de composition est la couture entre « le système abstrait » et « le monde réel ».
// composition-root.ts — le seul endroit où les vraies choses sont câblées
import { PostgresOrderRepository } from './adapters/postgres-order-repository'
import { StripePaymentGateway } from './adapters/stripe-payment-gateway'
import { SendGridMailer } from './adapters/sendgrid-mailer'
import { OrderService } from './domain/order-service'
import { OrderController } from './http/order-controller'
export function buildApp() {
// 1. Construire d'abord les dépendances feuilles (sans dépendances elles-mêmes)
const db = new PostgresOrderRepository(process.env.DATABASE_URL!)
const stripe = new StripePaymentGateway(process.env.STRIPE_SECRET_KEY!)
const mailer = new SendGridMailer(process.env.SENDGRID_KEY!)
// 2. Construire les services, en injectant leurs dépendances
const orderSvc = new OrderService(stripe, db, mailer)
// 3. Construire la couche de livraison, en injectant le service
const orderCtrl = new OrderController(orderSvc)
return orderCtrl
}
Remarquez comment la racine de composition se lit presque comme une recette : « préparez ces ingrédients, assemblez-les en services, mettez les services dans les contrôleurs. » Il y a un seul endroit dans tout le codebase où vous allez pour comprendre comment tout se connecte. C'est un atout considérable pour intégrer un nouvel ingénieur, ou quand vous devez échanger un ingrédient.
Dans un test, vous écrivez une racine de composition spécifique au test — un équivalent minuscule qui câble des faux à la place des vrais adapters :
// dans un fichier de test — même service, collaborateurs factices
const fakePayments = new InMemoryPaymentGateway()
const fakeOrders = new InMemoryOrderRepository()
const fakeMailer = new NoOpMailer()
const svc = new OrderService(fakePayments, fakeOrders, fakeMailer)
// s'exécute en quelques millisecondes ; pas de réseau, pas de base de données
DI manuelle vs conteneur DI — ce qui change et quand ça compte
Tout ce qui précède est de la DI manuelle, aussi appelée DI Pure. Vous écrivez le code de câblage vous-même. Pour beaucoup de projets — surtout les petits — c'est le bon choix : c'est du code simple, sans magie, immédiatement débogable, et un nouveau développeur peut comprendre tout le câblage en lisant un seul fichier.
À mesure que les applications grandissent, l'approche manuelle commence à faire mal. Un conteneur DI (parfois appelé conteneur IoC) automatise l'enregistrement et la résolution des dépendances, et ajoute par-dessus la gestion des durées de vie.
OrderService tend la main vers l'extérieur pour construire ses propres dépendances — chaque flèche est un couplage ancré dans le corps de la classe. À droite, une racine de composition construit les objets concrets et les injecte vers l'intérieur. Les flèches s'inversent. Le service ne connaît que les interfaces ; les types concrets sont un détail à la périphérie.| Question | DI manuelle / Pure | Conteneur DI (NestJS, InversifyJS, tsyringe…) |
|---|---|---|
| Comment les types sont-ils enregistrés ? | Vous écrivez le câblage explicitement dans une racine de composition. | Décorateurs, tokens ou métadonnées — le conteneur scanne et enregistre automatiquement. |
| Comment les instances sont-elles résolues ? | En appelant new vous-même, dans le bon ordre. |
Demandez au conteneur un type ; il construit le graphe complet récursivement. |
| Gestion des durées de vie | Vous décidez : une instance par module, par requête ou transitoire — et vous conservez la référence. | Déclarative : @Singleton(), @RequestScoped(), etc. Le conteneur gère la destruction. |
| Débogage | Tout est en code simple — posez un point d'arrêt n'importe où, suivez la pile d'appels. | La résolution se produit à l'intérieur du conteneur ; « pourquoi ai-je reçu cette instance ? » nécessite de comprendre les mécanismes internes du conteneur. |
| Code répétitif | Élevé pour les grands graphes — câbler une app de 50 classes à la main est verbeux. | Faible — le conteneur parcourt l'arbre des dépendances et construit tout automatiquement. |
| Sécurité à la compilation | TypeScript détecte les arguments de constructeur manquants à la compilation. | Dépend du conteneur ; les systèmes à base de tokens peuvent échouer à l'exécution si un binding est absent. |
| Idéal pour | Applications petites à moyennes, équipes nouvelles à la DI, microservices à portée limitée. | Grandes apps avec de nombreux services, équipes qui veulent de la convention-sur-configuration, frameworks comme NestJS où la DI est centrale. |
Le seuil à partir duquel un conteneur rapporte dépend du contexte, mais voici un repère approximatif : si votre racine de composition commence à devenir elle-même un fardeau de maintenance — vous la faites défiler en permanence, vous oubliez d'ajouter un nouveau binding, ou vous vous battez avec les durées de vie des objets — c'est le signal pour vous tourner vers un conteneur. Jusqu'alors, la version simple est plus facile à comprendre et à déboguer.
Le bénéfice pour les tests : injectez des faux, obtenez des tests rapides
La raison pour laquelle la DI vaut la peine d'être apprise — plus que toute élégance architecturale — c'est ce qu'elle fait à votre suite de tests. Quand chaque dépendance arrive par le constructeur, écrire un test signifie écrire une toute petite racine de composition avec des faux à la place des vrais adapters. Pas de base de données, pas de réseau, pas de sandbox fournisseur. Les tests s'exécutent en quelques millisecondes et n'échouent jamais parce que le compte Stripe de staging a une limite de taux.
// order-service.test.ts
class InMemoryPaymentGateway implements PaymentGateway {
receipts: Receipt[] = []
async charge(amount: Money, token: string): Promise<Receipt> {
const r = { id: 'fake-receipt', amount }
this.receipts.push(r)
return r
}
}
class InMemoryOrderRepository implements OrderRepository {
store: Order[] = []
async save(order: Order) { this.store.push(order) }
}
class SpyMailer implements Mailer {
sent: string[] = []
async send(to: string) { this.sent.push(to) }
}
// Le test lui-même est pur, rapide, déterministe :
it('confirme une commande et notifie le client', async () => {
const payments = new InMemoryPaymentGateway()
const repo = new InMemoryOrderRepository()
const mailer = new SpyMailer()
const svc = new OrderService(payments, repo, mailer)
const order = await svc.placeOrder(testCart)
expect(payments.receipts).toHaveLength(1)
expect(repo.store).toContainEqual(order)
expect(mailer.sent).toContain(testCart.email)
})
C'est la même intuition au cœur des Ports & Adapters — le faux en mémoire n'est que l'adapter pour le moment des tests, et la vraie implémentation Stripe ou Postgres est l'adapter de production. La DI est le mécanisme qui rend l'échange possible ; les ports & adapters sont le pattern architectural qui rend la frontière intentionnelle et explicite.
Il vaut aussi la peine de s'arrêter sur SOLID ici. Le « D » représente le Dependency Inversion Principle : les modules de haut niveau ne devraient pas dépendre des modules de bas niveau ; les deux devraient dépendre d'abstractions. Les interfaces ci-dessus (PaymentGateway, OrderRepository, Mailer) sont ces abstractions. OrderService est le module de haut niveau. StripePaymentGateway et PostgresOrderRepository sont les modules de bas niveau. Aucun ne connaît directement l'autre — ils se rencontrent à l'interface, et la racine de composition les assemble.
Anti-patterns : les façons dont la DI tourne mal
L'idée est simple, mais il existe quelques façons bien connues de la saper.
Le Service Locator
Un Service Locator est un registre global que le code appelle pour récupérer ses dépendances à l'exécution :
// anti-pattern : Service Locator — ressemble à de la DI mais n'en est pas
class OrderService {
async placeOrder(cart: Cart) {
const payments = ServiceLocator.get<PaymentGateway>('PaymentGateway')
const repo = ServiceLocator.get<OrderRepository>('OrderRepository')
// … continue
}
}
Ça semble plus propre que new Stripe() en ligne, mais ça cache le même problème. Les dépendances sont toujours invisibles dans la signature. Les tests doivent configurer un registre global avant de s'exécuter. Et la classe peut demander n'importe quoi à tout moment — vous avez perdu l'explicité qui rend la DI précieuse.
Sur-injection (le constructeur à 10 arguments)
Quand un constructeur accumule sept, huit ou dix paramètres, c'est généralement le signe que la classe en fait trop — pas que la DI est le mauvais outil. La correction consiste à décomposer la classe en collaborateurs plus petits et plus focalisés, chacun avec une liste de dépendances courte. La longueur du constructeur est un indicateur de santé utile.
Conteneurs globaux cachés
Accéder directement au conteneur DI depuis un service — plutôt qu'uniquement depuis la racine de composition — est le pattern Service Locator sous un nom différent. La règle est stricte : seule la racine de composition parle au conteneur. Les services ne parlent qu'à leurs interfaces injectées.
Magie impossible à tracer
Certains frameworks câblent les dépendances via des décorateurs et des métadonnées de réflexion d'une façon qui est réellement difficile à suivre quand quelque chose se casse. Si vous passez plus de temps à lire la documentation du conteneur qu'à lire votre propre logique métier, c'est un signe de recourir à une approche plus simple — ou au minimum d'ajouter une racine de composition bien documentée qui explicite les bindings majeurs.
Calibrer la DI : du projet solo à l'entreprise
Comme la plupart des idées architecturales, la DI s'adapte au problème. L'objectif reste toujours le même — du code testable et modifiable — mais la bonne mécanique pour y arriver change à mesure que l'équipe et le codebase grandissent.
| Stade de l'entreprise | Approche recommandée | Forme typique |
|---|---|---|
| Solo / startup précoce | DI manuelle avec un seul fichier de racine de composition. | Une fonction buildApp() ; peut-être 10–20 lignes. Rapide à écrire, facile à lire. |
| Petite équipe (5–20 ingénieurs) | Toujours de la DI manuelle, éventuellement répartie en modules de domaine, chacun avec sa propre fonction de câblage. | buildOrderModule(), buildBillingModule() — chacun autonome, composé une fois au démarrage. |
| Scale-up en croissance (20–100 ingénieurs) | Un conteneur léger commence à payer. Envisagez tsyringe, InversifyJS, ou le conteneur intégré dans votre framework. | Décorateurs sur les classes, conteneur configuré par module ; services à portée de requête pour les apps web. |
| Enterprise / grande plateforme | Un framework DI complet est presque indispensable. NestJS, Spring Boot ou ASP.NET Core sont construits autour de cette idée à grande échelle. | Convention-sur-configuration, hooks de cycle de vie, imports/exports de modules, portée multi-tenant. |
Une startup fintech peut commencer avec une racine de composition de 30 lignes qui câble trois services. Deux ans plus tard, le même codebase compte 60 services : le câblage manuel est devenu une tâche de maintenance en soi, et l'équipe se tourne vers NestJS ou InversifyJS pour gérer le graphe automatiquement. La transition est simple parce que le pattern sous-jacent — injection par constructeur, interfaces, racine de composition claire — reste le même. Le conteneur est un assistant mécanique, pas une idée différente.
Commencez avec la DI manuelle et une racine de composition. Vous pouvez introduire un conteneur à tout moment quand le fichier de câblage devient un fardeau, et la migration est mécanique : vous remplacez les appels new explicites par des enregistrements. Rien dans votre domaine, vos services ou vos tests n'a besoin de changer — seule la racine de composition.
Points clés à retenir
- La cause profonde d'un code rigide et non testable est une classe qui appelle
newsur ses propres dépendances. Le remède est de les passer de l'extérieur. - L'Inversion of Control est le principe (quelqu'un d'autre est responsable de fournir vos dépendances). La Dependency Injection est la façon la plus explicite et la plus testable de l'implémenter.
- L'injection par constructeur est le choix par défaut. Les dépendances sont visibles dans la signature, requises à la construction et facilement échangeables dans les tests.
- La racine de composition est l'endroit unique dans votre codebase où les objets réels sont câblés ensemble. Tout le reste parle via des interfaces.
- La DI manuelle est plus simple et plus débogable pour les petits codebases. Un conteneur DI rapporte quand la racine de composition elle-même devient un fardeau de maintenance.
- Le bénéfice pour les tests est immédiat : injectez des faux dans les tests, évitez le réseau et la base de données, et exécutez des milliers de cas en quelques secondes.
- C'est aussi le Dependency Inversion Principle (le « D » dans SOLID) en action : les modules de haut niveau et de bas niveau dépendent tous deux d'abstractions, jamais directement l'un de l'autre.
- Évitez le Service Locator (registre global caché), la sur-injection (constructeurs à dix arguments) et l'accès au conteneur en dehors de la racine de composition.
Avec une bonne compréhension de la façon dont les dépendances circulent, la question naturelle suivante est comment organiser les fichiers eux-mêmes. Dans la prochaine partie de cette série, nous examinons exactement ça : Structurer un Codebase : Dossiers par Fonctionnalité ou par Couche ? — et pourquoi la réponse dépend de la forme de votre équipe autant que de la forme de votre code.