Design Patterns Créationnels en Java (partie 2/2) : Abstract Factory et Builder

Abdoul Kadri ASSOUMANE MAMANE
Abdoul Kadri ASSOUMANE MAMANE Auteur
3 min de lecture
4 vues

Écouter l'article

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


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

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

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

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

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.

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.