Design Patterns Comportementaux en Java, orchestration (partie 2/2) : Template Method et State

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

Écouter l'article

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


Cette seconde partie des patterns comportementaux d'orchestration traite Template Method, qui fixe le squelette d'un traitement dont certaines étapes varient (un import de produits en CSV ou en JSON), et State, qui fait dépendre le comportement d'un objet de son état (le cycle de vie d'un ticket de support). Elle se lit sans la première partie (Strategy et Observer), à laquelle elle renvoie dans la dernière section, qui combine trois de ces patterns.

Le code est illustratif et simplifié : les classes non définies (Order, Agent, Product, ProductRepository, RawRecord, ImportResult…) 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.


Template Method : définir le squelette, déléguer les détails

Le problème

Plusieurs services d'import de données (CSV, JSON, XML) suivent le même processus :

  1. Valider le fichier
  2. Parser les lignes
  3. Transformer les données
  4. Sauvegarder en base
  5. Générer un rapport

Le processus est identique, mais chaque étape diffère selon le format. Sans pattern, chaque importeur réimplémente le processus complet par copier-coller.

Le diagramme UML

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

L'implémentation

La classe abstraite définit le squelette (le « template ») :

public abstract class DataImporter<T> {

    private static final Logger log = LoggerFactory.getLogger(DataImporter.class);

    /**
     * Template Method : le squelette de l'algorithme.
     * L'ordre des étapes est fixe (méthode final). Les sous-classes fournissent les détails.
     */
    public final ImportResult importData(InputStream inputStream) {
        ImportResult result = new ImportResult();
        result.setStartedAt(LocalDateTime.now());
        result.setFormat(getFormat());

        try {
            // Étape 1 : validation
            log.info("Validation du fichier {}...", getFormat());
            validateFile(inputStream);

            // Étape 2 : parsing
            log.info("Parsing des enregistrements...");
            List<RawRecord> rawRecords = parseRecords(inputStream);
            result.setTotalRecords(rawRecords.size());
            log.info("{} enregistrements trouvés", rawRecords.size());

            // Étape 3 : transformation
            List<T> entities = new ArrayList<>();
            for (RawRecord raw : rawRecords) {
                try {
                    T entity = transformRecord(raw);
                    entities.add(entity);
                    result.incrementSuccess();
                } catch (Exception e) {
                    onError(e, raw);
                    result.addError(raw.getLineNumber(), e.getMessage());
                }
            }

            // Étape 4 : sauvegarde en batch
            int saved = saveEntities(entities);
            result.setSavedCount(saved);

            // Étape 5 : rapport
            result.setCompletedAt(LocalDateTime.now());
            generateReport(result);

        } catch (Exception e) {
            log.error("Erreur fatale pendant l'import {}", getFormat(), e);
            result.setFatalError(e.getMessage());
        }

        return result;
    }

    // --- Méthodes abstraites : les sous-classes DOIVENT les implémenter ---

    protected abstract String getFormat();

    protected abstract void validateFile(InputStream input);

    protected abstract List<RawRecord> parseRecords(InputStream input);

    protected abstract T transformRecord(RawRecord raw);

    protected abstract int saveEntities(List<T> entities);

    // --- Hooks : les sous-classes PEUVENT les surcharger ---

    protected void generateReport(ImportResult result) {
        log.info("Import {} terminé : {}/{} succès, {} erreurs",
            result.getFormat(),
            result.getSuccessCount(), result.getTotalRecords(),
            result.getErrors().size());
    }

    protected void onError(Exception e, RawRecord raw) {
        log.warn("Erreur ligne {} : {}", raw.getLineNumber(), e.getMessage());
    }

    // --- Utilitaire partagé par les sous-classes ---

    protected String requireField(Map<String, String> data, String field, int line) {
        String value = data.get(field);
        if (value == null || value.isBlank()) {
            throw new ImportException("Champ '" + field + "' requis (ligne " + line + ")");
        }
        return value;
    }
}

