Série « Design Patterns en Java » : 9 articles en 4 familles, chacun lisible seul.
- Créationnels : Singleton et Factory Method (1/2, cet article) · Abstract Factory et Builder (2/2)
- Structurels : Adapter et Decorator (1/2) · Facade et Proxy (2/2)
- Comportementaux, orchestration : Strategy et Observer (1/2) · Template Method et State (2/2)
- Comportementaux, communication : Command (1/3) · Chain of Responsibility et Mediator (2/3) · Iterator et Visitor (3/3)
Beaucoup de développeurs utilisent des design patterns sans les nommer. Le conteneur Spring joue le rôle d'une grande Factory, HttpRequest.newBuilder() et Locale.Builder sont des Builders, et Calendar.getInstance() ou NumberFormat.getCurrencyInstance() sont des méthodes de fabrique statiques. Reconnaître ces patterns dans le code que l'on lit donne un vocabulaire commun avec le reste de l'équipe.
Les design patterns sont des solutions éprouvées à des problèmes récurrents, cataloguées en 1994 par le Gang of Four (Gamma, Helm, Johnson, Vlissides) dans un recueil de 23 patterns. On les retrouve dans le JDK, dans Spring et dans Hibernate. Les comprendre aide à comprendre pourquoi ces frameworks sont conçus comme ils le sont.
Cette série présente 17 patterns du GoF, avec pour chacun un diagramme UML, une implémentation Java complète, ses pièges et les cas où il vaut mieux s'en passer. Les patterns créationnels décrivent comment fabriquer les objets. Cette première partie traite Singleton et Factory Method ; la seconde traite Abstract Factory et Builder. Le cinquième pattern créationnel du GoF, Prototype (créer un objet en copiant un exemplaire existant), n'est pas traité.
Conventions de la série. Le code est illustratif et simplifié : les classes non définies (SmsClient, NotificationException…) sont supposées exister. Les noms suivent les conventions Java (classes en UpperCamelCase, méthodes en lowerCamelCase, interfaces sans préfixe I) et portent le rôle du pattern en suffixe (Factory, Builder, Adapter, Decorator, Facade, Proxy, Strategy, Listener, State, Command, Handler, Mediator). Les diagrammes UML reprennent exactement les noms du code. Les exemples ont été compilés et exécutés avec le JDK 21 et Spring Boot 3.5 ; les versions minimales de Java sont indiquées quand une fonctionnalité en dépend.
Singleton : une instance unique, à manier avec précaution
Le problème
Certaines classes ne doivent exister qu'en un seul exemplaire dans toute l'application : un pool de connexions, un gestionnaire de configuration, un cache applicatif. Créer plusieurs instances de ces classes gaspille des ressources et introduit des incohérences, par exemple deux caches avec des données différentes ou deux pools qui ouvrent chacun 50 connexions.
Le diagramme UML
L'implémentation naïve (et pourquoi elle pose problème)
public class ConfigurationManager {
private static ConfigurationManager instance;
private final Map<String, String> properties = new HashMap<>();
private ConfigurationManager() {
// Chargement de la configuration
loadProperties();
}
public static ConfigurationManager getInstance() {
if (instance == null) {
instance = new ConfigurationManager();
}
return instance;
}
private void loadProperties() {
// Charger depuis un fichier, une base, etc.
}
public String getProperty(String key) {
return properties.get(key);
}
}
Cette implémentation fonctionne en mono-thread. Deux threads qui appellent getInstance() simultanément alors que instance vaut null peuvent chacun créer une instance différente. Sur un serveur web dont le pool de threads traite des centaines de requêtes en parallèle (200 threads par défaut avec Tomcat), le risque est réel.
La solution thread-safe : le double-checked locking
public class ConfigurationManager {
private static volatile ConfigurationManager instance;
private final Map<String, String> properties;
private ConfigurationManager() {
this.properties = loadProperties();
}
public static ConfigurationManager getInstance() {
if (instance == null) { // 1er check (sans verrou)
synchronized (ConfigurationManager.class) {
if (instance == null) { // 2e check (avec verrou)
instance = new ConfigurationManager();
}
}
}
return instance;
}
private Map<String, String> loadProperties() {
Map<String, String> props = new HashMap<>();
// Chargement...
return Collections.unmodifiableMap(props);
}
public String getProperty(String key) {
return properties.get(key);
}
public String getProperty(String key, String defaultValue) {
return properties.getOrDefault(key, defaultValue);
}
}
Le premier test lit instance sans verrou, donc sans relation happens-before avec l'écriture faite par un autre thread. Sans volatile, un thread peut observer une référence non nulle alors que les écritures du constructeur ne lui sont pas encore visibles : il manipule alors un objet partiellement initialisé. Le modèle mémoire Java offre des garanties particulières pour les champs final (ici properties), mais pas pour les autres champs de la classe. Le mot-clé volatile établit la relation happens-before entre l'écriture de la référence et sa lecture : tout thread qui voit la référence voit aussi l'objet entièrement construit. Il s'agit donc de garantir la publication sûre de l'objet, et pas seulement d'empêcher un réordonnancement d'instructions.
Une alternative plus simple évite à la fois le verrou et le volatile, grâce à l'initialisation paresseuse des classes par la JVM (idiome du holder) :
public class ConfigurationManager {
private ConfigurationManager() { }
private static final class Holder {
private static final ConfigurationManager INSTANCE = new ConfigurationManager();
}
public static ConfigurationManager getInstance() {
return Holder.INSTANCE; // la classe Holder n'est chargée qu'au premier appel
}
}
La solution moderne : l'enum Singleton (recommandée par Joshua Bloch)
public enum DatabaseConnectionPool {
INSTANCE;
private final HikariDataSource dataSource;
DatabaseConnectionPool() {
HikariConfig config = new HikariConfig();
config.setJdbcUrl(System.getenv("DB_URL")); // ex. jdbc:postgresql://localhost:5432/myapp
config.setUsername(System.getenv("DB_USERNAME"));
config.setPassword(System.getenv("DB_PASSWORD"));
config.setMaximumPoolSize(20);
config.setMinimumIdle(5);
config.setConnectionTimeout(30000);
this.dataSource = new HikariDataSource(config);
}
public Connection getConnection() throws SQLException {
return dataSource.getConnection();
}
public void shutdown() {
if (!dataSource.isClosed()) {
dataSource.close();
}
}
}
// Utilisation
Connection conn = DatabaseConnectionPool.INSTANCE.getConnection();
L'enum Singleton est thread-safe par construction (la JVM initialise la classe une seule fois, sous verrou), résistante à la sérialisation (la désérialisation ne crée pas de nouvelle instance) et protégée contre la réflexion (Java interdit d'instancier une enum par réflexion). C'est la recommandation de Effective Java de Joshua Bloch quand on a réellement besoin d'un Singleton hors d'un conteneur d'injection.
Elle a ses limites : l'initialisation a lieu au premier usage de l'enum, lorsque la JVM initialise la classe (une erreur de configuration produit une ExceptionInInitializerError, puis des NoClassDefFoundError aux appels suivants), l'enum ne peut pas étendre une autre classe, et la dépendance globale reste difficile à remplacer dans les tests.
Singleton et Spring : pourquoi on n'écrit presque jamais de Singleton en Spring
Dans une application Spring, les beans ont par défaut la portée singleton (@Scope("singleton")). Quand on écrit :
@Service
public class UserService {
// Spring ne crée qu'une instance de ce bean dans le conteneur, injectée partout
}
Spring gère le cycle de vie, la création unique et l'injection. Il faut toutefois être précis : la portée singleton de Spring signifie une instance par bean et par conteneur (ApplicationContext), pas une instance par JVM. Si une application démarre deux contextes, ou si un test en crée un nouveau, chacun possède sa propre instance. Le pattern GoF garantit au contraire l'unicité au niveau de la classe, quel que soit le contexte.
Écrire un Singleton « à la main » dans une application Spring revient à contourner le conteneur IoC, ce qui est un code smell. Les cas justifiés sont rares : des utilitaires purs sans dépendances Spring, ou du code d'un module qui ne dépend pas de Spring.
Quand éviter le Singleton
Le Singleton est souvent un choix de facilité qui cache un problème d'architecture. Un singleton qui maintient un état mutable est une variable globale déguisée. Il rend les tests unitaires pénibles (il faut réinitialiser l'état entre chaque test), le code difficile à paralléliser et les dépendances invisibles.
Factory Method : déléguer la création aux sous-classes
Le problème
Imaginons un système de notification qui doit envoyer des messages par e-mail, SMS ou push selon le contexte. Le code naïf utilise des if/else :
// Simple fabrique : correcte, mais à modifier à chaque nouveau type
public Notification createNotification(String type) {
if ("email".equals(type)) return new EmailNotification();
else if ("sms".equals(type)) return new SmsNotification();
else if ("push".equals(type)) return new PushNotification();
else throw new IllegalArgumentException("Type inconnu : " + type);
}
Cette méthode est une simple fabrique (simple factory), un idiome courant qui n'est pas le Factory Method du GoF. Elle fonctionne bien tant que la liste des types est stable. Son inconvénient est que chaque nouveau type oblige à modifier la méthode, ce qui va à l'encontre du principe Open/Closed. Avec le Factory Method, la méthode de création est déclarée dans une classe abstraite et chaque sous-classe décide quelle instance créer.
Le diagramme UML
L'implémentation complète
L'interface produit définit le contrat de chaque notification :
public interface Notification {
void send(String recipient, String message);
String getType();
default boolean supports(String channel) {
return getType().equalsIgnoreCase(channel);
}
}
Les implémentations concrètes :
public class EmailNotification implements Notification {
private final JavaMailSender mailSender;
public EmailNotification(JavaMailSender mailSender) {
this.mailSender = mailSender;
}
@Override
public void send(String recipient, String message) {
SimpleMailMessage mail = new SimpleMailMessage();
mail.setTo(recipient);
mail.setSubject("Notification");
mail.setText(message);
mailSender.send(mail);
}
@Override
public String getType() {
return "EMAIL";
}
}
public class SmsNotification implements Notification {
private final SmsClient smsClient;
public SmsNotification(SmsClient smsClient) {
this.smsClient = smsClient;
}
@Override
public void send(String recipient, String message) {
smsClient.sendSms(recipient, message);
}
@Override
public String getType() {
return "SMS";
}
}
public class PushNotification implements Notification {
private final FirebaseMessaging firebaseMessaging;
public PushNotification(FirebaseMessaging firebaseMessaging) {
this.firebaseMessaging = firebaseMessaging;
}
@Override
public void send(String recipient, String message) {
// Nom complet : la classe Notification de Firebase entre en conflit avec la nôtre
Message fcmMessage = Message.builder()
.setToken(recipient)
.setNotification(
com.google.firebase.messaging.Notification.builder()
.setTitle("Notification")
.setBody(message)
.build()
)
.build();
try {
firebaseMessaging.send(fcmMessage);
} catch (FirebaseMessagingException e) {
throw new NotificationException("Échec de la notification push", e);
}
}
@Override
public String getType() {
return "PUSH";
}
}
La classe créatrice abstraite, dont la méthode sendNotification() est un Template Method qui s'appuie sur le Factory Method :
public abstract class NotificationFactory {
private static final Logger log = LoggerFactory.getLogger(NotificationFactory.class);
/**
* Factory Method : chaque sous-classe décide quel type créer.
*/
public abstract Notification createNotification();
/**
* Template Method qui utilise la Factory Method.
* Le processus est identique, seul le type de notification change.
*/
public void sendNotification(String recipient, String message) {
Notification notification = createNotification();
validateRecipient(recipient, notification.getType());
notification.send(recipient, message);
// Le destinataire n'est pas journalisé : adresse ou numéro sont des données personnelles
log.info("Notification {} envoyée", notification.getType());
}
private void validateRecipient(String recipient, String type) {
if (recipient == null || recipient.isBlank()) {
throw new IllegalArgumentException("Destinataire requis pour " + type);
}
}
}
Les factories concrètes :
public class EmailNotificationFactory extends NotificationFactory {
private final JavaMailSender mailSender;
public EmailNotificationFactory(JavaMailSender mailSender) {
this.mailSender = mailSender;
}
@Override
public Notification createNotification() {
return new EmailNotification(mailSender);
}
}
public class SmsNotificationFactory extends NotificationFactory {
private final SmsClient smsClient;
public SmsNotificationFactory(SmsClient smsClient) {
this.smsClient = smsClient;
}
@Override
public Notification createNotification() {
return new SmsNotification(smsClient);
}
}
public class PushNotificationFactory extends NotificationFactory {
private final FirebaseMessaging firebaseMessaging;
public PushNotificationFactory(FirebaseMessaging firebaseMessaging) {
this.firebaseMessaging = firebaseMessaging;
}
@Override
public Notification createNotification() {
return new PushNotification(firebaseMessaging);
}
}
Les fabriques dans le JDK et dans Spring
On retrouve la logique du Factory Method à plusieurs endroits, avec des variantes :
Collection.iterator(): chaque collection concrète décide quelIteratorcréer. C'est le Factory Method au sens du GoF.Calendar.getInstance()etNumberFormat.getCurrencyInstance(): des méthodes de fabrique statiques qui choisissent l'implémentation selon la locale. Elles relèvent de la simple fabrique plutôt que de l'héritage.BeanFactory.getBean(): le conteneur Spring crée et fournit les beans selon leur définition.DriverManager.getConnection(): délègue au driver JDBC concret enregistré.
En Spring, on peut simplifier le pattern avec un registre :
@Component
public class NotificationRegistry {
private final Map<String, Notification> notifications;
public NotificationRegistry(List<Notification> notificationList) {
this.notifications = notificationList.stream()
.collect(Collectors.toMap(
n -> n.getType().toUpperCase(Locale.ROOT),
Function.identity(),
(first, second) -> {
throw new IllegalStateException(
"Deux notifications déclarent le même type : " + first.getType());
}
));
}
public Notification getNotification(String type) {
Notification notification = notifications.get(type.toUpperCase(Locale.ROOT));
if (notification == null) {
throw new IllegalArgumentException("Type de notification inconnu : " + type);
}
return notification;
}
public Set<String> getAvailableTypes() {
return Collections.unmodifiableSet(notifications.keySet());
}
}
Chaque implémentation (EmailNotification, SmsNotification, PushNotification) est alors annotée @Component, et Spring injecte toutes les implémentations de Notification via List<Notification>. Le registre les indexe par type. Ajouter un canal revient à créer une nouvelle classe @Component qui implémente Notification, sans modifier le code existant, ce qui respecte l'Open/Closed Principle.
Ce registre appelle deux remarques. D'abord, Collectors.toMap sans fonction de fusion lève une IllegalStateException (« Duplicate key ») dès que deux notifications déclarent le même type. Cet échec au démarrage est souhaitable, et la fonction de fusion ci-dessus le rend explicite en nommant le type fautif. Ensuite, toUpperCase(Locale.ROOT) évite les surprises liées à la locale par défaut de la machine (le « i » turc, par exemple).
Ce qu'il faut retenir
| Pattern | Quand l'utiliser | Équivalent Spring |
|---|---|---|
| Singleton | Une seule instance nécessaire, hors conteneur Spring | @Scope("singleton") (défaut, une instance par conteneur) |
| Factory Method | Création polymorphe, un type à la fois | FactoryBean<T>, méthodes @Bean |
La partie 2 traite les deux patterns qui restent : Abstract Factory, pour créer des familles d'objets cohérentes, et Builder, pour construire pas à pas des objets complexes.
Partie suivante : Abstract Factory et Builder.
Série « Design Patterns en Java » : 9 articles en 4 familles, chacun lisible seul.
- Créationnels : Singleton et Factory Method (1/2, cet article) · Abstract Factory et Builder (2/2)
- Structurels : Adapter et Decorator (1/2) · Facade et Proxy (2/2)
- Comportementaux, orchestration : Strategy et Observer (1/2) · Template Method et State (2/2)
- Comportementaux, communication : Command (1/3) · Chain of Responsibility et Mediator (2/3) · Iterator et Visitor (3/3)