Design patterns en TypeScript - 08 - Singleton, Service Locator et Dependency Injection

Singleton (et pourquoi l'éviter), Service Locator (et pourquoi c'est pire) et Dependency Injection (la bonne approche).

08 - Singleton, Service Locator et Dependency Injection

Ce que tu vas apprendre

  • Pourquoi le Singleton pose problème (meme si tout le monde l'utilise)
  • Pourquoi le Service Locator est pire
  • Comment faire de la DI manuelle sans framework

Prerequisites

Avoir lu l'introduction de la serie et l'article sur le Repository. La serie sur l'architecture hexagonale aide a comprendre le contexte.


Le Singleton : une instance, un problème

Le pattern le plus connu. Une seule instance d'une classe dans toute l'application. En Java, c'est un constructeur prive + une méthode getInstance(). En TypeScript ? C'est beaucoup plus simple :

typescript// Le "Singleton TypeScript" : un export de module
// database.ts
const db = new Database(process.env.DATABASE_URL!);
export { db };

Quand tu importes db depuis ce module, tu obtiens toujours la meme instance. Le système de modules de Node/Bun garantit qu'un module n'est exécuté qu'une fois (sauf edge cases avec des symlinks ou des chemins différents). Pas besoin de getInstance(), pas besoin de constructeur prive.

Ca marche. Et c'est la que les ennuis commencent.

Les problèmes du Singleton

Dépendance cachee. Quand un service importe directement db, la dépendance est invisible dans sa signature. Tu regardes la classe, tu vois un constructeur sans paramètres. Tu ne sais pas qu'elle depend d'une base de donnees sans ouvrir le fichier et lire les imports.

typescript// La dependance est cachee dans les imports
import { db } from "./database";

class OrderService {
  async getOrder(id: string): Promise<Order> {
    return db.query("SELECT * FROM orders WHERE id = $1", [id]);
  }
}

Tests difficiles. Comment tester OrderService sans base de donnees ? Tu dois mocker le module ./database. En Bun, c'est faisable avec mock.module, mais c'est fragile et lie tes tests a la structure des imports.

État partage mutable. Un Singleton mutable, c'est un état global. Si un test modifie l'état du Singleton, le test suivant en hérité. Les tests deviennent dependants de l'ordre d'exécution.

Il y a des cas ou le Singleton est acceptable : un pool de connexions DB (tu en veux vraiment un seul), un logger applicatif, une config chargee une fois au démarrage. Mais meme dans ces cas, l'injection de dépendances est préférable.

Le Service Locator : le Singleton sous steroides

Le Service Locator est un registre global qui fournit les dépendances a la demande :

typescript// Le Service Locator
class Container {
  private static services = new Map<string, any>();

  static register(name: string, service: any): void {
    this.services.set(name, service);
  }

  static get<T>(name: string): T {
    const service = this.services.get(name);
    if (!service) throw new Error(`Service ${name} not registered`);
    return service as T;
  }
}

// Enregistrement au demarrage
Container.register("db", new Database(process.env.DATABASE_URL!));
Container.register("mailer", new SmtpMailer(smtpConfig));

// Usage dans un service
class OrderService {
  async createOrder(data: OrderData): Promise<Order> {
    const db = Container.get<Database>("db");
    const mailer = Container.get<Mailer>("mailer");
    // ...
  }
}

Tous les problèmes du Singleton, amplifies. La dépendance n'est pas juste cachee dans un import, elle est cachee derrière un appel Container.get au milieu du code. La signature de OrderService ne dit rien sur ses besoins. Pour tester, tu dois configurer le Container global avant chaque test. Si tu te trompes dans le nom du service ("db" vs "database"), erreur au runtime pas au compile time.

Martin Fowler a écrit un article entier expliquant pourquoi le Service Locator est un anti-pattern quand on a le choix. Et en TypeScript, on a le choix.

La bonne approche : Dependency Injection

Passer les dépendances explicitement par le constructeur. C'est tout.

typescriptinterface OrderRepository {
  save(order: Order): Promise<void>;
  findById(id: string): Promise<Order | null>;
}

interface EmailSender {
  send(to: string, subject: string, html: string): Promise<void>;
}

class OrderService {
  constructor(
    private repo: OrderRepository,
    private emailer: EmailSender,
  ) {}

  async createOrder(data: OrderData): Promise<Order> {
    const order = Order.create(data);
    await this.repo.save(order);
    await this.emailer.send(
      data.customerEmail,
      "Commande confirmee",
      `<h1>Merci pour ta commande #${order.id}</h1>`
    );
    return order;
  }
}

Les dépendances sont visibles. La signature dit : "j'ai besoin d'un OrderRepository et d'un EmailSender". Pour tester :

typescriptimport { test, expect } from "bun:test";

test("createOrder sauvegarde et envoie un mail", async () => {
  const fakeRepo = new InMemoryOrderRepository();
  const sentEmails: Array<{ to: string; subject: string }> = [];
  const fakeEmailer: EmailSender = {
    send: async (to, subject) => { sentEmails.push({ to, subject }); },
  };

  const service = new OrderService(fakeRepo, fakeEmailer);
  const order = await service.createOrder({
    customerEmail: "client@example.com",
    items: [{ productId: "p1", qty: 2, price: 10 }],
  });

  expect(order.id).toBeDefined();
  expect(sentEmails).toHaveLength(1);
  expect(sentEmails[0].to).toBe("client@example.com");
});

Pas de mock de modules. Pas de Container a configurer. Des objets simples passes en paramètres. C'est comme ca que fonctionne le code dans la serie sur l'architecture hexagonale : chaque use case recoit ses ports par le constructeur.

Le composition root

Ou est-ce qu'on assemble tout ? Dans le fichier d'entree de l'application, souvent main.ts ou index.ts. C'est le "composition root" :

typescript// main.ts - le seul endroit qui connait les implementations concretes
import { Database } from "./infra/database";
import { PgOrderRepository } from "./infra/pg-order-repository";
import { SmtpEmailSender } from "./infra/smtp-email-sender";
import { OrderService } from "./domain/order-service";
import { OrderController } from "./api/order-controller";

const db = new Database(process.env.DATABASE_URL!);
const orderRepo = new PgOrderRepository(db);
const emailSender = new SmtpEmailSender(process.env.SMTP_URL!);
const orderService = new OrderService(orderRepo, emailSender);
const orderController = new OrderController(orderService);

// Brancher les routes
app.post("/orders", orderController.create);
app.get("/orders/:id", orderController.get);

Un seul fichier qui fait les new. Le reste de l'application ne fait que recevoir des interfaces. Sur paltemps.fr, c'est exactement cette structure : le point d'entree assemble les adaptateurs (IMAP, SMTP, repositories) et les injecte dans les services.

Faut-il un framework DI ?

Des libs comme tsyringe ou inversify automatisent l'injection avec des decorateurs et de la reflexion :

typescript// Avec tsyringe
@injectable()
class OrderService {
  constructor(
    @inject("OrderRepository") private repo: OrderRepository,
    @inject("EmailSender") private emailer: EmailSender,
  ) {}
}

Mon avis : pour la plupart des projets TypeScript, la DI manuelle suffit. Tu as 10 services ? 20 ? Tu ecris le wiring a la main dans main.ts en 40 lignes. C'est lisible, debuggable, et zero dépendance supplementaire.

Un framework DI commence a se justifier quand tu depasses 50+ services avec des graphes de dépendances complexes. Avant ca, le coût (config, decorateurs, reflexion, emitDecoratorMetadata dans tsconfig) dépassé le benefice.

NestJS embarque un container DI. Si tu utilises NestJS, utilise-le, c'est l'approche idiomatique du framework. Mais si tu es sur Elysia, Fastify ou Express, la DI manuelle est le choix pragmatique.


Résumé

  • Le Singleton en TypeScript, c'est un export de module (simple mais avec des inconvenients pour les tests)
  • Le Service Locator cache les dépendances derrière un registre global (a éviter)
  • La Dependency Injection passe les dépendances par le constructeur (explicite et testable)
  • Le composition root assemble tout dans main.ts
  • Pas besoin de framework DI pour la plupart des projets

Article précédent : 07 - Decorator

Article suivant : 09 - Les anti-patterns

Sources

Réservez un audit gratuit de 30 minutes. Je vous montre concrètement ce qu'on peut automatiser.