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)
- Comportementaux, communication : Command (1/3, cet article) · Chain of Responsibility et Mediator (2/3) · Iterator et Visitor (3/3)
Dans un workflow où de nombreux services collaborent en séquence, avec des branchements conditionnels, des annulations et du rejeu, la difficulté n'est plus de savoir quoi faire, mais comment orchestrer qui fait quoi, dans quel ordre, et comment revenir en arrière.
Les patterns de communication répondent à la même question : comment faire communiquer des objets sans créer un réseau de dépendances ingérable ? Cette famille se lit en trois parties : Command ici, puis Chain of Responsibility et Mediator (partie 2), puis Iterator et Visitor (partie 3).
Le code est illustratif et simplifié : les classes non définies (Article, ArticleRepository, ArticleStatus…) 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.
Command : matérialiser une action en objet
Le problème
Un back-office de gestion de contenu où les administrateurs peuvent créer, modifier et supprimer du contenu, et surtout annuler leurs dernières actions. Sans Command, chaque bouton du back-office appelle directement un service : pas d'historique, pas d'undo, pas de file d'attente, pas d'audit.
Le pattern Command transforme une requête en objet autonome, qui contient toutes les informations nécessaires à son exécution. Cette matérialisation ouvre plusieurs possibilités : file d'attente, undo/redo, journalisation, exécution différée.
Le diagramme UML
L'implémentation
L'interface Command avec undo :
public interface Command {
/**
* Exécute la commande et retourne le résultat.
*/
CommandResult execute();
/**
* Annule la commande (remet l'état précédent).
*/
void undo();
/**
* Description lisible pour l'historique et l'audit.
*/
String getDescription();
/**
* Indique si cette commande supporte l'annulation.
*/
default boolean isUndoable() {
return true;
}
}
/**
* `data` est un DTO (par exemple ArticleResponse), jamais une entité JPA :
* l'entité fuirait vers la couche web avec ses relations paresseuses.
*/
public record CommandResult(
boolean success,
String message,
Object data
) {
public static CommandResult ok(String message) {
return new CommandResult(true, message, null);
}
public static CommandResult ok(String message, Object data) {
return new CommandResult(true, message, data);
}
public static CommandResult fail(String message) {
return new CommandResult(false, message, null);
}
}
public record ArticleData(
@NotBlank String title,
@NotBlank String content,
String author
) {}
public record ArticleResponse(Long id, String title, ArticleStatus status) {
public static ArticleResponse from(Article article) {
return new ArticleResponse(article.getId(), article.getTitle(), article.getStatus());
}
}
Les commandes concrètes : chacune porte son contexte et sa logique d'annulation.
public class CreateArticleCommand implements Command {
private final ArticleRepository repository;
private final ArticleData data;
private Long createdId; // Mémorisé pour le undo et le redo
public CreateArticleCommand(ArticleRepository repository, ArticleData data) {
this.repository = repository;
this.data = data;
}
@Override
public CommandResult execute() {
if (createdId != null) {
// Redo : on restaure l'article annulé, même identifiant, sans en recréer un
Article existing = repository.findById(createdId).orElseThrow();
existing.setStatus(ArticleStatus.DRAFT);
return CommandResult.ok("Article restauré : " + existing.getTitle(),
ArticleResponse.from(repository.save(existing)));
}
Article article = Article.builder()
.title(data.title())
.content(data.content())
.author(data.author())
.status(ArticleStatus.DRAFT)
.createdAt(LocalDateTime.now())
.build();
Article saved = repository.save(article);
this.createdId = saved.getId();
return CommandResult.ok(
"Article créé : " + saved.getTitle(),
ArticleResponse.from(saved)
);
}
@Override
public void undo() {
if (createdId != null) {
// Suppression logique, comme DeleteArticleCommand : pas de deleteById
Article article = repository.findById(createdId).orElseThrow();
article.setStatus(ArticleStatus.DELETED);
repository.save(article);
}
}
@Override
public String getDescription() {
return "Création article : " + data.title();
}
}
public class UpdateArticleCommand implements Command {
private final ArticleRepository repository;
private final Long articleId;
private final ArticleData newData;
private ArticleData previousData; // Sauvegarde pour le undo
public UpdateArticleCommand(ArticleRepository repository,
Long articleId, ArticleData newData) {
this.repository = repository;
this.articleId = articleId;
this.newData = newData;
}
@Override
public CommandResult execute() {
Article article = repository.findById(articleId)
.orElseThrow(() -> new EntityNotFoundException("Article " + articleId));
// Sauvegarde de l'état précédent AVANT modification
this.previousData = new ArticleData(
article.getTitle(), article.getContent(), article.getAuthor()
);
article.setTitle(newData.title());
article.setContent(newData.content());
article.setUpdatedAt(LocalDateTime.now());
Article saved = repository.save(article);
return CommandResult.ok("Article mis à jour : " + saved.getTitle(),
ArticleResponse.from(saved));
}
@Override
public void undo() {
if (previousData != null) {
Article article = repository.findById(articleId).orElseThrow();
article.setTitle(previousData.title());
article.setContent(previousData.content());
repository.save(article);
}
}
@Override
public String getDescription() {
return "Modification article #" + articleId;
}
}
public class DeleteArticleCommand implements Command {
private final ArticleRepository repository;
private final Long articleId;
private ArticleStatus previousStatus; // Pour restaurer l'état exact
public DeleteArticleCommand(ArticleRepository repository, Long articleId) {
this.repository = repository;
this.articleId = articleId;
}
@Override
public CommandResult execute() {
Article article = repository.findById(articleId)
.orElseThrow(() -> new EntityNotFoundException("Article " + articleId));
// Suppression logique : l'identifiant, les commentaires et les liens
// qui référencent l'article restent valides.
this.previousStatus = article.getStatus();
article.setStatus(ArticleStatus.DELETED);
repository.save(article);
return CommandResult.ok("Article supprimé : " + article.getTitle());
}
@Override
public void undo() {
if (previousStatus != null) {
Article article = repository.findById(articleId).orElseThrow();
article.setStatus(previousStatus);
repository.save(article);
}
}
@Override
public String getDescription() {
return "Suppression article #" + articleId;
}
}
PublishArticleCommand suit le même schéma que DeleteArticleCommand : elle mémorise le statut précédent, passe l'article à PUBLISHED, et l'annulation restaure l'ancien statut.
public class PublishArticleCommand implements Command {
private final ArticleRepository repository;
private final Long articleId;
private ArticleStatus previousStatus;
public PublishArticleCommand(ArticleRepository repository, Long articleId) {
this.repository = repository;
this.articleId = articleId;
}
@Override
public CommandResult execute() {
Article article = repository.findById(articleId)
.orElseThrow(() -> new EntityNotFoundException("Article " + articleId));
this.previousStatus = article.getStatus();
article.setStatus(ArticleStatus.PUBLISHED);
return CommandResult.ok("Article publié : " + article.getTitle(),
ArticleResponse.from(repository.save(article)));
}
@Override
public void undo() {
if (previousStatus != null) {
Article article = repository.findById(articleId).orElseThrow();
article.setStatus(previousStatus);
repository.save(article);
}
}
@Override
public String getDescription() {
return "Publication article #" + articleId;
}
}
Le choix de la suppression logique est volontaire, aussi bien pour DeleteArticleCommand que pour l'annulation de CreateArticleCommand. Un deleteById à l'annulation d'une création, suivi d'une recréation au redo, donnerait un nouvel identifiant à chaque aller-retour. De même, recréer l'entité à l'annulation d'une suppression (par exemple avec setId(null) puis save()) produit une nouvelle ligne avec un nouvel identifiant : les commentaires, favoris, URLs ou clés étrangères qui pointaient vers l'ancien article sont cassés. Avec un statut DELETED, l'annulation redonne exactement le même objet. Les requêtes de lecture doivent alors exclure ce statut (ou s'appuyer sur @SQLRestriction, disponible à partir d'Hibernate 6.3 et absente de la 6.2, en remplacement de @Where).
L'historique : une pile undo/redo par utilisateur. Un historique unique partagé par tous les administrateurs serait une erreur : le « undo » de l'un annulerait l'action d'un autre, et une ArrayDeque n'est pas thread-safe. CommandHistory n'est donc plus un singleton Spring, mais un objet simple, dont une instance est attribuée à chaque utilisateur :
public class CommandHistory {
private static final Logger log = LoggerFactory.getLogger(CommandHistory.class);
private static final int MAX_HISTORY_SIZE = 50;
private final Deque<Command> undoStack = new ArrayDeque<>();
private final Deque<Command> redoStack = new ArrayDeque<>();
/**
* Exécute une commande et l'ajoute à l'historique.
*/
public synchronized CommandResult executeCommand(Command command) {
CommandResult result = command.execute();
if (result.success() && command.isUndoable()) {
undoStack.push(command);
redoStack.clear(); // Une nouvelle action invalide le redo
// Limite la taille de l'historique
while (undoStack.size() > MAX_HISTORY_SIZE) {
undoStack.removeLast();
}
}
log.info("Commande exécutée : {} [{}]",
command.getDescription(), result.success() ? "OK" : "ÉCHEC");
return result;
}
/**
* Annule la dernière commande.
*/
public synchronized void undo() {
if (undoStack.isEmpty()) {
throw new IllegalStateException("Rien à annuler");
}
Command command = undoStack.pop();
command.undo();
redoStack.push(command);
log.info("Undo : {}", command.getDescription());
}
/**
* Refait la dernière commande annulée.
*/
public synchronized void redo() {
if (redoStack.isEmpty()) {
throw new IllegalStateException("Rien à refaire");
}
Command command = redoStack.pop();
CommandResult result = command.execute();
if (result.success()) {
undoStack.push(command);
} else {
redoStack.push(command); // l'échec ne fait pas perdre la commande
}
log.info("Redo : {}", command.getDescription());
}
public synchronized List<String> getUndoHistory() {
return undoStack.stream().map(Command::getDescription).toList();
}
public synchronized boolean canUndo() { return !undoStack.isEmpty(); }
public synchronized boolean canRedo() { return !redoStack.isEmpty(); }
}
@Service
public class CommandHistoryRegistry {
private final Map<String, CommandHistory> histories = new ConcurrentHashMap<>();
public CommandHistory forUser(String username) {
return histories.computeIfAbsent(username, u -> new CommandHistory());
}
}
Cette solution a des limites à connaître. L'historique vit en mémoire : il est perdu au redémarrage de l'application et n'est pas partagé entre plusieurs instances. Si l'undo doit survivre à un redémarrage ou fonctionner en cluster, il faut persister les commandes (journal en base avec les données nécessaires à l'annulation) ou s'appuyer sur un mécanisme de versionnage du contenu. Enfin, entre l'action et son annulation, un autre administrateur a pu modifier le même article : un verrouillage optimiste (@Version) permet de détecter ce conflit au lieu d'écraser silencieusement son travail.
Utilisation dans un contrôleur
Le contrôleur reste fin : il délègue à un service qui porte la transaction et choisit l'historique de l'utilisateur authentifié.
@Service
public class ArticleCommandService {
private final CommandHistoryRegistry histories;
private final ArticleRepository articleRepository;
public ArticleCommandService(CommandHistoryRegistry histories,
ArticleRepository articleRepository) {
this.histories = histories;
this.articleRepository = articleRepository;
}
@Transactional
public CommandResult create(String username, ArticleData data) {
return histories.forUser(username).executeCommand(
new CreateArticleCommand(articleRepository, data));
}
@Transactional
public CommandResult update(String username, Long id, ArticleData data) {
return histories.forUser(username).executeCommand(
new UpdateArticleCommand(articleRepository, id, data));
}
@Transactional
public CommandResult delete(String username, Long id) {
return histories.forUser(username).executeCommand(
new DeleteArticleCommand(articleRepository, id));
}
@Transactional
public void undo(String username) {
histories.forUser(username).undo();
}
@Transactional
public void redo(String username) {
histories.forUser(username).redo();
}
public List<String> history(String username) {
return histories.forUser(username).getUndoHistory();
}
}
@RestController
@RequestMapping("/api/admin/articles")
public class AdminArticleController {
private final ArticleCommandService commandService;
public AdminArticleController(ArticleCommandService commandService) {
this.commandService = commandService;
}
@PostMapping
public ResponseEntity<CommandResult> createArticle(
@Valid @RequestBody ArticleData data, Authentication auth) {
CommandResult result = commandService.create(auth.getName(), data);
return ResponseEntity.status(HttpStatus.CREATED).body(result);
}
@PutMapping("/{id}")
public ResponseEntity<CommandResult> updateArticle(
@PathVariable Long id, @Valid @RequestBody ArticleData data,
Authentication auth) {
return ResponseEntity.ok(commandService.update(auth.getName(), id, data));
}
@DeleteMapping("/{id}")
public ResponseEntity<CommandResult> deleteArticle(
@PathVariable Long id, Authentication auth) {
return ResponseEntity.ok(commandService.delete(auth.getName(), id));
}
@PostMapping("/undo")
public ResponseEntity<Map<String, String>> undo(Authentication auth) {
commandService.undo(auth.getName());
return ResponseEntity.ok(Map.of("message", "Action annulée"));
}
@PostMapping("/redo")
public ResponseEntity<Map<String, String>> redo(Authentication auth) {
commandService.redo(auth.getName());
return ResponseEntity.ok(Map.of("message", "Action refaite"));
}
@GetMapping("/history")
public ResponseEntity<List<String>> getHistory(Authentication auth) {
return ResponseEntity.ok(commandService.history(auth.getName()));
}
}
Command dans l'écosystème Java
Le pattern Command, ou des idées proches, se retrouve à plusieurs endroits de l'écosystème Java :
Runnable,CallableetExecutor: les formes les plus réduites du pattern. Une tâche est un objet qu'on peut mettre en file, exécuter plus tard ou sur un autre thread.- Files de messages (RabbitMQ, Kafka) : un message peut transporter l'intention d'une action (« envoyer cet e-mail »), c'est-à-dire une commande sérialisée. Beaucoup de messages sont en revanche des événements (« la commande a été créée »), ce qui est autre chose.
- CQRS (Command Query Responsibility Segregation) : ce style d'architecture sépare les modèles d'écriture et de lecture, et nomme « commandes » les messages d'écriture. Il s'inspire de la même idée, mais reste un pattern d'architecture distinct du Command du GoF.
Ce qu'il faut retenir
| Pattern | Quand l'utiliser | Exemples JDK / Spring |
|---|---|---|
| Command | Actions annulables, file d'attente, audit | Runnable, Callable, Executor |
La partie 2 traite Chain of Responsibility, pour enchaîner des traitements séquentiels, et Mediator, pour centraliser la communication entre composants.
Partie précédente : Template Method et State. Partie suivante : Chain of Responsibility et Mediator.
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)
- Comportementaux, communication : Command (1/3, cet article) · Chain of Responsibility et Mediator (2/3) · Iterator et Visitor (3/3)