Article original : How to Build Modern Clean Architecture
Par Bertil Muth
Clean Architecture est un terme inventé par Robert C. Martin. L'idée principale est que les entités et les cas d'utilisation sont indépendants des frameworks, de l'interface utilisateur, de la base de données et des services externes.
Un style d'architecture propre a un effet positif sur la maintenabilité parce que :
- Nous pouvons tester les entités de domaine et les cas d'utilisation sans framework, interface utilisateur ou infrastructure.
- Les décisions technologiques peuvent changer sans affecter le code de domaine, et vice versa. Il est même possible de passer à un nouveau framework avec un effort limité.
Mon objectif est d'aplatir la courbe d'apprentissage et de réduire l'effort qu'il pourrait vous prendre pour implémenter une architecture propre. C'est pourquoi j'ai créé les bibliothèques Modern Clean Architecture.
Dans cet article, je vais vous montrer comment créer une application avec une architecture propre moderne, d'un front-end HTML/JavaScript à un back-end Spring Boot. L'accent sera mis sur le back-end.
Commençons par un aperçu de l'application exemple – un classique intemporel, l'application TODO.
Application exemple de liste de tâches
Une liste de tâches est une collection de tâches. Une tâche a un nom, et est soit complétée soit non. En tant qu'utilisateur, vous pouvez :
- Créer une seule liste de tâches et la persister
- Ajouter une tâche
- Compléter une tâche, ou la "décompléter"
- Supprimer une tâche
- Lister toutes les tâches
- Filtrer les tâches complétées/non complétées
Voici à quoi ressemble une liste de tâches avec 1 tâche non complétée et 2 tâches complétées :

