Le pattern Singleton : garantir une instance unique #
Introduction #
Le Singleton est l’un des design patterns les plus connus (et parfois controversés) du Gang of Four. Son objectif est simple : garantir qu’une classe n’a qu’une seule instance dans toute l’application et fournir un point d’accès global à cette instance.
Dans cet article, nous allons explorer le pattern Singleton à travers un exemple concret : une classe Coordinateur qui ne peut exister qu’en un seul exemplaire dans le système.
Qu’est-ce que le Singleton ? #
Définition #
Le pattern Singleton assure qu’une classe :
- N’a qu’une seule instance durant toute la vie de l’application
- Fournit un point d’accès global à cette instance
- Contrôle sa propre instanciation via un constructeur privé
Structure UML #
classDiagram
class Singleton {
-static instance: Singleton
-constructor()
+static getInstance() Singleton
+businessMethod()
}
note for Singleton "Constructeur privé\nInstance statique\nGetter statique"
| Caractéristique | Description |
|---|---|
| Constructeur privé | Empêche l’instanciation directe avec new |
| Instance statique | Stocke l’unique instance de la classe |
| Méthode statique | getInstance() retourne l’instance unique |
| Lazy initialization | L’instance est créée à la première demande |
Cas d’usage : Le coordinateur unique #
Contexte #
Imaginons un système de gestion d’équipe où :
- Un seul coordinateur supervise toute l’équipe
- Ce coordinateur doit être accessible depuis n’importe où dans l’application
- Il ne doit jamais y avoir deux coordinateurs simultanément
Problématique sans Singleton #
// PROBLÈME : Plusieurs instances possibles
const coord1 = new Coordinateur();
const coord2 = new Coordinateur();
// coord1 et coord2 sont deux instances différentes
console.log(coord1 === coord2); // false
Conséquences :
- Incohérence des données
- Confusion sur “qui est le vrai coordinateur”
- Gaspillage de ressources
- Bugs difficiles à tracer
Implémentation typeScript #
Architecture complète #
classDiagram
class Person {
<>
#name: string
#firstname: string
#email: string
#phone: string
+birthdate: Date
+gender: string
-strategy: NameStrategy
+getName() string
+setName(name: string) void
+getFirstname() string
+setFirstname(firstname: string) void
+getEmail() string
+setEmail(email: string) void
+getPhone() string
+setPhone(phone: string) void
+getDisplayName() string
+setStrategy(strategy: NameStrategy) void
+sayHello()* void
}
class Coordinateur {
-static instance: Coordinateur
-static instanciations: number
-constructor()
+static getInstance() Coordinateur
+sayHello() void
}
class NameStrategy {
<>
+transform(person: Person) string
}
class NameFirstStrategy {
+transform(person: Person) string
}
Person <|-- Coordinateur : hérite
Person --> NameStrategy : utilise
NameStrategy <|.. NameFirstStrategy : implémente
note for Coordinateur "Singleton Pattern\nUne seule instance possible"
Classe abstraite Person #
import { NameFirstStrategy } from "./strategies/name-first-strategy";
import type { NameStrategy } from "./strategies/name-strategy";
export abstract class Person {
// Attributs protégés (accessibles aux sous-classes)
protected name: string = '';
protected firstname: string = '';
protected email: string = '';
protected phone: string = '';
// Attributs publics
public birthdate: Date = new Date();
public gender: string = '';
// Pattern Strategy pour le formatage du nom
private strategy: NameStrategy = new NameFirstStrategy()
constructor() {}
// Getters et Setters avec validation
public getName(): string {
return this.name;
}
public setName(name: string): void {
// Empêche la modification si déjà défini
if (this.name === '') {
this.name = name;
}
}
public getFirstname(): string {
return this.firstname;
}
public setFirstname(firstname: string): void {
if (this.firstname === '') {
this.firstname = firstname;
}
}
public getEmail(): string {
return this.email;
}
public setEmail(email: string): void {
if (this.email === '') {
this.email = email;
}
}
public getPhone(): string {
return this.phone;
}
public setPhone(phone: string): void {
if (this.phone === '') {
this.phone = phone;
}
}
// Utilisation du pattern Strategy
public getDisplayName(): string {
return this.strategy.transform(this)
}
public setStrategy(strategy: NameStrategy): void {
this.strategy = strategy
}
// Méthode abstraite (doit être implémentée par les sous-classes)
public abstract sayHello(): void;
}
Points clés :
- Classe abstraite : ne peut pas être instanciée directement
- Encapsulation : attributs protégés avec getters/setters
- Validation : empêche la modification des données une fois définies
- Strategy Pattern : formatage du nom configurable
Classe Singleton Coordinateur #
import { Person } from "./person"
export class Coordinateur extends Person {
// Instance statique privée (le cœur du Singleton)
private static instance: Coordinateur | undefined = undefined
// Compteur optionnel pour tracer les tentatives d'instanciation
private static instanciations: number = 0
// Constructeur privé : interdit `new Coordinateur()`
private constructor() {
super()
Coordinateur.instanciations++
}
// Méthode statique : seul moyen d'obtenir l'instance
public static getInstance(): Coordinateur {
// Lazy initialization : création à la première demande
if (Coordinateur.instance === undefined) {
Coordinateur.instance = new Coordinateur()
}
return Coordinateur.instance
}
// Implémentation de la méthode abstraite
public sayHello(): void {
console.log(`Bonjour, je suis ${this.getDisplayName()}, le coordinateur unique !`);
}
// Méthode utilitaire pour déboguer
public static getInstanciationCount(): number {
return Coordinateur.instanciations
}
}
Stratégie de Formatage (Pattern Strategy) #
// Interface Strategy
export interface NameStrategy {
transform(person: Person): string;
}
// Implémentation : Prénom puis Nom
export class NameFirstStrategy implements NameStrategy {
transform(person: Person): string {
return `${person.getFirstname()} ${person.getName()}`;
}
}
// Implémentation alternative : Nom puis Prénom
export class LastNameFirstStrategy implements NameStrategy {
transform(person: Person): string {
return `${person.getName()} ${person.getFirstname()}`;
}
}
Analyse du Code #
Diagramme de Séquence : Obtention de l’Instance #
sequenceDiagram
participant Client1
participant Client2
participant Coordinateur
Note over Client1: Première demande
Client1->>Coordinateur: getInstance()
alt Instance n'existe pas
Coordinateur->>Coordinateur: new Coordinateur()
Note over Coordinateur: instance créée
end
Coordinateur-->>Client1: retourne instance
Note over Client2: Deuxième demande
Client2->>Coordinateur: getInstance()
alt Instance existe déjà
Note over Coordinateur: Retourne instance existante
end
Coordinateur-->>Client2: retourne MÊME instance
Note over Client1,Client2: client1 === client2
Le Constructeur privé : Le gardien #
// Constructeur privé
private constructor() {
super()
Coordinateur.instanciations++
}
Pourquoi privé ?
- Empêche : new Coordinateur() (erreur de compilation)
- Force l’utilisation de getInstance()
- Contrôle total sur l’instanciation
Tentative d’instanciation directe :
// ERREUR : Constructor of class 'Coordinateur' is private
const coord = new Coordinateur();
getInstance() : Le Point d’Accès Unique #
public static getInstance(): Coordinateur {
// Vérification de l'existence
if (Coordinateur.instance === undefined) {
// Création lazy (à la première demande)
Coordinateur.instance = new Coordinateur()
}
// Retourne toujours la même instance
return Coordinateur.instance
}
Caractéristiques :
- Thread-safe en JavaScript (single-threaded)
- Lazy initialization : création différée
- Point d’accès unique et contrôlé
Le compteur d’instanciations : Déboggage #
private static instanciations: number = 0
public static getInstanciationCount(): number {
return Coordinateur.instanciations
}
Utilité :
- Vérifier que le constructeur n’est appelé qu’une fois
- Tracer les tentatives d’instanciation
- Tests unitaires : valider le comportement Singleton
Utilisation pratique #
Exemple complet #
// main.ts
import { Coordinateur } from './coordinateur';
import { LastNameFirstStrategy } from './strategies/last-name-first-strategy';
// Obtenir l'instance unique
const coord1 = Coordinateur.getInstance();
// Configurer le coordinateur
coord1.setFirstname('Jean');
coord1.setName('Dupont');
coord1.setEmail('jean.dupont@example.com');
coord1.birthdate = new Date('1980-05-15');
coord1.gender = 'M';
// Dire bonjour
coord1.sayHello();
// Output : "Bonjour, je suis Jean Dupont, le coordinateur unique !"
// Obtenir à nouveau l'instance (même objet)
const coord2 = Coordinateur.getInstance();
console.log(coord1 === coord2); // true
console.log(coord2.getEmail()); // "jean.dupont@example.com"
// Changer la stratégie de formatage
coord2.setStrategy(new LastNameFirstStrategy());
console.log(coord2.getDisplayName()); // "Dupont Jean"
// Vérifier le nombre d'instanciations
console.log(Coordinateur.getInstanciationCount()); // 1
Tests Unitaires
typescript// coordinateur.test.ts
import { describe, it, expect, beforeEach } from 'vitest';
import { Coordinateur } from './coordinateur';
describe('Coordinateur Singleton', () => {
it('devrait retourner la même instance', () => {
const coord1 = Coordinateur.getInstance();
const coord2 = Coordinateur.getInstance();
expect(coord1).toBe(coord2);
});
it('ne devrait instancier qu\'une seule fois', () => {
const coord = Coordinateur.getInstance();
expect(Coordinateur.getInstanciationCount()).toBe(1);
});
it('devrait partager l\'état entre les références', () => {
const coord1 = Coordinateur.getInstance();
coord1.setFirstname('Alice');
const coord2 = Coordinateur.getInstance();
expect(coord2.getFirstname()).toBe('Alice');
});
it('ne devrait pas permettre new Coordinateur()', () => {
// TypeScript compile-time check
// @ts-expect-error - Constructor is private
expect(() => new Coordinateur()).toThrow();
});
});
Avantages et inconvénients #
Avantages #
| Avantage | Description | Exemple |
|---|---|---|
| Instance unique garantie | Impossible de créer plusieurs instances | Un seul coordinateur |
| Accès global | Accessible depuis n’importe où | Coordinateur.getInstance() |
| Contrôle strict | Initialisation maîtrisée (Lazy loading) | |
| Économie de ressources | Une seule instance en mémoire | Optimisation mémoire |
| État partagé | Toutes les parties partagent les mêmes données | Configuration globale |
Inconvénients #
| Inconvénient | Description | Impact |
|---|---|---|
| État global | Variable globale déguisée | Couplage fort |
| Tests difficiles | État persistant entre tests | Besoin de reset |
| Violation SRP | Gère instanciation + logique métier | Responsabilités multiples |
| Concurrence | Problèmes en multi-threading | Non applicable en JS |
| Dépendances cachées | Difficile de tracer les dépendances | Maintenance complexe |
Quand utiliser le Singleton ? #
Utilisez le Singleton pour :
// Configuration globale
class AppConfig {
private static instance: AppConfig;
private config: Record<string, any> = {};
private constructor() {
// Charger la config depuis un fichier
}
public static getInstance(): AppConfig {
if (!AppConfig.instance) {
AppConfig.instance = new AppConfig();
}
return AppConfig.instance;
}
}
// Logger global
class Logger {
private static instance: Logger;
private constructor() {}
public static getInstance(): Logger {
if (!Logger.instance) {
Logger.instance = new Logger();
}
return Logger.instance;
}
public log(message: string): void {
console.log(`[${new Date().toISOString()}] ${message}`);
}
}
// Connection unique à une base de données
class DatabaseConnection {
private static instance: DatabaseConnection;
private connection: any;
private constructor() {
// Établir la connexion
}
public static getInstance(): DatabaseConnection {
if (!DatabaseConnection.instance) {
DatabaseConnection.instance = new DatabaseConnection();
}
return DatabaseConnection.instance;
}
}
N’utilisez PAS le Singleton pour :
// Objets métier simples
class User {
// Pas besoin de Singleton
}
// Services avec état modifiable fréquent
class ShoppingCart {
// Chaque utilisateur a son propre panier
}
// Objets facilement mockables pour les tests
class ApiClient {
// Mieux vaut l'injecter en dépendance
}
Variantes et alternatives #
Singleton Eager (chargement immédiat) #
export class EagerCoordinateur extends Person {
// Instance créée dès le chargement de la classe
private static instance: EagerCoordinateur = new EagerCoordinateur();
private constructor() {
super();
}
public static getInstance(): EagerCoordinateur {
return EagerCoordinateur.instance;
}
public sayHello(): void {
console.log("Coordinateur eager chargé !");
}
}
Différences :
- Plus simple (pas de vérification)
- Chargement immédiat (même si jamais utilisé)
- Thread-safe par défaut (si applicable)
Singleton avec méthode de réinitialisation (Tests) #
export class TestableCoordinateur extends Person {
private static instance: TestableCoordinateur | undefined;
private constructor() {
super();
}
public static getInstance(): TestableCoordinateur {
if (!TestableCoordinateur.instance) {
TestableCoordinateur.instance = new TestableCoordinateur();
}
return TestableCoordinateur.instance;
}
// À utiliser UNIQUEMENT dans les tests
public static resetInstance(): void {
TestableCoordinateur.instance = undefined;
}
public sayHello(): void {
console.log("Coordinateur testable !");
}
}
// Dans les tests
afterEach(() => {
TestableCoordinateur.resetInstance();
});
Module Singleton (Pattern ES6) #
En JavaScript/TypeScript, les modules sont déjà des singletons :
// coordinateur-module.ts
class CoordinateurClass extends Person {
constructor() {
super();
}
public sayHello(): void {
console.log("Coordinateur via module !");
}
}
// Export d'une instance unique
export const coordinateur = new CoordinateurClass();
// Utilisation
import { coordinateur } from './coordinateur-module';
coordinateur.setFirstname('Marie');
coordinateur.sayHello();
Avantages :
- Plus simple (pas de getInstance())
- Idiomatique en JavaScript
- Facile à mocker (export nommé)
Injection de dépendances (alternative moderne) #
// Container IoC (ex: InversifyJS, TSyringe)
import { injectable, singleton } from 'tsyringe';
@singleton()
@injectable()
export class CoordinateurService extends Person {
constructor() {
super();
}
public sayHello(): void {
console.log("Coordinateur via DI !");
}
}
// Utilisation
import { container } from 'tsyringe';
const coord1 = container.resolve(CoordinateurService);
const coord2 = container.resolve(CoordinateurService);
console.log(coord1 === coord2); // true
Avantages :
- Testable (injection de mocks)
- Découplage total
- Gestion du cycle de vie
Bonnes pratiques #
Éviter l’état global excessif #
// MAUVAIS : Trop de logique métier dans le Singleton
class BadCoordinateur {
private static instance: BadCoordinateur;
public tasks: Task[] = [];
public team: Member[] = [];
public projects: Project[] = [];
// ... 50 propriétés
// Des milliers de lignes de logique métier
}
// BON : Singleton léger qui délègue
class GoodCoordinateur {
private static instance: GoodCoordinateur;
constructor(
private taskManager: TaskManager,
private teamManager: TeamManager,
private projectManager: ProjectManager
) {}
}
Rendre le Singleton testable #
// Interface pour le coordinateur
export interface ICoordinateur {
sayHello(): void;
getDisplayName(): string;
}
// Implémentation Singleton
export class Coordinateur extends Person implements ICoordinateur {
private static instance: Coordinateur | undefined;
private constructor() {
super();
}
public static getInstance(): ICoordinateur {
if (!Coordinateur.instance) {
Coordinateur.instance = new Coordinateur();
}
return Coordinateur.instance;
}
public sayHello(): void {
console.log("Hello from real Coordinateur");
}
}
// Mock pour les tests
export class MockCoordinateur implements ICoordinateur {
public sayHello(): void {
console.log("Hello from mock");
}
public getDisplayName(): string {
return "Mock Coordinateur";
}
}
// Dans les tests
it('should work with mock', () => {
const mock = new MockCoordinateur();
const service = new SomeService(mock); // Injection
expect(service.doSomething()).toBe('expected');
});
Documentation claire #
/**
* Coordinateur - Singleton Pattern
*
* Représente le coordinateur unique de l'équipe.
* Une seule instance peut exister dans toute l'application.
*
* @example
* ```typescript
* const coord = Coordinateur.getInstance();
* coord.setFirstname('Alice');
* coord.sayHello();
* ```
*
* @remarks
* Ne jamais utiliser `new Coordinateur()` (constructeur privé)
* L'instance est partagée globalement
*
* @see Person - Classe de base
* @see NameStrategy - Stratégie de formatage du nom
*/
export class Coordinateur extends Person {
// ...
}
Logging et monitoring #
export class Coordinateur extends Person {
private static instance: Coordinateur | undefined;
private static instanciations: number = 0;
private static accessCount: number = 0;
private constructor() {
super();
Coordinateur.instanciations++;
console.log(`[Coordinateur] Instance créée (count: ${Coordinateur.instanciations})`);
}
public static getInstance(): Coordinateur {
Coordinateur.accessCount++;
if (!Coordinateur.instance) {
console.log('[Coordinateur] Lazy initialization...');
Coordinateur.instance = new Coordinateur();
}
if (Coordinateur.accessCount % 100 === 0) {
console.log(`[Coordinateur] Accédé ${Coordinateur.accessCount} fois`);
}
return Coordinateur.instance;
}
public static getStats(): { instanciations: number; accesses: number } {
return {
instanciations: Coordinateur.instanciations,
accesses: Coordinateur.accessCount
};
}
public sayHello(): void {
console.log(`Bonjour, je suis ${this.getDisplayName()}`);
}
}
Conclusion #
Récapitulatif #
Le Singleton est un pattern puissant mais à utiliser avec précaution :
| Avantages | Inconvénients |
|---|---|
| Instance unique garantie | État global |
| Accès global simplifié | Tests difficiles |
| Contrôle strict | Couplage fort |
| Économie de ressources | Violation SRP |
Quand l’utiliser ? #
Utilisez le Singleton pour :
- Configuration globale de l’application
- Logger centralisé
- Connection pool unique
- Cache partagé
- Gestionnaire de ressources unique (comme notre Coordinateur)
Évitez le Singleton pour :
- Objets métier standards
- Services facilement testables
- État fréquemment modifié
- Objets avec plusieurs contextes
Le Singleton est comme un assaisonnement : utilisé avec parcimonie, il rehausse le plat. Utilisé à outrance, il le gâche.
Le pattern Singleton a sa place dans votre boîte à outils, mais ne doit pas être votre premier réflexe. Dans notre exemple du Coordinateur, il est justifié car :
- Il représente une ressource unique par nature
- Son accès global est nécessaire
- Il simplifie l’architecture sans la compromettre
Mais restez vigilant : un Singleton mal utilisé peut transformer votre code en un cauchemar de maintenance.