L'importeur CSV ne se préoccupe que du format CSV. Le parsing s'appuie sur Apache Commons CSV, car un simple line.split(",") casse dès qu'un champ contient une virgule entre guillemets ("Clavier, sans fil") ou un saut de ligne :

@Component
public class CsvProductImporter extends DataImporter<Product> {

    private final ProductRepository productRepository;

    public CsvProductImporter(ProductRepository productRepository) {
        this.productRepository = productRepository;
    }

    @Override
    protected String getFormat() {
        return "CSV";
    }

    @Override
    protected void validateFile(InputStream input) {
        if (input == null) {
            throw new ImportException("Fichier CSV requis");
        }
    }

    @Override
    protected List<RawRecord> parseRecords(InputStream input) {
        CSVFormat format = CSVFormat.DEFAULT.builder()
            .setHeader()                 // la première ligne fournit les en-têtes
            .setSkipHeaderRecord(true)
            .setTrim(true)
            .build();

        List<RawRecord> records = new ArrayList<>();
        try (Reader reader = new InputStreamReader(input, StandardCharsets.UTF_8);
             CSVParser parser = format.parse(reader)) {

            if (parser.getHeaderNames().isEmpty()) {
                throw new ImportException("Fichier vide");
            }
            for (CSVRecord csvRecord : parser) {
                records.add(new RawRecord(
                    (int) csvRecord.getRecordNumber(), csvRecord.toMap()));
            }
        } catch (IOException e) {
            throw new ImportException("Erreur de lecture CSV", e);
        }
        return records;
    }

    @Override
    protected Product transformRecord(RawRecord raw) {
        Map<String, String> data = raw.getData();

        String name = requireField(data, "name", raw.getLineNumber());
        String priceStr = requireField(data, "price", raw.getLineNumber());

        return Product.builder()
            .name(name)
            .price(new BigDecimal(priceStr))
            .sku(data.getOrDefault("sku", ""))
            .category(data.getOrDefault("category", "UNKNOWN"))
            .build();
    }

    @Override
    protected int saveEntities(List<Product> products) {
        return productRepository.saveAll(products).size();
    }
}

L'importeur JSON : même squelette, parsing différent :

@Component
public class JsonProductImporter extends DataImporter<Product> {

    private final ObjectMapper objectMapper;
    private final ProductRepository productRepository;

    public JsonProductImporter(ObjectMapper objectMapper,
                                ProductRepository productRepository) {
        this.objectMapper = objectMapper;
        this.productRepository = productRepository;
    }

    @Override
    protected String getFormat() {
        return "JSON";
    }

    @Override
    protected void validateFile(InputStream input) {
        if (input == null) {
            throw new ImportException("Fichier JSON requis");
        }
    }

    @Override
    protected List<RawRecord> parseRecords(InputStream input) {
        try {
            List<Map<String, Object>> items = objectMapper.readValue(
                input, new TypeReference<List<Map<String, Object>>>() {}
            );

            List<RawRecord> records = new ArrayList<>();
            for (int i = 0; i < items.size(); i++) {
                Map<String, String> stringData = new LinkedHashMap<>();
                items.get(i).forEach((k, v) -> {
                    if (v != null) {
                        stringData.put(k, String.valueOf(v));
                    }
                });
                records.add(new RawRecord(i + 1, stringData));
            }
            return records;
        } catch (IOException e) {
            throw new ImportException("JSON invalide", e);
        }
    }

    @Override
    protected Product transformRecord(RawRecord raw) {
        Map<String, String> data = raw.getData();
        int line = raw.getLineNumber();

        return Product.builder()
            .name(requireField(data, "name", line))
            .price(new BigDecimal(requireField(data, "price", line)))
            .sku(data.getOrDefault("sku", ""))
            .category(data.getOrDefault("category", "UNKNOWN"))
            .build();
    }