Nous commencerons par le cœur de l'application, les entités de domaine. Ensuite, nous travaillerons vers l'extérieur jusqu'au front-end.
Les entités de domaine
Les entités de domaine centrales sont TodoList et Task.
L'entité TodoList contient :
- un identifiant unique,
- une liste de tâches,
- des méthodes de domaine pour ajouter, compléter et supprimer des tâches
L'entité TodoList ne contient pas de setters publics. Les setters briseraient l'encapsulation appropriée.
Voici une partie de l'entité TodoList. Les annotations Lombok raccourcissent le code.
public class TodoList implements AggregateRoot<TodoList, TodoListId> {
private final TodoListId id;
private final List<Task> tasks;
@Value(staticConstructor = "of")
public static class TodoListId implements Identifier {
@NonNull
UUID uuid;
}
@Override
public TodoListId getId() {
return id;
}
...
public TaskId addTask(String taskName) {
if (taskName == null || isWhitespaceName(taskName)) {
throw new IllegalTaskName("Veuillez spécifier un nom de tâche non nul et non vide !");
}
TaskId taskId = add(TaskId.of(UUID.randomUUID()), taskName, false);
return taskId;
}
...
public void deleteTask(TaskId task) {
Optional<Task> foundTask = findTask(task);
foundTask.ifPresent(tasks::remove);
}
...
}
À quoi sert l'interface AggregateRoot ? Aggregate root est un terme de Domain Driven Design (DDD) par Eric Evans :
Un agrégat est un ensemble d'objets associés que nous traitons comme une unité pour les modifications de données. Chaque agrégat a une racine et une frontière. La frontière définit ce qui est à l'intérieur de l'agrégat. La racine est une entité spécifique unique contenue dans l'agrégat.
Nous pouvons changer l'état de l'agrégat uniquement par la racine de l'agrégat. Dans notre exemple, cela signifie : nous devons toujours utiliser le TodoList pour ajouter, supprimer ou modifier une tâche.
Cela permet à TodoList de faire respecter les contraintes. Par exemple, nous ne pouvons pas ajouter une tâche avec un nom vide à la liste.
L'interface AggregateRoot fait partie de la bibliothèque jMolecules. Cette bibliothèque rend les concepts DDD explicites dans le code de domaine. Pendant la construction, un plugin ByteBuddy mappe les annotations aux annotations Spring Data.
Ainsi, nous n'avons qu'un seul modèle, à la fois pour représenter les concepts de domaine et la persistance. Pourtant, nous n'avons aucune annotation spécifique à la persistance dans le code de domaine. Nous ne nous lions à aucun framework.
La classe Task est similaire, mais elle implémente l'interface Entity de jMolecules à la place :
public class Task implements Entity<TodoList, TaskId> {
private final TaskId id;
private final String name;
private final boolean completed;
@Value(staticConstructor = "of")
public static class TaskId implements Identifier {
@NonNull
UUID uuid;
}
Task(@NonNull TaskId id, @NonNull String name, boolean completed) {
this.id = id;
this.name = name;
this.completed = completed;
}
}
Le constructeur de Task est package private. Ainsi, nous ne pouvons pas créer une instance de Task depuis l'extérieur du package de domaine. Et la classe Task est immutable. Aucun changement de son état n'est possible depuis l'extérieur de la frontière de l'agrégat.
Nous avons besoin d'un dépôt pour stocker le TodoList. Pour rester dans les termes de domaine dans le code de domaine, il est appelé TodoLists :
public interface TodoLists extends Repository<TodoList, TodoListId> {
TodoList save(TodoList entity);
Optional<TodoList> findById(TodoListId id);
Iterable<TodoList> findAll();
}
Encore une fois, le code utilise une annotation jMolecues : Repository. Pendant la construction, le plugin ByteBuddy le traduit en un dépôt Spring Data.
Nous allons sauter les exceptions de domaine, car il n'y a rien de spécial à leur sujet. C'est le package de domaine complet.
Le comportement de l'application (et les cas d'utilisation)
Ensuite, nous définissons le comportement de l'application visible par l'utilisateur final. Toute interaction de l'utilisateur avec l'application se fait comme suit :
- L'interface utilisateur envoie une requête.
- Le backend réagit en exécutant un gestionnaire de requêtes. Le gestionnaire de requêtes fait tout ce qui est nécessaire pour satisfaire la requête :
- Accéder à la base de données
- Appeler des services externes
- Appeler des méthodes d'entité de domaine
- Le gestionnaire de requêtes peut retourner une réponse.
Nous implémentons un gestionnaire de requêtes avec une interface fonctionnelle Java 8.
Un gestionnaire qui retourne une réponse implémente l'interface java.util.Function. Voici le code du gestionnaire AddTask. Ce gestionnaire
- extrait l'identifiant de la liste de tâches et le nom de la tâche d'une AddTaskRequest_,
- trouve la liste de tâches dans le dépôt (ou lance une exception),
- ajoute une tâche avec le nom de la requête à la liste,
- retourne une AddTaskResponse avec l'identifiant de la tâche ajoutée.
@AllArgsConstructor
class AddTask implements Function<AddTaskRequest, AddTaskResponse> {
@NonNull
private final TodoLists repository;
@Override
public AddTaskResponse apply(@NonNull AddTaskRequest request) {
final UUID todoListUuid = request.getTodoListUuid();
final String taskName = request.getTaskName();
final TodoList todoList = repository.findById(TodoListId.of(todoListUuid))
.orElseThrow(() -> new TodoListNotFound("Le dépôt ne contient pas de TodoList avec l'identifiant " + todoListUuid));
TaskId taskId = todoList.addTask(taskName);
repository.save(todoList);
return new AddTaskResponse(taskId.getUuid());
}
}
Lombok crée un constructeur avec l'interface de dépôt TodoLists comme argument de constructeur. Nous passons toute dépendance externe sous forme d'interface au constructeur du gestionnaire.
Les requêtes et les réponses sont des objets immutables :
@Value
public class AddTaskRequest {
@NonNull
UUID todoListUuid;
@NonNull
String taskName;
}
Les bibliothèques Modern Clean Architecture les (dé)sérialisent depuis/vers JSON.
Ensuite, un exemple de gestionnaire qui ne retourne pas de réponse. Le gestionnaire DeleteTask reçoit une DeleteTaskRequest. Comme le gestionnaire ne retourne pas de réponse, il implémente l'interface Consumer.
@AllArgsConstructor
class DeleteTask implements Consumer<DeleteTaskRequest> {
@NonNull
private final TodoLists repository;
@Override
public void accept(@NonNull DeleteTaskRequest request) {
final UUID todoListUuid = request.getTodoListUuid();
final UUID taskUuid = request.getTaskUuid();
final TodoList todoList = repository.findById(TodoListId.of(todoListUuid))
.orElseThrow(() -> new TodoListNotFound("Le dépôt ne contient pas de TodoList avec l'identifiant " + todoListUuid));
todoList.deleteTask(TaskId.of(taskUuid));
repository.save(todoList);
}
}
Une question reste : qui crée ces gestionnaires ?
La réponse : une classe implémentant l'interface BehaviorModel. Le modèle de comportement mappe chaque classe de requête au gestionnaire de requêtes pour ce type de requête.
Voici une partie du TodoListBehaviorModel :
@AllArgsConstructor
public class TodoListBehaviorModel implements BehaviorModel {
@NonNull
private final TodoLists todoLists;
...
@Override
public Model model() {
return Model.builder()
.user(FindOrCreateListRequest.class).systemPublish(findOrCreateList())
.user(AddTaskRequest.class).systemPublish(addTask())
.user(ToggleTaskCompletionRequest.class).system(toggleTaskCompletion())
...
.build();
}
private Function<FindOrCreateListRequest, FindOrCreateListResponse> findOrCreateList() {
return new FindOrCreateList(todoLists);
}
private Function<AddTaskRequest, AddTaskResponse> addTask() {
return new AddTask(todoLists);
}
private Consumer<ToggleTaskCompletionRequest> toggleTaskCompletion() {
return new ToggleTaskCompletion(todoLists);
}
...
}
Les instructions user(...) définissent les classes de requête. Nous utilisons systemPublish(...) pour les gestionnaires qui retournent une réponse, et system(...) pour les gestionnaires qui ne le font pas.
Le modèle de comportement a un constructeur avec des dépendances externes passées en tant qu'interfaces. Et il crée tous les gestionnaires et injecte les dépendances appropriées dans ceux-ci.
En configurant les dépendances du modèle de comportement, nous configurons tous les gestionnaires. C'est exactement ce que nous voulons : un endroit central où nous pouvons changer ou basculer les dépendances technologiques. C'est ainsi que les décisions technologiques peuvent changer sans affecter le code de domaine.
La couche Web de l'application (les adaptateurs)
La couche Web dans une architecture propre moderne peut être très fine. Dans sa forme la plus simple, elle se compose de seulement 2 classes :
- Une classe pour la configuration des dépendances
- Une classe pour la gestion des exceptions
Voici la classe TodoListConfiguration :
@Configuration
class TodoListConfiguration {
@Bean
TodoListBehaviorModel behaviorModel(TodoLists repository) {
return new TodoListBehaviorModel(repository);
}
}
Spring injecte l'implémentation de l'interface de dépôt TodoLists dans la méthode behaviorModel(...). Cette méthode crée une implémentation de modèle de comportement en tant que bean.
Si l'application utilise des services externes, la classe de configuration est l'endroit pour créer les instances concrètes en tant que beans. Et les injecter dans le modèle de comportement.
Alors, où sont tous les contrôleurs ?
Eh bien, il n'y en a pas que vous devez créer. Au moins si vous ne gérez que les requêtes POST. (Pour la gestion des requêtes GET, voir la section Q&A plus tard.)
La bibliothèque spring-behavior-web fait partie des bibliothèques Modern Clean Architecture. Nous définissons un seul endpoint pour les requêtes. Nous spécifions l'URL de cet endpoint dans le fichier application.properties :
behavior.endpoint = /todolist
Si cette propriété existe, spring-behavior-web configure un contrôleur pour l'endpoint en arrière-plan. Ce contrôleur reçoit les requêtes POST.
Nous n'avons pas besoin d'écrire de code spécifique à Spring pour ajouter un nouveau comportement. Et nous n'avons pas besoin d'ajouter ou de modifier un contrôleur.
Voici ce qui se passe lorsque l'endpoint reçoit une requête POST :
- spring-behavior-web désérialise la requête,
- spring-behavior-web passe la requête à un comportement configuré par le modèle de comportement,
- le comportement passe la requête au gestionnaire de requêtes approprié (s'il y en a un),
- spring-behavior-web sérialise la réponse et la passe à l'endpoint (s'il y en a une).
Par défaut, spring-behavior-web enveloppe chaque appel à un gestionnaire de requêtes dans une transaction.
Comment envoyer des requêtes POST
Une fois que nous démarrons l'application Spring Boot, nous pouvons envoyer des requêtes POST à l'endpoint.
Nous incluons une propriété @type dans le contenu JSON afin que spring-behavior-web puisse déterminer la bonne classe de requête lors de la désérialisation.
Par exemple, voici une commande curl valide de l'application To Do List. Elle envoie une FindOrCreateListRequest à l'endpoint.
curl -H "Content-Type: application/json" -X POST -d '{"@type": "FindOrCreateListRequest"}' [http://localhost:8080/todolist](http://localhost:8080/todolist)
Et voici la syntaxe correspondante à utiliser dans Windows PowerShell :
iwr http://localhost:8080/todolist -Method 'POST' -Headers @{'Content-Type' = 'application/json'} -Body '{"@type": "FindOrCreateListRequest"}'
Gestion des exceptions
La gestion des exceptions avec spring-behavior-web n'est pas différente de celle des applications Spring "normales". Nous créons une classe annotée avec @ControllerAdvice. Et nous plaçons des méthodes annotées avec @ExceptionHandler dans celle-ci.
Voir TodoListExceptionHandling par exemple :
@ControllerAdvice
class TodoListExceptionHandling {
@ExceptionHandler({ Exception.class })
public ResponseEntity<ExceptionResponse> handle(Exception e) {
return responseOf(e, BAD_REQUEST);
}
...
}
Notez que dans une application réelle, les différents types d'exceptions nécessitent un traitement différent.
Le front-end de l'application
Le front-end de l'application To Do List se compose de :
- une page HTML,
- un fichier CSS pour la mise en forme,
- et un fichier JavaScript main.js
Nous nous concentrons sur main.js ici. Il envoie des requêtes et met à jour la page web.
Voici une partie de son contenu :
// URL pour poster toutes les requêtes,
// Doit être la même que celle dans application.properties
// (Voir https://github.com/bertilmuth/modern-clean-architecture/blob/main/samples/todolist/src/main/resources/application.properties)
const BEHAVIOR_ENDPOINT = "/todolist";
//variables
var todoListUuid;
...
// fonctions
function restoreList(){
const request = {"@type":"FindOrCreateListRequest"};
post(request, function(response){
todoListUuid = response.todoListUuid;
restoreTasksOf(todoListUuid);
});
}
function restoreTasksOf(todoListUuid) {
const request = {"@type":"ListTasksRequest", "todoListUuid":todoListUuid};
post(request, function(response){
showTasks(response.tasks);
});
}
...
function post(jsonObject, responseHandler) {
const xhr = new XMLHttpRequest();
xhr.open("POST", BEHAVIOR_ENDPOINT);
xhr.setRequestHeader("Accept", "application/json");
xhr.setRequestHeader("Content-Type", "application/json");
xhr.onreadystatechange = function() {
if (xhr.readyState === 4) {
response = xhr.responseText.length > 0? JSON.parse(xhr.response) : "";
if(response.error){
alert('Statut ' + response.status + ' "' + response.message + '"');
} else{
responseHandler(response);
}
}
};
const jsonString = JSON.stringify(jsonObject);
xhr.send(jsonString);
}
Ainsi, par exemple, voici l'objet JSON pour une ListTasksRequest :
const request = {@type:ListTasksRequest, todoListUuid:todoListUuid};
La méthode post(...) envoie la requête au backend et passe la réponse au gestionnaire de réponse (la fonction de rappel que vous avez passée en tant que deuxième paramètre).
C'est tout ce qui concerne l'application To Do List.
Questions & Réponses
Que faire si...
... je veux envoyer des requêtes GET au lieu de requêtes POST ?
... je veux que la couche web évolue séparément du comportement ?
... je veux utiliser un framework différent de Spring ?
... j'ai une application beaucoup plus grande que l'exemple To Do List. Comment la structurer ?
Voici les réponses.
Conclusion
Dans cet article, je vous ai présenté une manière particulière d'implémenter une Clean Architecture. Il existe de nombreuses autres façons.
Mon objectif est de réduire l'effort de construction d'une Clean Architecture et d'aplatir la courbe d'apprentissage.
Pour y parvenir, les bibliothèques Modern Clean Architecture fournissent les fonctionnalités suivantes :
- Sérialisation des requêtes et réponses immutables sans annotations spécifiques à la sérialisation.
- Aucune nécessité de DTOs. Vous pouvez utiliser les mêmes objets immutables pour les requêtes/réponses dans la couche web et les cas d'utilisation.
- Endpoint générique qui reçoit et transmet les requêtes POST. Un nouveau comportement et une nouvelle logique de domaine peuvent être ajoutés et utilisés sans avoir besoin d'écrire du code spécifique au framework.
Dans mon prochain article, je décrirai comment tester une Modern Clean Architecture.
Je vous invite à visiter la page Modern Clean Architecture sur GitHub.
Voir l'application exemple To Do List.
Et n'hésitez pas à partager vos commentaires avec moi. Qu'en pensez-vous ?
Si vous souhaitez suivre ce que je fais ou me laisser un message, suivez-moi sur LinkedIn ou Twitter.
Remerciements
Merci à Surya Shakti pour avoir publié le code original du front-end uniquement de la liste de tâches.
Merci à Oliver Drotbohm pour m'avoir dirigé vers la bibliothèque jMolecules.