Aller au contenu principal

Singleton : garder une instance unique sans fabriquer un état global

Le Singleton promet deux choses : une seule instance et un point d'accès global. Cet article montre pourquoi la version naïve crée cent objets sous cent threads, comment Java, Python, Spring, Symfony et Laravel gardent l'unicité sans l'accès global, et les pièges qui apparaissent avec les workers longue durée.

Architecture logicielle ·
Adel LATIBI
Adel LATIBI

Le Briefing Dev - les ressources et actus de la semaine, droit dans ta boîte chaque vendredi gratuitement.

En vous inscrivant, vous acceptez de recevoir notre newsletter. Désinscription possible à tout moment.

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.

NaiveDemo.java, Java 25
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.modules comme 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.

Configuration.java, Java 25
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, Python 3.15
# 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.

Spring Framework 7, à ne pas reproduire
@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 ?

Le reproche vise surtout l'accès global, qui cache les dépendances et complique les tests. L'unicité d'une instance reste une bonne idée pour un pool de connexions ou un cache, et tous les conteneurs d'injection l'utilisent par défaut. Ce qui pose problème, c'est le champ statique appelé depuis n'importe où.

Quelle différence entre le singleton de Spring et celui du Gang of Four ?

Le Gang of Four garantit une instance par classe, et plus précisément par chargeur de classes en Java. Spring garantit une instance par conteneur et par définition de bean : deux définitions de la même classe donnent deux instances, et deux contextes Spring en donnent deux aussi.

Le double-checked locking est-il une bonne solution en Java ?

Il fonctionne à condition de déclarer le champ 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 ?

Le plus simple est de créer l'instance au niveau d'un module et de l'importer là où elle sert. Python garde chaque module en cache dans sys.modules, donc le code de création ne s'exécute qu'une fois par processus.

Quelle différence entre un Singleton et une classe avec uniquement des méthodes statiques ?

Un Singleton reste un objet : il peut implémenter une interface et donc être remplacé par une autre implémentation en test. Une classe statique ne permet rien de tout ça, ses appels sont figés dans le code qui l'utilise.

Vous êtes expert ?

Partagez votre expertise sur notre blog

Tutoriel, retour d'expérience, analyse - publiez un article invité et gagnez en visibilité.

Écrire pour nous