    @Override
    protected int saveEntities(List<Product> products) {
        return productRepository.saveAll(products).size();
    }
}

Les deux transformRecord sont ici équivalents pour que chaque classe reste lisible isolément. Dans une base de code réelle, on remonterait cette méthode dans une classe intermédiaire ProductImporter extends DataImporter<Product>, dont les importeurs CSV et JSON hériteraient : l'héritage ne sert qu'aux étapes qui varient réellement.

Template Method vs Strategy

Les deux se distinguent ainsi :

  • Template Method : l'algorithme est dans la classe mère. Les sous-classes fournissent les détails par héritage.
  • Strategy : l'algorithme est dans un objet séparé. Le contexte délègue par composition.

En pratique, si le processus global est fixe et que seuls les détails changent, on choisit Template Method. Si l'algorithme entier peut être remplacé à l'exécution, on choisit Strategy.

On trouve le Template Method classique dans HttpServlet.service() (qui appelle doGet(), doPost()...), dans OncePerRequestFilter de Spring (doFilter() appelle doFilterInternal()) et dans AbstractApplicationContext.refresh(), dont plusieurs étapes sont des méthodes à surcharger.

JdbcTemplate et RestTemplate portent « Template » dans leur nom, mais ils relèvent d'une variante différente : le squelette (ouvrir la connexion, exécuter, fermer, traduire les exceptions) est fixe, et les variations sont passées en callbacks (RowMapper, PreparedStatementCallback). C'est de la composition, donc plus proche de Strategy que du Template Method par héritage.


State : quand le comportement dépend de l'état

Le problème

Un système de tickets de support où le comportement de chaque action dépend de l'état du ticket : un ticket en cours ne peut pas être fermé sans être résolu (un ticket seulement ouvert peut être annulé), on ne peut pas assigner un ticket fermé, et la réouverture n'est possible que depuis l'état résolu ou fermé. Le code à base de switch devient vite un labyrinthe :

// Le switch infernal
public void close(Ticket ticket) {
    switch (ticket.getStatus()) {
        case OPEN -> ticket.setStatus(TicketStatus.CLOSED); // annulation
        case IN_PROGRESS -> throw new IllegalStateException("Résoudre avant de fermer");
        case RESOLVED -> ticket.setStatus(TicketStatus.CLOSED);
        case CLOSED -> throw new IllegalStateException("Déjà fermé");
    }
}
// ... et le même switch pour assign(), resolve(), reopen()

Chaque nouvel état oblige à modifier toutes ces méthodes. Le State encapsule le comportement dans des objets d'état.

Le diagramme UML

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

L'implémentation

Chaque état du modèle a une classe correspondante. Un ticket rouvert retourne simplement à l'état OPEN :

public enum TicketStatus {
    OPEN, IN_PROGRESS, RESOLVED, CLOSED
}

L'interface State, avec des méthodes par défaut qui lèvent une exception :

public interface TicketState {

    default void assign(Ticket context, Agent agent) {
        throw new InvalidStateTransitionException(
            "Impossible d'assigner depuis l'état " + getStatus());
    }

    default void resolve(Ticket context, String resolution) {
        throw new InvalidStateTransitionException(
            "Impossible de résoudre depuis l'état " + getStatus());
    }

    default void close(Ticket context) {
        throw new InvalidStateTransitionException(
            "Impossible de fermer depuis l'état " + getStatus());
    }

    default void reopen(Ticket context, String reason) {
        throw new InvalidStateTransitionException(
            "Impossible de rouvrir depuis l'état " + getStatus());
    }

    TicketStatus getStatus();

    /**
     * Transitions autorisées depuis cet état (pour l'UI).
     */
    default Set<String> allowedTransitions() {
        return Set.of();
    }
}

Les états concrets : chacun ne surcharge que les transitions valides.

public class OpenState implements TicketState {

