Série « Design Patterns en Java » : 9 articles en 4 familles, chacun lisible seul.
- Créationnels : Singleton et Factory Method (1/2) · Abstract Factory et Builder (2/2, cet article)
- 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)
Les patterns créationnels décrivent comment fabriquer les objets. Cette seconde partie traite Abstract Factory, qui crée des familles d'objets cohérentes (par exemple une famille PostgreSQL et une famille H2), et Builder, qui construit pas à pas un objet complexe et immuable. Elle se lit sans la première partie (Singleton et Factory Method), dont un seul élément est réutilisé dans la dernière section : le registre de notifications.
Le code est illustratif et simplifié : les classes non définies (Customer, Product, OrderLine, ShippingInfo, PaymentInfo, PostgresMigrationRunner, H2MigrationRunner…) sont supposées exister. Les noms suivent les conventions Java et le rôle du pattern en suffixe, comme dans le reste de la série ; les diagrammes UML reprennent 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.
Abstract Factory : créer des familles d'objets cohérentes
Le problème
Considérons une application qui doit fonctionner avec PostgreSQL en production et H2 en test. Chaque moteur nécessite des implémentations différentes pour la construction des requêtes, les migrations et la gestion des séquences.
Il ne s'agit plus de créer un seul objet (Factory Method), mais une famille d'objets qui doivent rester cohérents entre eux. Un PostgresQueryBuilder associé à un H2SequenceGenerator ne fonctionnera pas.
Le diagramme UML
L'implémentation
Un point de sécurité avant le code. Les noms de tables, de colonnes et de séquences ne peuvent pas être passés comme paramètres liés (? ou :param) : ils doivent être écrits dans le texte SQL. Si ces identifiants proviennent, même indirectement, d'une entrée utilisateur, les concaténer tels quels ouvre la porte à une injection SQL. On centralise donc leur validation dans une petite classe utilitaire, qui n'accepte que des identifiants simples (lettres, chiffres, underscore, éventuellement qualifiés par un schéma). Les valeurs, elles, passent toujours par des paramètres liés.
public final class SqlIdentifiers {
private static final Pattern IDENTIFIER =
Pattern.compile("[A-Za-z_][A-Za-z0-9_]{0,62}(\\.[A-Za-z_][A-Za-z0-9_]{0,62})?");
private SqlIdentifiers() { }
public static String require(String identifier) {
if (identifier == null || !IDENTIFIER.matcher(identifier).matches()) {
throw new IllegalArgumentException("Identifiant SQL invalide : " + identifier);
}
return identifier;
}
}
Les interfaces produits :
public interface QueryBuilder {
// whereColumns : colonnes filtrées par égalité, rendues sous forme de paramètres nommés
String buildSelect(String table, List<String> columns, List<String> whereColumns);
String buildInsert(String table, Map<String, Object> fields);
String buildPaginatedQuery(String baseQuery, int page, int size);
}
public interface MigrationRunner {
void runMigration(String scriptPath);
List<String> getAppliedMigrations();
void rollback(String migrationId);
}
public interface SequenceGenerator {
Long nextValue(String sequenceName);
void resetSequence(String sequenceName, long value);
}
L'Abstract Factory :
public interface DatabaseFactory {
QueryBuilder createQueryBuilder();
MigrationRunner createMigrationRunner();
SequenceGenerator createSequenceGenerator();
/**
* Méthode statique pour obtenir la bonne famille
* selon l'environnement.
*/
static DatabaseFactory forEnvironment(String dbType, JdbcTemplate jdbcTemplate) {
return switch (dbType.toLowerCase(Locale.ROOT)) {
case "postgresql", "postgres" -> new PostgresDatabaseFactory(jdbcTemplate);
case "h2" -> new H2DatabaseFactory(jdbcTemplate);
case "mysql" -> new MySqlDatabaseFactory(jdbcTemplate);
default -> throw new UnsupportedOperationException(
"Base de données non supportée : " + dbType
);
};
}
}
L'implémentation PostgreSQL, une famille cohérente (les classes PostgresMigrationRunner, H2MigrationRunner, H2SequenceGenerator et MySqlDatabaseFactory suivent le même schéma et sont omises par concision) :
public class PostgresDatabaseFactory implements DatabaseFactory {
private final JdbcTemplate jdbcTemplate;
public PostgresDatabaseFactory(JdbcTemplate jdbcTemplate) {
this.jdbcTemplate = jdbcTemplate;
}
@Override
public QueryBuilder createQueryBuilder() {
return new PostgresQueryBuilder();
}
@Override
public MigrationRunner createMigrationRunner() {
return new PostgresMigrationRunner(jdbcTemplate);
}
@Override
public SequenceGenerator createSequenceGenerator() {
return new PostgresSequenceGenerator(jdbcTemplate);
}
}
public class PostgresQueryBuilder implements QueryBuilder {
@Override
public String buildSelect(String table, List<String> columns, List<String> whereColumns) {
String cols = columns.isEmpty()
? "*"
: columns.stream().map(SqlIdentifiers::require).collect(Collectors.joining(", "));
String query = "SELECT " + cols + " FROM " + SqlIdentifiers.require(table);
if (!whereColumns.isEmpty()) {
query += " WHERE " + whereColumns.stream()
.map(SqlIdentifiers::require)
.map(c -> c + " = :" + c) // valeur liée par paramètre nommé
.collect(Collectors.joining(" AND "));
}
return query;
}
@Override
public String buildInsert(String table, Map<String, Object> fields) {
List<String> names = fields.keySet().stream().map(SqlIdentifiers::require).toList();
String placeholders = names.stream()
.map(k -> ":" + k)
.collect(Collectors.joining(", "));
return "INSERT INTO " + SqlIdentifiers.require(table)
+ " (" + String.join(", ", names) + ") VALUES (" + placeholders + ") RETURNING id";
}
@Override
public String buildPaginatedQuery(String baseQuery, int page, int size) {
if (page < 0 || size <= 0) {
throw new IllegalArgumentException("Pagination invalide");
}
return baseQuery + " LIMIT " + size + " OFFSET " + ((long) page * size);
}
}
public class PostgresSequenceGenerator implements SequenceGenerator {
private final JdbcTemplate jdbcTemplate;
public PostgresSequenceGenerator(JdbcTemplate jdbcTemplate) {
this.jdbcTemplate = Objects.requireNonNull(jdbcTemplate);
}
@Override
public Long nextValue(String sequenceName) {
// Nom validé puis passé en paramètre lié, converti en regclass côté PostgreSQL
String name = SqlIdentifiers.require(sequenceName);
return jdbcTemplate.queryForObject(
"SELECT nextval(CAST(? AS regclass))", Long.class, name
);
}
@Override
public void resetSequence(String sequenceName, long value) {
// DDL : l'identifiant ne peut pas être un paramètre lié, d'où la validation.
// `value` est un long, il ne peut pas transporter de SQL.
String name = SqlIdentifiers.require(sequenceName);
jdbcTemplate.execute("ALTER SEQUENCE " + name + " RESTART WITH " + value);
}
}
L'implémentation H2 : mêmes interfaces, comportement adapté :
public class H2DatabaseFactory implements DatabaseFactory {
private final JdbcTemplate jdbcTemplate;
public H2DatabaseFactory(JdbcTemplate jdbcTemplate) {
this.jdbcTemplate = jdbcTemplate;
}
@Override
public QueryBuilder createQueryBuilder() {
return new H2QueryBuilder();
}
@Override
public MigrationRunner createMigrationRunner() {
return new H2MigrationRunner(jdbcTemplate);
}
@Override
public SequenceGenerator createSequenceGenerator() {
return new H2SequenceGenerator(jdbcTemplate);
}
}
public class H2QueryBuilder implements QueryBuilder {
@Override
public String buildSelect(String table, List<String> columns, List<String> whereColumns) {
// H2 est compatible avec le SQL standard pour les SELECT
String cols = columns.isEmpty()
? "*"
: columns.stream().map(SqlIdentifiers::require).collect(Collectors.joining(", "));
String query = "SELECT " + cols + " FROM " + SqlIdentifiers.require(table);
if (!whereColumns.isEmpty()) {
query += " WHERE " + whereColumns.stream()
.map(SqlIdentifiers::require)
.map(c -> c + " = :" + c)
.collect(Collectors.joining(" AND "));
}
return query;
}
@Override
public String buildInsert(String table, Map<String, Object> fields) {
List<String> names = fields.keySet().stream().map(SqlIdentifiers::require).toList();
String placeholders = names.stream()
.map(k -> ":" + k)
.collect(Collectors.joining(", "));
// Pas de RETURNING ici : la clé générée se récupère côté JDBC
// avec Statement.RETURN_GENERATED_KEYS (ou un KeyHolder avec Spring).
return "INSERT INTO " + SqlIdentifiers.require(table)
+ " (" + String.join(", ", names) + ") VALUES (" + placeholders + ")";
}
@Override
public String buildPaginatedQuery(String baseQuery, int page, int size) {
if (page < 0 || size <= 0) {
throw new IllegalArgumentException("Pagination invalide");
}
return baseQuery + " LIMIT " + size + " OFFSET " + ((long) page * size);
}
}
Utilisation : le client ne connaît pas la famille concrète
@Service
public class DataAccessService {
private final QueryBuilder queryBuilder;
private final SequenceGenerator sequenceGenerator;
private final MigrationRunner migrationRunner;
public DataAccessService(@Value("${spring.datasource.driver-class-name}") String driver,
JdbcTemplate jdbcTemplate) {
String dbType = detectDbType(driver);
DatabaseFactory factory = DatabaseFactory.forEnvironment(dbType, jdbcTemplate);
this.queryBuilder = factory.createQueryBuilder();
this.sequenceGenerator = factory.createSequenceGenerator();
this.migrationRunner = factory.createMigrationRunner();
}
private String detectDbType(String driver) {
if (driver.contains("postgresql")) return "postgresql";
if (driver.contains("h2")) return "h2";
if (driver.contains("mysql")) return "mysql";
throw new IllegalArgumentException("Driver non reconnu : " + driver);
}
public String buildUserQuery(List<String> filterColumns) {
return queryBuilder.buildSelect("users",
List.of("id", "username", "email", "created_at"),
filterColumns); // ex. List.of("username") donne WHERE username = :username
}
}
Dans un projet réel, les dialectes Hibernate et un outil de migration comme Flyway ou Liquibase couvrent déjà une grande partie de ce besoin. L'exemple sert surtout à illustrer la structure du pattern.
Abstract Factory vs Factory Method
Les deux se distinguent en deux lignes :
- Factory Method : une méthode qui crée un objet. Les sous-classes choisissent quel type.
- Abstract Factory : une interface qui crée une famille d'objets cohérents. Chaque implémentation de la factory produit tous les objets d'une même famille.
En pratique, l'Abstract Factory repose souvent sur des Factory Methods : chaque méthode create*() est, prise isolément, une méthode de fabrique.
Builder : construire des objets complexes étape par étape
Le problème
Soit une classe Order dont le constructeur prend 14 paramètres. Les appels ressemblent à ceci :
// Illisible : quel est le 7e paramètre ?
Order order = new Order("CMD-001", customer, LocalDate.now(), null, true,
false, "STANDARD", 3, null, "FR", Currency.EUR, 0.20,
"Livraison fragile", PaymentMethod.CARD);
Le Builder permet de nommer chaque paramètre et de construire l'objet étape par étape. On cite parfois StringBuilder comme exemple, mais son enchaînement fluide ne met pas en œuvre le Builder du GoF : il n'y a ni séparation entre construction et représentation finale, ni Director. Les vrais exemples du JDK sont HttpRequest.newBuilder(), Locale.Builder ou ProcessBuilder.
Le diagramme UML
L'implémentation avec validation intégrée
public class Order {
private final String orderNumber;
private final Customer customer;
private final LocalDate orderDate;
private final List<OrderLine> lines;
private final ShippingInfo shipping;
private final PaymentInfo payment;
private final OrderStatus status;
private final String notes;
// Constructeur privé : seul le Builder peut créer un Order
private Order(Builder builder) {
this.orderNumber = builder.orderNumber;
this.customer = builder.customer;
this.orderDate = builder.orderDate;
this.lines = List.copyOf(builder.lines); // Copie défensive immuable
this.shipping = builder.shipping;
this.payment = builder.payment;
this.status = builder.status;
this.notes = builder.notes;
}
public BigDecimal getTotal() {
return lines.stream()
.map(OrderLine::getSubtotal)
.reduce(BigDecimal.ZERO, BigDecimal::add);
}
public BigDecimal getTotalWithTax(BigDecimal taxRate) {
BigDecimal total = getTotal();
return total.add(total.multiply(taxRate)).setScale(2, RoundingMode.HALF_UP);
}
// Getters uniquement : pas de setters, l'objet est immuable
public String getOrderNumber() { return orderNumber; }
public Customer getCustomer() { return customer; }
public LocalDate getOrderDate() { return orderDate; }
public List<OrderLine> getLines() { return lines; }
public ShippingInfo getShipping() { return shipping; }
public PaymentInfo getPayment() { return payment; }
public OrderStatus getStatus() { return status; }
public String getNotes() { return notes; }
public static Builder builder() {
return new Builder();
}
// --- Le Builder ---
public static class Builder {
private String orderNumber;
private Customer customer;
private LocalDate orderDate = LocalDate.now();
private final List<OrderLine> lines = new ArrayList<>();
private ShippingInfo shipping;
private PaymentInfo payment;
private OrderStatus status = OrderStatus.DRAFT;
private String notes;
private Builder() {}
public Builder orderNumber(String orderNumber) {
this.orderNumber = orderNumber;
return this;
}
public Builder customer(Customer customer) {
this.customer = customer;
return this;
}
public Builder orderDate(LocalDate orderDate) {
this.orderDate = orderDate;
return this;
}
public Builder addLine(Product product, int quantity) {
this.lines.add(new OrderLine(product, quantity));
return this;
}
public Builder addLine(Product product, int quantity, BigDecimal unitPrice) {
this.lines.add(new OrderLine(product, quantity, unitPrice));
return this;
}
public Builder addLine(OrderLine line) {
this.lines.add(Objects.requireNonNull(line));
return this;
}
public Builder shipping(ShippingInfo shipping) {
this.shipping = shipping;
return this;
}
public Builder payment(PaymentInfo payment) {
this.payment = payment;
return this;
}
public Builder status(OrderStatus status) {
this.status = status;
return this;
}
public Builder notes(String notes) {
this.notes = notes;
return this;
}
/**
* Construit l'Order après validation.
* build() est le seul endroit où l'on valide la cohérence globale.
*/
public Order build() {
validate();
return new Order(this);
}
private void validate() {
List<String> errors = new ArrayList<>();
if (orderNumber == null || orderNumber.isBlank()) {
errors.add("Numéro de commande requis");
}
if (customer == null) {
errors.add("Client requis");
}
if (lines.isEmpty()) {
errors.add("Au moins une ligne de commande requise");
}
if (shipping == null) {
errors.add("Informations de livraison requises");
}
if (!errors.isEmpty()) {
throw new IllegalStateException(
"Commande invalide : " + String.join(", ", errors)
);
}
}
}
}
Utilisation : lisible et expressive
Order order = Order.builder()
.orderNumber("CMD-2024-001")
.customer(customer)
.addLine(laptop, 1)
.addLine(mouse, 2, new BigDecimal("29.99"))
.addLine(keyboard, 1)
.shipping(ShippingInfo.builder()
.address("10 rue de la Paix, 75001 Paris")
.method(ShippingMethod.EXPRESS)
.build())
.payment(PaymentInfo.of(PaymentMethod.CARD, "visa-****-1234"))
.notes("Livraison fragile")
.build();
// Avec laptop à 1099.99 et keyboard à 100.00 :
System.out.println(order.getTotal()); // 1259.97
System.out.println(order.getTotalWithTax( // 1511.96
new BigDecimal("0.20")));
Comparé au constructeur à 14 paramètres, chaque champ est nommé, l'ordre n'a plus d'importance, et build() valide la cohérence globale avant de retourner un objet immuable.
Builder + Lombok : le raccourci pragmatique
Lombok génère le Builder automatiquement avec @Builder :
@Builder
@Getter
public class UserResponse {
private final Long id;
private final String username;
private final String email;
@Builder.Default
private final Set<String> roles = Set.of();
private final LocalDateTime createdAt;
}
// Utilisation identique
UserResponse response = UserResponse.builder()
.id(1L)
.username("akam")
.email("contact@akam.dev")
.roles(Set.of("ROLE_ADMIN"))
.createdAt(LocalDateTime.now())
.build();
Lombok convient pour les DTO simples (les record Java couvrent aussi ce besoin quand le nombre de champs reste raisonnable). Pour les objets métier complexes, avec de la logique dans les méthodes du builder (comme addLine(), qui crée un OrderLine) ou des règles de cohérence dans build(), un Builder écrit à la main vaut l'investissement. Il demande une cinquantaine de lignes supplémentaires, mais il rend le code plus lisible et plus sûr.
Le Director : des recettes préconstruites
Le Director est optionnel, mais utile quand certaines configurations reviennent souvent :
public class OrderDirector {
private final SequenceGenerator sequenceGenerator;
public OrderDirector(SequenceGenerator sequenceGenerator) {
this.sequenceGenerator = sequenceGenerator;
}
/**
* Commande standard : livraison standard, paiement par carte.
*/
public Order createStandardOrder(Customer customer, List<OrderLine> lines) {
return baseOrder(customer, lines, ShippingMethod.STANDARD)
.status(OrderStatus.CONFIRMED)
.build();
}
/**
* Commande express : livraison express, priorité haute.
*/
public Order createExpressOrder(Customer customer, List<OrderLine> lines) {
return baseOrder(customer, lines, ShippingMethod.EXPRESS)
.notes("PRIORITÉ EXPRESS")
.status(OrderStatus.CONFIRMED)
.build();
}
private Order.Builder baseOrder(Customer customer, List<OrderLine> lines,
ShippingMethod method) {
Order.Builder builder = Order.builder()
.orderNumber(generateOrderNumber())
.customer(customer)
.shipping(ShippingInfo.builder()
.address(customer.getDefaultAddress())
.method(method)
.build())
.payment(PaymentInfo.of(PaymentMethod.CARD,
customer.getDefaultPaymentMethod()));
lines.forEach(builder::addLine);
return builder;
}
private String generateOrderNumber() {
long next = sequenceGenerator.nextValue("order_number_seq");
return "CMD-" + LocalDate.now().getYear() + "-" + String.format("%06d", next);
}
}
Le numéro de commande vient d'une séquence de base de données, ce qui garantit l'unicité même avec plusieurs instances de l'application. Un System.nanoTime() % 1_000_000 ne l'offre pas : le modulo ramène les valeurs dans un intervalle d'un million, deux appels peuvent donc produire le même numéro, et nanoTime() n'a de sens que dans une même JVM.
Le Director peut aussi être un @Service Spring qui injecte d'autres services pour valider le stock ou appliquer des règles commerciales.
Les patterns créationnels en synergie
Les patterns créationnels s'utilisent rarement isolément. Dans une application réelle :
- Un Builder construit un objet complexe dont certaines parties sont fournies par une Factory :
Order order = Order.builder()
.orderNumber(orderNumberFactory.generate()) // Factory
.customer(customer)
.addLine(laptop, 1)
.shipping(shippingFactory.createForCountry("FR")) // Factory
.build();
- Une Abstract Factory peut utiliser des Builders pour configurer les objets de chaque famille :
public class PostgresDatabaseFactory implements DatabaseFactory {
// ... jdbcTemplate injecté par le constructeur
@Override
public MigrationRunner createMigrationRunner() {
MigrationSettings settings = MigrationSettings.builder() // Builder
.locations("classpath:db/migration/postgresql")
.baselineOnMigrate(true)
.build();
return new PostgresMigrationRunner(jdbcTemplate, settings);
}
}
- Un Singleton donne accès à une Factory partagée. Avec Spring, c'est le conteneur qui joue ce rôle : le registre vu plus haut est un bean singleton injecté là où on en a besoin.
@Service
public class AlertService {
private final NotificationRegistry registry; // bean singleton + Factory
public AlertService(NotificationRegistry registry) {
this.registry = registry;
}
public void alert(String recipient, String message) {
registry.getNotification("EMAIL")
.send(recipient, message);
}
}
Ce qu'il faut retenir
| Pattern | Quand l'utiliser | Équivalent Spring |
|---|---|---|
| Abstract Factory | Famille d'objets cohérents | Approximation : profils Spring (@Profile), qui activent une famille de beans selon l'environnement |
| Builder | Objets complexes, 4 paramètres ou plus | Lombok @Builder, HttpRequest.newBuilder(), Locale.Builder |
Les patterns structurels (partie 1) décrivent ensuite comment assembler ces objets une fois créés : Adapter, Decorator, Facade et Proxy.
Partie précédente : Singleton et Factory Method. Partie suivante : Adapter et Decorator.
Série « Design Patterns en Java » : 9 articles en 4 familles, chacun lisible seul.
- Créationnels : Singleton et Factory Method (1/2) · Abstract Factory et Builder (2/2, cet article)
- 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)