Le Singleton occupe la première case du tableau périodique des design patterns. C'est aussi le plus court à expliquer : une classe, une seule instance, un point d'accès global. Sa version classique tient en dix lignes de Java.
Entre ces dix lignes et un service qui reçoit des requêtes simultanées en production, il y a un écart que les exemples de cours laissent de côté. Le même code qui marche dans un main de test peut créer plusieurs instances ou mélanger les données de deux utilisateurs.
La difficulté tient à la définition elle-même, qui mélange deux promesses. L'unicité de l'instance est utile dans beaucoup de cas. L'accès global, lui, crée une dépendance cachée que les frameworks modernes ont appris à éviter.
Cet article sépare les deux promesses, montre comment Java, Python, Spring, Symfony et Laravel tiennent la première, et détaille les pièges qui restent une fois l'unicité confiée à un conteneur. Il s'adresse aux personnes en reconversion, aux développeurs juniors et aux curieux qui croisent le mot sans savoir ce qu'il recouvre.
Le problème : dix lignes, cent instances
Voici la version qu'on trouve dans la plupart des tutoriels : un constructeur privé, un champ statique, une méthode qui crée l'objet au premier appel. Les exemples de cet article sont écrits pour Java 25, la version LTS en cours au moment où ces lignes sont écrites, et ont été exécutés sur un JDK 21 sans modification, puisqu'ils n'utilisent rien d'apparu après.
import java.util.concurrent.Executors;
import java.util.concurrent.atomic.AtomicInteger;
public class NaiveDemo {
static final class Configuration {
static final AtomicInteger creations = new AtomicInteger();
private static Configuration instance;
private Configuration() {
creations.incrementAndGet();
try {
Thread.sleep(50); // simule un chargement de fichier
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}
static Configuration getInstance() {
if (instance == null) {
instance = new Configuration();
}
return instance;
}
}
public static void main(String[] args) {
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
for (int i = 0; i < 100; i++) {
executor.submit(Configuration::getInstance);
}
}
System.out.println("Instances créées : " + Configuration.creations.get());
}
}
Sur trois exécutions, le programme affiche « Instances créées : 100 » à chaque fois. Les cent threads virtuels testent instance == null pendant que le premier constructeur est encore en train de dormir, et chacun crée son propre objet. Sans la pause de 50 millisecondes, le problème apparaît moins souvent, ce qui le rend plus dangereux : il passe les tests et se manifeste sous charge.
Le second défaut est moins visible. N'importe quelle classe peut appeler Configuration.getInstance() sans que sa signature le montre. Pour remplacer cette configuration par une version de test, il faut modifier le singleton lui-même ou ajouter une méthode de réinitialisation qui n'a rien à faire en production.
Le principe : séparer l'unicité de l'accès global
Le Gang of Four définit le Singleton par deux engagements : la classe n'a qu'une instance, et elle fournit un point d'accès global à cette instance. Le premier engagement répond à un besoin réel. Un pool de connexions ou un client HTTP configuré une fois n'ont aucune raison d'exister en plusieurs exemplaires.
Le second engagement pose le problème de couplage décrit plus haut. Les conteneurs d'injection de dépendances le remplacent par autre chose : l'objet reste unique, mais il arrive dans les classes qui en ont besoin par leur constructeur. La dépendance devient visible, et un test peut fournir une autre implémentation sans toucher au code. C'est l'application directe du principe d'inversion des dépendances décrit dans l'article sur les principes SOLID.
Les frameworks courants appliquent tous ce partage des rôles, avec des vocabulaires différents.
- Spring. La portée par défaut d'un bean s'appelle singleton : le conteneur crée une instance par définition de bean et la renvoie à chaque demande. La documentation précise que ce singleton diffère de celui du Gang of Four, qui impose une instance par chargeur de classes, alors que Spring garantit une instance par conteneur et par définition.
- Symfony. La documentation de Symfony 7.4 indique que tous les services du conteneur sont partagés par défaut : chaque récupération renvoie la même instance. Pour obtenir une nouvelle instance à chaque demande, on déclare le service avec
#[Autoconfigure(shared: false)]. - Laravel. La méthode
singleton()du conteneur enregistre une liaison résolue une seule fois, puis renvoie le même objet aux appels suivants. Laravel 13 propose aussi l'attribut#[Singleton]à poser directement sur la classe ou l'interface. - Python. Un module n'est exécuté qu'une fois par processus. La référence du langage décrit
sys.modulescomme le cache des modules déjà importés, consulté en premier à chaque import. Une instance créée au niveau d'un module est donc partagée par tous ceux qui l'importent.
Mon avis est tranché sur ce point : dans une application qui tourne sur un framework, écrire un Singleton à la main avec un champ statique est presque toujours une erreur. Le conteneur fait déjà le travail, avec la gestion du cycle de vie et la possibilité de substituer l'objet en test. La version manuelle reste utile hors framework, dans un script ou un outil en ligne de commande.
Unique, mais à quelle échelle ?
Le mot « unique » cache une question que les définitions abordent rarement : unique où ? Chaque mécanisme vu plus haut garantit l'unicité dans un périmètre précis, et ce périmètre est toujours plus petit que l'application entière.
Une instance créée au niveau d'un module Python existe une fois par processus. Un serveur d'application configuré avec quatre workers, par exemple pour un projet Django ou FastAPI, contient donc quatre instances, une par processus, qui ne se voient pas. Un cache en mémoire placé dans ce singleton produit quatre caches différents, et un utilisateur dont les requêtes tombent sur deux workers peut voir deux états différents.
Le même raisonnement s'applique à Java. Spring garantit une instance par conteneur, et une application déployée sur trois serveurs en possède donc au minimum trois. Dès que l'état doit être partagé au-delà d'un processus, le Singleton n'est plus le bon outil : la donnée doit vivre dans une base, dans Redis ou dans un autre service externe.
Avant d'écrire ou de déclarer un Singleton, deux questions suffisent à trancher la plupart des cas. L'objet a-t-il un état qui change pendant l'exécution ? Si oui, ce partage est-il souhaité entre toutes les requêtes et tous les utilisateurs du processus ? Un client HTTP ou un objet de configuration en lecture seule passent ces deux questions sans difficulté. Un panier ou un résultat de calcul propre à une requête échouent dès la première.
Exemples : trois façons correctes d'obtenir une instance unique
Java : l'énumération à un seul élément
Quand il faut un Singleton écrit à la main en Java, l'énumération à un seul élément est la forme la plus sûre. La JVM initialise les constantes d'une énumération une seule fois, au chargement de la classe, et cette initialisation est protégée contre les accès concurrents.
public enum Configuration {
INSTANCE;
private final String urlBase =
System.getenv().getOrDefault("API_URL", "https://api.exemple.fr");
public String urlBase() {
return urlBase;
}
}
Le même test que plus haut, cent threads virtuels qui accèdent à Configuration.INSTANCE, compte une seule création. Le champ statique et la vérification null disparaissent, le langage s'en charge. La limite de cette forme tient à sa rigidité, une énumération ne peut pas hériter d'une autre classe.
Python : l'instance au niveau du module
En Python 3.15, inutile de recopier les implémentations à base de métaclasse ou de __new__ qu'on trouve encore dans les tutoriels. Le module sert de conteneur.
# reglages.py
from dataclasses import dataclass
@dataclass(frozen=True)
class Reglages:
url_base: str
timeout: float
reglages = Reglages(url_base="https://api.exemple.fr", timeout=5.0)
Deux modules qui font from reglages import reglages reçoivent le même objet, et le test reglages is facturation.reglages renvoie True. Le code du module ne s'exécute qu'au premier import. Le paramètre frozen=True ajoute une protection utile : l'objet partagé ne peut pas être modifié par erreur depuis un autre module.
Spring : le bean sans état
Dans Spring, rien à écrire pour obtenir l'unicité : un @Service est un bean de portée singleton. Le travail porte sur ce qu'on met dedans. La documentation de Spring recommande la portée prototype pour les beans qui ont un état et la portée singleton pour les beans sans état. La formation Spring Boot : développer des API REST en Java travaille cette organisation sur une API complète, et Les fondamentaux de la programmation Java et POO pose les bases nécessaires avant.
Les pièges
Un état modifiable dans un singleton partagé
Un champ qui compte des visites, mémorise le dernier utilisateur ou accumule des résultats dans une liste devient partagé entre toutes les requêtes servies par l'application. Deux requêtes simultanées lisent et écrivent le même champ, avec les mêmes effets que dans la démonstration naïve. Les données d'un utilisateur peuvent même apparaître dans la réponse d'un autre.
@Service
public class CompteurVisites {
private int total; // partagé par toutes les requêtes
public int incrementer() {
return ++total; // lecture et écriture non atomiques
}
}
La correction dépend du besoin. Un compteur passe par un AtomicInteger ou par la base de données, et une donnée propre à l'utilisateur voyage en paramètre de méthode au lieu de vivre dans un champ.
Les workers qui ne s'arrêtent jamais
En PHP classique, chaque requête repart d'un processus neuf, et un service partagé disparaît à la fin de la réponse. Les workers longue durée changent cette règle. La documentation de Symfony Messenger rappelle que le même service est injecté dans tous les messages traités par un worker, avec son état interne conservé d'un message à l'autre. Symfony fournit ResetInterface pour remettre ce type de service à zéro entre deux messages.
Laravel Octane présente le même risque côté HTTP. Sa documentation explique qu'un singleton qui reçoit la requête dans son constructeur garde cette requête pour les appels suivants, avec des en-têtes et des données qui ne correspondent plus. La solution recommandée consiste à passer les données nécessaires en argument de méthode, ou à utiliser scoped(), dont les instances sont vidées à chaque nouvelle requête ou nouveau job.
Un objet à courte durée de vie injecté dans un singleton
Un singleton reçoit ses dépendances une seule fois, à sa création. Si l'une d'elles devait être recréée à chaque usage, le singleton garde quand même la première instance pour toute sa durée de vie. Spring le documente pour un bean prototype injecté dans un bean singleton, et Symfony le signale pour un service non partagé passé en argument de constructeur. Dans les deux cas, la documentation propose d'injecter un mécanisme d'accès, comme ObjectProvider chez Spring ou une closure de service chez Symfony, au lieu de l'objet lui-même.
Les tests qui dépendent de leur ordre
Un singleton écrit à la main garde son état d'un test à l'autre dans le même processus. Un test qui modifie la configuration influence le suivant, et la suite passe ou échoue selon l'ordre d'exécution. Avec un conteneur, chaque test peut construire son propre graphe d'objets, ce qui supprime le problème à la source.
Pour les projets Symfony, la formation Symfony 7 : initiation au framework PHP professionnel couvre le conteneur de services et la configuration de leur partage.
Questions fréquentes
Le Singleton est-il un anti-pattern ?
Quelle différence entre le singleton de Spring et celui du Gang of Four ?
Le double-checked locking est-il une bonne solution en Java ?
volatile, faute de quoi un thread peut voir un objet partiellement construit. Il reste plus long et plus fragile que l'énumération ou que le conteneur d'un framework, qui règlent la question sans code de synchronisation.Comment écrire un Singleton en Python ?
sys.modules, donc le code de création ne s'exécute qu'une fois par processus.