    @Override
    public void assign(Ticket context, Agent agent) {
        context.setAssignedAgent(agent);
        context.addHistory("Assigné à " + agent.getName());
        context.setState(new InProgressState());
    }

    @Override
    public void close(Ticket context) {
        // Fermer sans résoudre (annulation)
        context.setClosedAt(LocalDateTime.now());
        context.addHistory("Fermé sans résolution (annulé)");
        context.setState(new ClosedState());
    }

    @Override
    public TicketStatus getStatus() {
        return TicketStatus.OPEN;
    }

    @Override
    public Set<String> allowedTransitions() {
        return Set.of("assign", "close");
    }
}

public class InProgressState implements TicketState {

    @Override
    public void assign(Ticket context, Agent agent) {
        // Réassignation
        context.setAssignedAgent(agent);
        context.addHistory("Réassigné à " + agent.getName());
    }

    @Override
    public void resolve(Ticket context, String resolution) {
        if (resolution == null || resolution.isBlank()) {
            throw new InvalidStateTransitionException(
                "Une résolution est requise pour résoudre le ticket");
        }
        context.setResolution(resolution);
        context.addHistory("Résolu : " + resolution);
        context.setState(new ResolvedState());
    }

    @Override
    public TicketStatus getStatus() {
        return TicketStatus.IN_PROGRESS;
    }

    @Override
    public Set<String> allowedTransitions() {
        return Set.of("assign", "resolve");
    }
}

public class ResolvedState implements TicketState {

    @Override
    public void close(Ticket context) {
        context.setClosedAt(LocalDateTime.now());
        context.addHistory("Fermé après résolution");
        context.setState(new ClosedState());
    }

    @Override
    public void reopen(Ticket context, String reason) {
        context.setResolution(null);
        context.addHistory("Rouvert : " + reason);
        context.setState(new OpenState());
    }

    @Override
    public TicketStatus getStatus() {
        return TicketStatus.RESOLVED;
    }

    @Override
    public Set<String> allowedTransitions() {
        return Set.of("close", "reopen");
    }
}

public class ClosedState implements TicketState {

    @Override
    public void reopen(Ticket context, String reason) {
        if (reason == null || reason.isBlank()) {
            throw new InvalidStateTransitionException(
                "Une raison est requise pour rouvrir le ticket");
        }
        context.setClosedAt(null);
        context.setResolution(null);
        context.addHistory("Rouvert : " + reason);
        context.setState(new OpenState());
    }

    @Override
    public TicketStatus getStatus() {
        return TicketStatus.CLOSED;
    }

    @Override
    public Set<String> allowedTransitions() {
        return Set.of("reopen");
    }
}

