Design Patterns Comportementaux en Java, communication (partie 1/3) : Command

Abdoul Kadri ASSOUMANE MAMANE
Abdoul Kadri ASSOUMANE MAMANE Auteur
7 min de lecture
339 vues

Écouter l'article

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


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

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

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, Callable et Executor : 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.

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.