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)
- Structurels : Adapter et Decorator (1/2) · Facade et Proxy (2/2)
- Comportementaux, orchestration : Strategy et Observer (1/2) · Template Method et State (2/2, cet article)
- Comportementaux, communication : Command (1/3) · Chain of Responsibility et Mediator (2/3) · Iterator et Visitor (3/3)
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 :
- Valider le fichier
- Parser les lignes
- Transformer les données
- Sauvegarder en base
- 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
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
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.
- Créationnels : Singleton et Factory Method (1/2) · Abstract Factory et Builder (2/2)
- Structurels : Adapter et Decorator (1/2) · Facade et Proxy (2/2)
- Comportementaux, orchestration : Strategy et Observer (1/2) · Template Method et State (2/2, cet article)
- Comportementaux, communication : Command (1/3) · Chain of Responsibility et Mediator (2/3) · Iterator et Visitor (3/3)