Ces états ne portent aucune donnée : on peut donc les partager (constantes ou valeurs d'une enum) plutôt que d'en instancier un nouveau à chaque transition.

Le Context délègue tout à l'état courant :

public class Ticket {

    private TicketState state;
    private Agent assignedAgent;
    private String resolution;
    private LocalDateTime closedAt;
    private final List<String> history = new ArrayList<>();

    public Ticket() {
        this.state = new OpenState();
        addHistory("Ticket créé");
    }

    // --- Délégations à l'état courant ---

    public void assign(Agent agent) {
        state.assign(this, agent);
    }

    public void resolve(String resolution) {
        state.resolve(this, resolution);
    }

    public void close() {
        state.close(this);
    }

    public void reopen(String reason) {
        state.reopen(this, reason);
    }

    // --- Méthodes de mutation (package-private, utilisées par les états) ---

    void setState(TicketState newState) {
        TicketStatus from = this.state.getStatus();
        this.state = newState;
        addHistory(String.format("Transition : %s -> %s", from, newState.getStatus()));
    }

    void setAssignedAgent(Agent agent) { this.assignedAgent = agent; }
    void setResolution(String resolution) { this.resolution = resolution; }
    void setClosedAt(LocalDateTime closedAt) { this.closedAt = closedAt; }
    void addHistory(String entry) {
        this.history.add(LocalDateTime.now() + " - " + entry);
    }

    // --- Getters ---

    public TicketStatus getStatus() { return state.getStatus(); }
    public Set<String> getAllowedTransitions() { return state.allowedTransitions(); }
    public List<String> getHistory() { return Collections.unmodifiableList(history); }
    public Agent getAssignedAgent() { return assignedAgent; }
    public String getResolution() { return resolution; }
    public LocalDateTime getClosedAt() { return closedAt; }
}

Utilisation

Ticket ticket = new Ticket();
System.out.println(ticket.getStatus());              // OPEN
System.out.println(ticket.getAllowedTransitions());   // [assign, close] (ordre non garanti)

ticket.assign(new Agent("Alice"));                    // -> IN_PROGRESS
ticket.resolve("Problème résolu par redémarrage");    // -> RESOLVED
ticket.close();                                       // -> CLOSED

// Tentative invalide
try {
    ticket.assign(new Agent("Bob"));  // lève InvalidStateTransitionException
} catch (InvalidStateTransitionException e) {
    System.out.println(e.getMessage());
    // "Impossible d'assigner depuis l'état CLOSED"
}

ticket.reopen("Le problème persiste");                // -> OPEN
ticket.getHistory().forEach(System.out::println);

State vs Strategy (la vraie différence)

Structurellement, State et Strategy se ressemblent : les deux encapsulent un comportement derrière une interface. La différence est sémantique :

  • Strategy : le client choisit l'algorithme. Les stratégies ne se connaissent pas entre elles.
  • State : c'est l'objet lui-même qui change de comportement au fil de son cycle de vie. Les transitions sont déclenchées par les états (chaque état désigne le suivant), ou par le contexte dans d'autres variantes du pattern.

Les patterns comportementaux d'orchestration en synergie

Ces patterns se combinent naturellement dans une application réelle. L'exemple suivant en met trois en jeu : Strategy calcule le prix, State gère le cycle de vie de la commande (order.confirm(), supposée implémentée sur le modèle de Ticket) et Observer notifie les autres composants. Template Method n'apparaît pas dans ce service : il intervient en amont, pour structurer l'import des produits du catalogue (voir DataImporter plus haut).

@Service
public class PurchaseService {

    private final PricingService pricingService;              // Strategy
    private final OrderRepository orderRepository;
    private final ApplicationEventPublisher eventPublisher;    // Observer

    public PurchaseService(PricingService pricingService,
                           OrderRepository orderRepository,
                           ApplicationEventPublisher eventPublisher) {
        this.pricingService = pricingService;
        this.orderRepository = orderRepository;
        this.eventPublisher = eventPublisher;
    }

    @Transactional
    public Order purchase(Customer customer, Product product, int quantity) {
        // Strategy : choix du meilleur tarif applicable
        PriceResult price = pricingService.calculatePrice(product, customer);

        // Order.draft() est supposée exister : création d'une commande à l'état DRAFT
        Order order = orderRepository.save(
            Order.draft(customer, product, quantity, price.price()));

        order.confirm();   // State : DRAFT -> CONFIRMED

        // Observer : les listeners réagissent sans que ce service les connaisse
        eventPublisher.publishEvent(
            new OrderCreatedEvent(order, SecurityUtils.getCurrentUsername()));

        return order;
    }
}

Ce qu'il faut retenir

Pattern Quand l'utiliser Équivalent Spring / JDK
Template Method Processus fixe, détails variables par héritage HttpServlet.service(), OncePerRequestFilter (JdbcTemplate : variante à callbacks)
State Comportement qui change selon l'état interne Machines à états (Spring Statemachine), moteurs de workflow

La partie suivante ouvre la famille des patterns de communication avec Command : transformer une action en objet pour l'historiser, l'annuler ou la rejouer.


Partie précédente : Strategy et Observer. Partie suivante : Command.

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.