Design Patterns Créationnels en Java (partie 1/2) : Singleton et Factory Method

Abdoul Kadri ASSOUMANE MAMANE
Abdoul Kadri ASSOUMANE MAMANE Auteur
9 min de lecture
504 vues

Écouter l'article

Série « Design Patterns en Java » : 9 articles en 4 familles, chacun lisible seul.


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

Diagramme de classes (diagramme 1)
Diagramme de classes (diagramme 1)

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

Diagramme de classes (diagramme 2)
Diagramme de classes (diagramme 2)

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 quel Iterator créer. C'est le Factory Method au sens du GoF.
  • Calendar.getInstance() et NumberFormat.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.

Partager cet article

Envie de ne rien rater ?

Recevez les nouveaux articles directement dans votre boîte mail. Pas de spam, uniquement du contenu technique.

Commentaires (0)

Laisser un commentaire

0 / 2000

Aucun commentaire pour le moment. Soyez le premier à réagir !

Lecture

Articles similaires

Envie d'en discuter ?

Une opportunité d'alternance, un projet ou une question technique : écrivez-moi, je réponds généralement sous 24 à 48 heures.