Dans cet article, Arnaud Becheler, formateur C++ et consultant en architectures logicielles, examine pourquoi le pattern Singleton, malgré son apparente simplicité, se transforme souvent en piège pour la maintenabilité : dépendances cachées, tests impossibles et problèmes de concurrence. À partir d’une expérience sur un projet legacy truffé de Singletons, il expose les pièges courants et propose des alternatives pratiques (injection de dépendances, composition root, factories).
Seulement si vous en abusez.
J’ai un jour rejoint un projet de dispositif médical dont la base de code était truffée de Singletons. Le code était legacy, hérité de plusieurs années de développement, et complètement non testé. Quand j’ai essayé de comprendre et d’améliorer le système, j’ai découvert que de multiples Singletons interagissaient partout dans le code. Des classes avec des interfaces en apparence simples cachaient dans leurs fichiers d’implémentation des appels à cinq ou six Singletons globaux différents.
Les tests étaient impossibles. Chaque tentative d’isoler un composant pour le tester embarquait tout le système avec lui. Je me heurtais à des problèmes de synchronisation et de concurrence presque impossibles à déboguer. Les Singletons avaient verrouillé toute la base de code dans un bloc monolithique et fragile.
Mes collègues m’ont demandé de partager ce que j’avais compris sur les raisons de ce désastre et sur la façon de l’éviter. Cet article est né de cette présentation de partage de connaissances. L’expérience a été douloureuse. Je savais depuis ma thèse que je devais me méfier des fausses promesses des Singletons, mais je n’avais jamais vu à quel point cela pouvait détruire un projet. C’est ce qui m’a motivé à écrire cet article. Analysons ce choix de conception cancéreux.
Qu’est-ce que le pattern Singleton ?
Le pattern Singleton garantit qu’une classe ne peut avoir qu’une seule instance pendant toute la durée de vie du programme et fournit un accès global à cette instance. La classe contrôle sa propre création via un constructeur privé et une méthode statique.
Voici à quoi cela ressemble en C++ :
class DatabaseConnection {
private:
DatabaseConnection() { /* initialize */ }
public:
static DatabaseConnection& instance() {
static DatabaseConnection instance;
return instance;
}
// Delete copy and move operations
DatabaseConnection(const DatabaseConnection&) = delete;
DatabaseConnection& operator=(const DatabaseConnection&) = delete;
};
// Usage
DatabaseConnection::instance().query("SELECT * FROM users");
Un détail important : seule la création de l’instance est thread-safe depuis C++11. Plusieurs threads peuvent appeler instance() en même temps sans problème. Mais l’accès aux membres ? Ça, il faut toujours le synchroniser.
Pourquoi Singleton devient un antipattern
Une variable globale déguisée
Le Singleton a l’air propre et bien encapsulé. Mais en réalité ? C’est juste une variable globale avec des étapes en plus. Regardez :
class UserService {
public:
void createUser(const std::string& name) {
// Direct dependency on concrete Singleton
DatabaseConnection::instance().insert(name);
}
};
La classe UserService a une dépendance cachée. En regardant son interface, rien n’indique qu’elle a besoin d’une DatabaseConnection. Il faut aller fouiller dans l’implémentation pour le découvrir.
Cela viole le Dependency Inversion Principle, parce que vous dépendez d’une classe concrète, pas d’une abstraction. Ça viole aussi le Single Responsibility Principle, parce que la classe gère maintenant la création d’un utilisateur et la recherche de la base de données globale.
Les dépendances cachées rendent votre code plus difficile à comprendre, à tester et à faire évoluer. Vous ne pouvez pas voir ce dont une classe a vraiment besoin juste en la regardant.
Couplage fort à des implémentations concrètes
Le pattern Singleton verrouille votre code sur une implémentation précise. Voici ce qui se passe quand vous devez changer quelque chose :
class OrderProcessor {
public:
void processOrder(int orderId) {
DatabaseConnection::instance().update(orderId);
}
};
Supposons que vous deviez supporter plusieurs types de bases de données parce que le client le demande, ou basculer sur une base de test en développement, ou utiliser une base mock pour les tests unitaires. Avec l’approche Singleton, vous êtes coincé. OrderProcessor est câblé en dur pour utiliser DatabaseConnection, et il n’y a aucun moyen de substituer une autre implémentation sans modifier le code source. Ne vous laissez pas tromper par la simplicité apparente de cet extrait : dans une vraie base de code, ce changement peut être extrêmement coûteux.
Cela viole l’Open/Closed Principle. Vos classes devraient être ouvertes à l’extension mais fermées à la modification. Besoin de changer la base de données ? Avec des Singletons, vous devez modifier chaque classe qui l’utilise.
Impossible de le substituer
Ce couplage fort crée un autre problème : vous ne pouvez pas remplacer le Singleton par autre chose. Il n’existe tout simplement aucune abstraction à substituer. Cela casse le Liskov Substitution Principle.
Les tests deviennent douloureux. Comment tester que OrderProcessor fonctionne sans vraie base de données ? Avec des Singletons, vous ne pouvez pas. Pas de mocks, pas de stubs. Chaque test utilise la vraie chose. Les tests deviennent lents, fragiles, et dépendants les uns des autres.
Et ce n’est pas seulement pour les tests. Vous ne pouvez pas avoir de configurations distinctes pour le développement, la pré-production et la production. Vous ne pouvez pas expérimenter avec différentes implémentations. Vous ne pouvez pas optimiser pour certains cas spécifiques. Le Singleton vous enferme.
Pourquoi les tests deviennent un cauchemar
Les tests révèlent à quel point les Singletons peuvent faire des dégâts.
Vous ne pouvez pas mocker les dépendances
Prenons un service qui utilise un Singleton pour la connexion à la base de données :
// How do you test this without a real database?
TEST(UserServiceTest, CreateUser) {
UserService service;
service.createUser("Alice");
// DatabaseConnection::instance() is the REAL database!
// Cannot inject a mock
}
Sans mocks, vos tests unitaires ont besoin d’une vraie base de données. Cela rend les tests lents (les opérations sur la base sont bien plus lentes qu’en mémoire), fragiles (problèmes réseau, base indisponible, configuration incorrecte cassent les tests), et coûteux (il faut une infrastructure de base de données rien que pour tester).
Les tests unitaires sont censés tester une seule unité. Les Singletons rendent cela impossible.
Les tests se polluent mutuellement
Des tests qui partagent une instance Singleton se polluent au niveau de l’état :
TEST(Test1, ModifiesDatabase) {
DatabaseConnection::instance().insert("test data");
// State persists in the Singleton...
}
TEST(Test2, ExpectsCleanState) {
// But the database still has data from Test1!
// Tests are now ORDER-DEPENDENT
}
Les frameworks de tests exécutent les tests dans un ordre aléatoire ou en parallèle pour débusquer les dépendances cachées. Avec des Singletons, ça ne marche plus. Les tests nécessitent un ordre précis. Chaque test doit nettoyer soigneusement. Une seule erreur et c’est toute la suite de tests qui se casse.
Et plus vous avez de tests, pire c’est. Traquer pourquoi un test ne plante que lorsqu’il est exécuté après un autre test précis ? C’est un enfer très particulier. Les Singletons transforment votre suite de tests, qui devrait être un filet de sécurité, en source de pannes aléatoires.
Problèmes de concurrence
Le C++ moderne rend la création de Singleton thread-safe. Mais ce n’est que la moitié de l’histoire. Les vrais problèmes apparaissent quand plusieurs threads utilisent les données du Singleton.
Voici un cache implémenté comme Singleton :
class Cache {
private:
std::map<std::string, std::string> data;
Cache() = default;
public:
static Cache& instance() {
static Cache cache;
return cache;
}
void set(const std::string& key, const std::string& value) {
data[key] = value; // NOT THREAD-SAFE!
}
};
La méthode instance() est thread-safe : C++11 garantit que les variables locales static sont initialisées une seule fois, même avec plusieurs threads. Mais la méthode set() ? Elle modifie data, qui est partagée par tous les threads. Sans synchronisation, des appels concurrents à set() peuvent corrompre la map. Crashs, comportement indéfini, tout le plaisir habituel.
Vous pourriez ajouter un mutex. D’accord, cela empêche la corruption. Mais vous créez alors un goulot d’étranglement global. Chaque thread attend le mutex. Les opérations parallèles deviennent séquentielles. À mesure que vous ajoutez des threads, le problème s’aggrave.
Et c’est pire avec plusieurs Singletons. Un logger Singleton utilise un Singleton de configuration, qui utilise un Singleton de système de fichiers. Vous vous retrouvez avec plusieurs verrous à acquérir dans le bon ordre pour éviter les deadlocks. Bon courage pour raisonner sur l’ordre des verrous à l’échelle de toute l’application.
Et les tests ? Comment écrire des tests déterministes pour la sûreté vis-à-vis des threads quand le Singleton persiste entre les exécutions ? Comment simuler de manière fiable des conditions de course ? L’état global partagé rend les tests multi-threads extrêmement difficiles.
Retours du terrain
Revenons à ce projet de dispositif médical. Je vais transformer un peu les problèmes pour préserver la confidentialité. Le système utilisait des Singletons partout : communication avec le dispositif, configuration, logging, stockage des données. Pris isolément, chacun de ces Singletons avait l’air raisonnable. Mais ensemble ? Un désastre complet.
Ce qui m’a vraiment frustré, c’est la simplicité trompeuse. Vous voyiez une classe avec un constructeur sans paramètres, quelques méthodes simples. Ça a l’air propre, non ? Faux. Il fallait aller dans l’implémentation pour découvrir les appels aux Singletons cachés à l’intérieur. L’interface mentait sur ce dont la classe avait réellement besoin.
Essayer d’instancier une simple classe de traitement de données ? Bam. Elle déclenchait le Singleton de communication avec l’appareil, qui s’attendait à trouver du vrai matériel. Les Singletons avaient verrouillé ensemble le traitement des données, la génération des données et l’UI dans un énorme bloc. Vous ne pouviez rien tester isolément. Pour tester un petit composant, il fallait mocker ou désactiver cinq ou six Singletons éparpillés dans le code. Et les threads ? Ils accédaient à différents Singletons dans des ordres aléatoires. L’UI se comportait de façon erratique. La matrice d’interactions était impossible à comprendre.
Point intéressant à l’ère de l’IA : quand vous demandez à une IA de vous aider sur du code avec des dépendances Singleton cachées, elle a le même problème que nous. Elle ne peut pas deviner à partir de l’interface que des Singletons sont appelés à l’intérieur. Elle doit lire toute l’implémentation, suivre les appels à travers plusieurs fichiers, ce qui fait exploser le contexte. Une question simple sur une interface de classe se transforme en enquête sur dix fichiers interconnectés. Si le code est difficile à comprendre pour nous, il l’est tout autant pour les outils d’IA.
Une erreur fréquente
Ce chaos vient en réalité d’un malentendu très simple. Beaucoup de développeurs utilisent le pattern Singleton à partir de cette idée fausse :
« Mon application n’a qu’UNE base de données, donc mon code doit avoir UNE instance. »
Cela mélange deux concepts différents : ce que vous avez dans votre déploiement et la manière dont vous écrivez votre code. Ce n’est pas parce qu’il n’y a qu’un seul serveur de base de données que votre code a besoin d’un objet global Singleton.
Réfléchissez-y. Votre application n’a qu’une seule connexion internet, mais vous ne créez pas partout un InternetConnection::instance(). Vous passez des objets réseau là où ils sont nécessaires. C’est la même chose pour les bases de données, la configuration, le logging et d’autres ressources qui sont uniques dans votre déploiement.
Le nombre de ressources que vous avez est un détail de déploiement, pas une contrainte d’architecture. Votre code devrait fonctionner de la même façon qu’il y ait une base de données ou dix, un fichier de configuration ou plusieurs. Le pattern Singleton pousse des détails de déploiement dans votre architecture. Cela rend le code rigide et difficile à tester.
De meilleures alternatives
Dependency Injection
Passez les dépendances via les constructeurs. Au lieu d’aller chercher un Singleton global, les classes reçoivent ce dont elles ont besoin depuis l’extérieur.
Beaucoup de développeurs se ruent par réflexe sur le polymorphisme runtime (fonctions virtuelles et héritage). Mais les appels virtuels ont un coût. Voici une autre approche utilisant des concepts : le compilateur vérifie vos types et fournit des erreurs claires, tout est inline, aucun coût au runtime. Parfois, vous avez vraiment besoin des fonctions virtuelles (stocker différents types dans des conteneurs, systèmes de plugins), mais pour la plupart des cas de dependency injection ? Non. C’est aussi une question de goût personnel – j’explore encore ce qui fonctionne le mieux selon les contextes.
#include <concepts>
// Define what a database connection must do
template<typename T>
concept DatabaseLike = requires(T db, const std::string& data) {
{ db.insert(data) } -> std::same_as<void>;
{ db.query(data) } -> std::same_as<void>;
};
// Implementations (no inheritance needed)
class DatabaseConnection {
public:
void insert(const std::string& data) { /* real work */ }
void query(const std::string& sql) { /* real work */ }
};
class MockDatabaseConnection {
public:
void insert(const std::string& data) { /* record call */ }
void query(const std::string& sql) { /* return test data */ }
};
// Service uses the concept
template<DatabaseLike DB>
class UserService {
private:
DB& db;
public:
explicit UserService(DB& database) : db(database) {}
void createUser(const std::string& name) {
db.insert(name);
}
};
// Production usage
DatabaseConnection realDb;
UserService service(realDb); // Type deduced!
// Testing
MockDatabaseConnection mockDb;
UserService testService(mockDb); // Type deduced!
Les dépendances sont explicites et visibles. Les tests sont simples. Vous pouvez changer d’implémentation facilement. Tout ça sans Singleton.
Gestion du scope et du cycle de vie
Pour des scénarios plus complexes, utilisez un objet « conteneur » qui gère la durée de vie des dépendances :
class Application {
private:
DatabaseConnection db;
Cache cache;
Logger logger;
public:
Application()
: db(loadConfig("db")),
cache(loadConfig("cache")),
logger(loadConfig("logger")) {}
UserService createUserService() {
return UserService(db, logger);
}
OrderProcessor createOrderProcessor() {
return OrderProcessor(db, cache, logger);
}
};
La classe Application est votre composition root. Elle crée et gère les dépendances partagées. Les services reçoivent leurs dépendances par injection, mais Application s’assure que les ressources coûteuses comme les connexions à la base de données sont créées une seule fois et réutilisées.
Vous obtenez les avantages des instances partagées (efficacité, configuration cohérente) sans état global. Chaque service a des dépendances claires, testables. Vous pouvez facilement créer différentes configurations d’Application pour les tests, le développement et la production.
Factory et Registry patterns
Vous avez besoin d’une création centralisée mais voulez éviter l’état global ? Les factories et registries sont un compromis :
class ServiceRegistry {
private:
std::map<std::string, std::shared_ptr<IService>> services;
public:
void registerService(const std::string& name,
std::shared_ptr<IService> service) {
services[name] = service;
}
std::shared_ptr<IService> getService(const std::string& name) {
return services.at(name);
}
};
// Usage
ServiceRegistry registry;
registry.registerService("database", std::make_shared<DatabaseService>());
registry.registerService("cache", std::make_shared<CacheService>());
// Components receive the registry via dependency injection
class Application {
private:
ServiceRegistry& registry;
public:
explicit Application(ServiceRegistry& reg) : registry(reg) {}
void run() {
auto db = registry.getService("database");
// Use the database...
}
};
Le registry lui-même est passé par dependency injection, pas accédé globalement. Vous gardez une gestion centralisée des services, mais vous préservez testabilité et flexibilité.
Quand peut-on utiliser Singleton ?
Après tous ces problèmes, vous vous demandez peut-être si le Singleton est jamais acceptable. Réponse : rarement, et avec beaucoup de précautions.
Le pattern peut fonctionner quand vous avez une ressource vraiment unique, fondamentalement globale, et que les bénéfices surpassent clairement les coûts en termes de tests et de maintenance. Mais même dans ce cas, demandez-vous si la dependency injection ne ferait pas aussi bien l’affaire.
Cas possibles : systèmes de logging (même si je préfère passer une référence de logger), interfaces matérielles vers des dispositifs vraiment uniques (mais j’envisagerais quand même d’abstraire l’interface), ou caches ultra critiques en performance où les alternatives seraient trop coûteuses.
Mais réfléchissez bien aux compromis. Le faible gain de performance vaut-il une testabilité dégradée ? Pourriez-vous utiliser la dependency injection avec une seule instance au niveau de l’application ? Serait-il utile de pouvoir substituer les implémentations pour les tests ou pour différents environnements ?
Dans la plupart des cas, ce qui semble être un bon candidat Singleton peut être mieux résolu par de la dependency injection et une bonne gestion du cycle de vie des objets. Le Singleton est séduisant parce qu’il est simple. Mais cette simplicité a un coût sur la maintenabilité à long terme.
Conclusion
Le pattern Singleton paraît simple et élégant. Mais il crée des problèmes qui s’aggravent avec le temps. Ce qui commence comme un accès pratique à une ressource partagée devient un mélange de dépendances cachées, de code impossible à mocker et d’état global.
Le pattern reste populaire parce qu’il est facile. Quelques lignes de code suffisent pour avoir un accès global à n’importe quoi. Mais cette commodité est un piège. Les coûts apparaissent plus tard — quand vous avez besoin de tester, quand vous voulez réutiliser des composants dans d’autres contextes, quand vous devez changer une implémentation sans tout casser.
Dans la plupart des cas, la dependency injection combinée à une bonne gestion du cycle de vie est une meilleure approche. Elle rend les dépendances explicites, permet de tester avec des mocks, respecte les principes SOLID et produit un code plus facile à comprendre, modifier et maintenir.
Principaux problèmes avec Singleton
- Cache les dépendances derrière de l’état global, en violant le Dependency Inversion Principle et le Single Responsibility Principle
- Crée un couplage fort aux implémentations concrètes, rend le code rigide et viole l’Open/Closed Principle
- Rend les tests difficiles à cause de dépendances non mockables et d’un état global partagé
- Amplifie les problèmes de concurrence car l’état global partagé crée des goulots d’étranglement et des risques de deadlock
- Confond déploiement et architecture – le fait d’avoir une ressource unique ne signifie pas que vous avez besoin d’une seule instance dans le code
Meilleures approches
- Dependency injection : passer les dépendances explicitement via les constructeurs
- Gestion de la durée de vie : laisser une composition root gérer le cycle de vie des objets
- Factory et registry patterns : centraliser la logique de création sans état global
- Remettez-vous en question : avant d’utiliser Singleton, évaluez soigneusement si la commodité justifie le coût de maintenance
N’utilisez pas les patterns aveuglément. Ils viennent avec des compromis. Si vous ne pouvez pas donner une explication claire et complète de pourquoi vous voulez utiliser un outil, c’est que vous ne le comprenez pas (encore). Et il ne faudra pas longtemps avant que ce manque de compréhension se traduise par du code non testable, puis des bugs aléatoires, puis des deadlines manquées, puis des équipes frustrées et, au final, des clients mécontents.
Bon courage !
Aller plus loin
Vous pouvez aussi retrouver cet article en anglais sur son blog. Découvrez aussi ses formations en C++ comme Les Fondamentaux, La Maîtrise de C++ moderne de C++11 à C++23, ou encore comment Tester vos projets C++.
Arnaud BECHELER – Formateur C++

Arnaud Becheler, docteur en écologie évolutive et expert en C++, s’est forgé une réputation unique en associant ses compétences scientifiques à une expertise technique pointue.
Spécialisé dans la conception de modèles prédictifs et de simulations en C++, il a utilisé son expertise pour modéliser des dynamiques écologiques complexes.
Avec plus de 10 ans d’expérience, il intervient aujourd’hui en tant que consultant, offrant son savoir-faire en intelligence artificielle, machine learning et architectures logicielles.