Programmation Java
Design patterns de création en Java : Singleton, Factory, Builder, Prototype
Intermédiaire9 min de lectureMis à jour
Cette page a été traduite de l'anglais avec l'aide de l'IA, puis vérifiée par rapport à l'original. Lire la version anglaise
Les patterns de création séparent la fabrication des objets de leur utilisation : c'est ce qui permet de changer d'implémentation, de garantir des invariants et de regrouper la logique de construction en un seul endroit. Ce guide présente Singleton, Factory Method, Abstract Factory, Builder et Prototype avec du code Java 21 idiomatique, montre où chacun apparaît dans le JDK et dans Spring, et dit franchement lesquels sont surutilisés et ce que les fonctionnalités modernes du langage — enums, records, static factories, injection de dépendances — permettent de remplacer.
Ce que vous allez apprendre
- Expliquer le problème que résout chaque pattern de création et le reconnaître dans le JDK et les frameworks
- Implémenter correctement un Singleton thread-safe et savoir quand un conteneur DI doit plutôt s'en charger
- Utiliser les static factories et Factory Method pour découpler l'appelant des classes concrètes
- Construire des objets immuables à nombreux paramètres optionnels avec le pattern Builder
- Juger quand un pattern apporte de la clarté et quand il n'ajoute que de la cérémonie
Les design patterns sont des solutions nommées à des problèmes de conception récurrents. Les noms comptent autant que les solutions : dire « fais-en un Builder » ou « c'est en fait une Abstract Factory » résume toute une structure en trois mots. Les cinq patterns de création du catalogue d'origine s'attaquent tous au même problème — du code qui fait new ConcreteThing(...) est soudé à cette classe concrète et aux détails de sa construction — et le résolvent dans des situations différentes. Ce guide les traite un par un avec du code Java 21 et, tout aussi important, signale où le langage et les frameworks ont absorbé le pattern au point que vous n'avez plus besoin de l'écrire à la main.
Pourquoi la construction a besoin de patterns
Un appel à new fige trois décisions d'un coup : quelle classe, quels arguments et quand. Parfois, ces décisions devraient se prendre ailleurs — dans la configuration, dans une sous-classe, dans un objet qui connaît les règles pour obtenir une instance valide. Trois symptômes signalent le besoin d'un pattern de création :
- Une classe est instanciée à de nombreux endroits et vous devez changer l'implémentation utilisée, ou ajouter du cache, du pooling ou des logs autour de la création.
- Un constructeur a accumulé de nombreux paramètres, pour la plupart optionnels, et les appelants passent
nullou0pour ceux qui ne les intéressent pas. - Créer l'objet demande plusieurs étapes, une validation croisée entre champs ou la connaissance d'autres objets dont l'appelant ne devrait pas avoir à se soucier.
Les patterns ci-dessous sont les réponses classiques. Avant eux, un idiome mérite d'être cité car il sert de fondation à plusieurs : la static factory method (LocalDate.of, List.of, Executors.newFixedThreadPool). Contrairement à un constructeur, elle porte un nom explicite, peut renvoyer une instance en cache ou déjà existante, peut renvoyer un sous-type ou une interface, et peut choisir la classe à l'exécution. Bon nombre de ces patterns sont des static factories dotées d'une structure supplémentaire.
Singleton
Intention : garantir qu'une classe n'a qu'une seule instance et fournir un point d'accès global à celle-ci.
L'implémentation des manuels — constructeur privé, instance statique, getter statique — a été si souvent mal écrite qu'elle mérite un traitement précis. Sur la JVM, les formes correctes les plus simples sont l'enum et l'idiome du holder (initialisation à la demande) :
// Enum singleton: serialisation-safe, reflection-safe, thread-safe by the JLS
public enum ClockSource {
INSTANCE;
private final Clock clock = Clock.systemUTC();
public Instant now() { return clock.instant(); }
}
// Holder idiom: lazy, thread-safe without synchronisation, for classes that cannot be enums
public final class Registry {
private Registry() {}
private static final class Holder {
static final Registry INSTANCE = new Registry(); // initialised on first access to Holder
}
public static Registry get() { return Holder.INSTANCE; }
}
Les deux s'appuient sur la garantie d'initialisation des classes de la JVM : une classe est initialisée exactement une fois, sous un verrou géré par la JVM, et le résultat est visible de tous les threads. La tristement célèbre version en double-checked locking — if (instance == null) synchronized (...) { if (instance == null) instance = new ...} — n'est correcte que si le champ est volatile, et même dans ce cas elle demande plus de code que l'idiome du holder, qui n'en demande aucun.
Ce sont ses coûts qui rendent le pattern controversé. Un Singleton récupéré via Registry.get() à l'intérieur d'une classe est une dépendance cachée : rien dans le constructeur de la classe n'indique qu'elle a besoin d'un Registry, les tests ne peuvent pas le remplacer, et tous les tests partagent son état. La réponse moderne consiste à garder la propriété — une seule instance — et à abandonner l'accès global : laissez un conteneur d'injection de dépendances créer l'instance unique et la fournir à quiconque déclare en avoir besoin. Un bean Spring est de scope singleton par défaut, ce qui explique qu'on trouve rarement une méthode getInstance() dans une application Spring. Réservez le pattern écrit à la main au code sans conteneur et aux ressources réellement globales au processus, comme un backend de logging.
Factory Method
Intention : définir une interface de création d'objet, mais laisser les sous-classes (ou une fonction fournie) décider de la classe à instancier.
La forme classique est une méthode abstraite dans une superclasse, appelée par la logique de template de celle-ci :
public abstract class ReportExporter {
public final Path export(Report report, Path dir) throws IOException {
Writer writer = createWriter(); // the factory method
Path target = dir.resolve(report.name() + "." + writer.extension());
Files.writeString(target, writer.render(report));
return target;
}
protected abstract Writer createWriter();
public interface Writer {
String extension();
String render(Report report);
}
}
public final class CsvExporter extends ReportExporter {
@Override protected Writer createWriter() { return new CsvWriter(); }
}
export est figée ; c'est la sous-classe qui décide avec quoi elle exporte. java.util.Calendar.getInstance(), NumberFormat.getInstance() et la méthode Collection.iterator() — chaque collection créant son propre type d'itérateur — en sont des exemples dans le JDK.
Depuis Java 8, la sous-classe est souvent superflue : passez la création sous forme de Supplier<Writer> ou de Function<Config, Writer> au constructeur. C'est le même pattern, avec une lambda à la place d'une redéfinition, et il se compose mieux : vous pouvez construire une Map<String, Supplier<Writer>> indexée par nom de format et y chercher la factory à l'exécution, ce qui est la manière habituelle de bâtir des points d'extension de type plugin. ThreadFactory, Collectors.toCollection(ArrayList::new) et Stream.generate(supplier) dans le JDK reçoivent tous des factories de cette façon.
Une forme simple apparentée est la factory paramétrée : une seule méthode statique avec un switch qui renvoie une implémentation différente selon l'argument. C'est le bon premier pas quand les implémentations sont peu nombreuses et que l'appelant sait laquelle il veut par son nom ; avec une interface sealed, l'exhaustivité du switch est vérifiée par le compilateur.
Abstract Factory
Intention : fournir une interface pour créer des familles d'objets liés sans nommer leurs classes concrètes.
Là où Factory Method crée un produit, Abstract Factory en crée plusieurs qui doivent être cohérents entre eux. L'exemple canonique est une bibliothèque d'interface graphique — boutons, menus et boîtes de dialogue doivent tous venir du même look-and-feel — et, côté backend, la même forme se retrouve dans un ensemble de composants d'infrastructure liés :
public interface StorageFactory {
BlobStore blobs();
QueueClient queue();
LockProvider locks();
}
public final class AwsStorageFactory implements StorageFactory {
private final Region region;
public AwsStorageFactory(Region region) { this.region = region; }
@Override public BlobStore blobs() { return new S3BlobStore(region); }
@Override public QueueClient queue() { return new SqsQueueClient(region); }
@Override public LockProvider locks() { return new DynamoLockProvider(region); }
}
public final class LocalStorageFactory implements StorageFactory {
@Override public BlobStore blobs() { return new DirectoryBlobStore(Path.of("data")); }
@Override public QueueClient queue() { return new InMemoryQueue(); }
@Override public LockProvider locks() { return new FileLockProvider(); }
}
Le code applicatif reçoit une StorageFactory et ne mentionne jamais S3 ni le répertoire local. Changer toute la famille revient à changer un seul objet. La conception DriverManager/Connection de JDBC relève de l'abstract factory dans l'esprit : une Connection crée des Statement et des PreparedStatement adaptés à sa base de données.
Dans une application Spring, les profils et les classes @Configuration jouent ce rôle — une configuration @Profile("local") définit la famille locale de beans, une autre définit la famille cloud — si bien que les interfaces de factory explicites se rencontrent surtout dans les bibliothèques et le code sans framework.
Builder
Intention : séparer la construction d'un objet complexe de sa représentation pour qu'un même processus puisse produire des représentations différentes — en pratique, offrir un moyen lisible, étape par étape, de créer un objet immuable comportant de nombreuses parties optionnelles.
En Java, il prend la forme d'une classe statique imbriquée avec des setters fluides et une méthode build() qui valide puis crée le produit immuable :
public final class HttpRequestSpec {
private final URI uri;
private final String method;
private final Map<String, String> headers;
private final Duration timeout;
private final byte[] body;
private HttpRequestSpec(Builder b) {
this.uri = b.uri;
this.method = b.method;
this.headers = Map.copyOf(b.headers); // defensive, immutable
this.timeout = b.timeout;
this.body = b.body == null ? new byte[0] : b.body.clone();
}
public static Builder builder(URI uri) { return new Builder(uri); }
public static final class Builder {
private final URI uri; // required: constructor parameter
private String method = "GET"; // optional: sensible defaults
private final Map<String, String> headers = new LinkedHashMap<>();
private Duration timeout = Duration.ofSeconds(30);
private byte[] body;
private Builder(URI uri) { this.uri = Objects.requireNonNull(uri); }
public Builder method(String method) { this.method = method; return this; }
public Builder header(String name, String v) { headers.put(name, v); return this; }
public Builder timeout(Duration timeout) { this.timeout = timeout; return this; }
public Builder body(byte[] body) { this.body = body; return this; }
public HttpRequestSpec build() {
if (body != null && method.equals("GET")) {
throw new IllegalStateException("GET requests cannot carry a body"); // cross-field rule
}
return new HttpRequestSpec(this);
}
}
}
HttpRequestSpec spec = HttpRequestSpec.builder(URI.create("https://api.example.com/orders"))
.method("POST")
.header("Content-Type", "application/json")
.timeout(Duration.ofSeconds(5))
.body(payload)
.build();
L'appel nomme chaque valeur, le produit est immuable, les valeurs par défaut sont au même endroit et la validation croisée des champs s'exécute une seule fois dans build(). Le JDK utilise cette forme pour HttpRequest.newBuilder(), ProcessBuilder, StringBuilder (un builder de chaînes) et Stream.Builder ; l'annotation @Builder de Lombok la génère, et les records à nombreux composants s'accompagnent souvent d'un builder écrit à la main.
Deux variantes méritent d'être connues. Un staged builder utilise une chaîne d'interfaces pour imposer l'ordre des étapes obligatoires (.uri(...) renvoie un type qui n'offre que .method(...)) — plus sûr, mais plus de code. Et un builder au-dessus d'un record peut se réduire à une classe mutable dont le build() appelle le constructeur canonique du record, la validation restant dans le constructeur compact du record.
N'écrivez pas de Builder pour un type à deux champs obligatoires : un constructeur ou un record est plus clair. Le pattern devient rentable à partir de quatre paramètres, ou quand l'optionnalité est réelle.
Prototype
Intention : créer de nouveaux objets en copiant une instance existante plutôt qu'en les construisant de zéro.
Prototype a de l'intérêt quand la construction d'un objet coûte cher (chargement d'une configuration volumineuse, compilation d'un template, ouverture d'un pool de connexions) ou quand l'appelant veut « le même, avec une petite modification ». Le mécanisme natif de Java est Object.clone() avec l'interface marqueur Cloneable, et il est largement considéré comme une erreur de conception : clone() est protected sur Object, fait une copie superficielle champ par champ, contourne les constructeurs et ne permet pas d'affecter aux champs final une valeur différente de l'original. Préférez une copie explicite :
public final class PipelineConfig {
private final List<String> stages;
private final Map<String, String> options;
public PipelineConfig(List<String> stages, Map<String, String> options) {
this.stages = List.copyOf(stages);
this.options = Map.copyOf(options);
}
public PipelineConfig(PipelineConfig other) { // copy constructor
this(other.stages, other.options);
}
public PipelineConfig withOption(String key, String value) { // "wither": copy with one change
Map<String, String> next = new HashMap<>(options);
next.put(key, value);
return new PipelineConfig(stages, next);
}
}
Avec des types immuables, le pattern devient presque invisible : une méthode « wither » renvoie une copie modifiée sans toucher à l'original, qu'on peut partager sans risque entre threads. Les records rendent cela idiomatique (new Config(existing.name(), newTimeout)), et une syntaxe de type with pour les records a été discutée pour le langage, mais elle ne fait pas partie de Java 21.
Quand il faut vraiment copier en profondeur des graphes mutables — un modèle de document, un état de jeu —, un constructeur de copie par classe, qui copie ses enfants, est plus prévisible que clone() ; la sérialisation aller-retour en octets reste un dernier recours, lent et fragile.
Choisir entre eux
| Pattern | À utiliser quand | Alternative moderne |
|---|---|---|
| Singleton | Une seule instance d'une ressource globale au processus, sans conteneur | Scope singleton d'un conteneur DI ; enum pour les objets assimilables à des constantes |
| Factory Method | Les sous-classes ou les appelants choisissent le produit concret | Paramètre Supplier<T> / Function ; static factory avec switch |
| Abstract Factory | Plusieurs produits doivent provenir d'une même famille cohérente | Profils Spring et classes de configuration |
| Builder | Nombreux paramètres, valeurs optionnelles, validation croisée, immuabilité | Record + constructeur compact pour les petits types aux champs tous obligatoires |
| Prototype | Copie avec modifications ou construction coûteuse | Constructeurs de copie et withers sur des types immuables |
Erreurs fréquentes
Le Singleton comme variable globale. Un état statique mutable, accessible de partout, impossible à tester et sujet aux race conditions. Injectez-le.
Le Singleton paresseux mal écrit. Un double-checked locking sans volatile, ou un simple test de null non synchronisé, crée deux instances sous charge. Utilisez un enum ou l'idiome du holder.
La factory qui ne fait qu'envelopper new. Un ThingFactory.create() qui renvoie new Thing() sans aucun point de variation n'est que de la cérémonie. Introduisez le pattern quand il existe une deuxième implémentation ou une règle de construction à centraliser.
Un produit mutable après build(). Un builder dont le produit a encore des setters perd l'immuabilité qui le justifiait.
Un Builder sans validation. build() est le seul endroit qui voit tous les champs : vérifiez-y les invariants entre champs.
clone() sur des classes à champs mutables. La copie superficielle partage les parties mutables : chaque copie modifie les listes de l'autre. Copiez explicitement.
La réflexion qui contourne un Singleton. Un constructeur privé peut être appelé par réflexion ; la forme enum est la seule que la JVM protège, et aussi la seule qui reste une instance unique après sérialisation.
Points clés à retenir
- Les patterns de création découplent le code des classes concrètes et centralisent les règles de construction ; la static factory method est la brique de base de la plupart d'entre eux.
- Implémentez un Singleton sous forme d'enum ou avec l'idiome du holder, et préférez le scope singleton d'un conteneur DI pour que la dépendance soit visible et testable.
- Factory Method délègue le choix du produit à une sous-classe ou, en Java moderne, à un
Supplier/Functionpassé en paramètre ; Abstract Factory fait de même pour une famille de produits qui doivent aller ensemble. - Builder répond au problème des nombreux paramètres optionnels : valeurs nommées à l'appel, immuabilité, un seul endroit pour les valeurs par défaut et la validation.
- En Java, Prototype signifie constructeurs de copie et withers sur des types immuables, pas
Cloneable. - N'appliquez un pattern que lorsque la variation qu'il permet existe réellement ; un pattern avec une seule implémentation n'est qu'une indirection sans bénéfice.
Sources et lectures complémentaires
Questions fréquentes
- Le Singleton est-il un anti-pattern ?
- La version écrite à la main avec accès global l'est généralement : elle masque les dépendances, fait partager un état entre les tests et se remplace difficilement. L'idée d'une instance unique par application reste valable, et c'est exactement ce que fournit un bean Spring de scope singleton, injecté plutôt que récupéré. N'écrivez une classe Singleton que pour des ressources réellement globales au processus, dans du code sans conteneur.
- Quelle différence entre Factory Method et Abstract Factory ?
- Factory Method est une méthode redéfinissable qui crée un seul produit et laisse les sous-classes choisir la classe concrète. Abstract Factory est un objet dont les méthodes créent une famille de produits liés qui doivent être utilisés ensemble, comme les widgets d'un même look-and-feel ou les drivers d'une même base de données. Une abstract factory est souvent implémentée à l'aide de factory methods.
- Quand utiliser un Builder plutôt qu'un constructeur ?
- Quand un type a plus de trois ou quatre paramètres, dont plusieurs optionnels, ou quand la validation doit considérer plusieurs champs ensemble. Un builder garde le résultat immuable, nomme chaque valeur à l'appel et évite l'explosion des constructeurs télescopiques. Pour une petite valeur dont tous les champs sont obligatoires, le constructeur canonique d'un record est plus simple.
- Faut-il utiliser clone() pour le pattern Prototype ?
- Préférez un constructeur de copie ou une méthode statique de copie. Object.clone est protected, exige l'interface marqueur Cloneable, fait par défaut une copie superficielle et court-circuite les constructeurs, ce qui complique les invariants et les champs final. Un constructeur de copie est explicite, profond là où vous le décidez, et fonctionne avec les champs final.