1. POST sur la collection : création dans /reading-list
Quand l'API devient non seulement une « vitrine », mais aussi une « caisse », nous avons une nouvelle responsabilité : changer l'état du système de manière prévisible et identique pour tous les clients. La première étape la plus fréquente est la création d'une nouvelle ressource au sein de la collection, et en HTTP, cela s'exprime traditionnellement par POST sur l'URI de la collection. Dans notre cas, la collection est la liste de lecture, donc nous créons un élément via POST /api/v1/reading-list.
Il faut bien saisir l'image sémantique. L'URI /api/v1/reading-list est « toute la liste » (collection). Si le client fait un GET, il demande à voir le contenu. Si le client fait un POST, il dit : « ajoute un nouvel élément à cette liste ». Pas « remplace toute la liste », pas « met à jour quelque chose par id », mais bien « crée un nouvel objet, et je t'enverrai les détails dans le body ».
Voici à quoi ressemble la requête au niveau humain (dans Postman, vous verrez approximativement la même chose, sans le format HTTP brut) :
POST /api/v1/reading-list HTTP/1.1
Content-Type: application/json
{
"title": "Clean Code",
"author": "Robert C. Martin",
"status": "PLANNED",
"externalId": "OL12345M",
"comment": "Trouver une édition papier"
}
Remarquez un détail très caractéristique : dans la requête, il n'y a pas d'id. Ce n'est pas un oubli du client, c'est notre accord : l'identifiant est créé sur le serveur, car c'est le serveur qui est responsable de l'unicité et de l'intégrité interne des données. Si l'id venait du client, on arriverait très vite au jeu amusant « devine qui a accidentellement écrasé le livre d'un autre » (spoiler : généralement c'est le backend qu'il faut deviner à 3 heures du matin).
2. CreateReadingItemRequest
Quand vous faites une opération create pour la première fois, la main se tend toute seule : « Mais on a ReadingListItem, on va juste le désérialiser depuis le JSON ». C'est une impulsion normale, mais elle mène rapidement à de la confusion : le modèle domaine commence à vivre selon les règles de la couche HTTP, et la couche HTTP découvre soudain des détails du domaine qu'elle ne devrait pas connaître. C'est pourquoi nous créons un DTO de requête séparé — CreateReadingItemRequest.
L'idée est très simple : le DTO de requête décrit exactement ce que le client est en droit d'envoyer lors de la création. Le client envoie les données sur le livre et le statut souhaité, et le serveur ajoute ce qui relève de la « cuisine interne » : id, d'éventuelles valeurs par défaut et des règles métier. Dans notre projet, il est pratique de définir le DTO de requête comme un record, car c'est un « conteneur de données », pas un objet avec un comportement riche.
package com.example.readlater.readinglist.dto;
import com.example.readlater.readinglist.domain.ReadingStatus;
// Request DTO : décrit uniquement ce que le client a le droit d'envoyer lors de la création
public record CreateReadingItemRequest(
String title, // Titre du livre (obligatoire par définition)
String author, // Auteur (obligatoire par définition)
ReadingStatus status, // Statut souhaité au départ
String externalId, // Identifiant dans le catalogue externe (peut être null)
String comment // Commentaire de l'utilisateur (peut être null)
) {}
Il y a encore un détail pratique important. Les champs externalId et comment sont optionnels, c'est-à-dire qu'ils peuvent être null. Au niveau de cette leçon, nous n'approfondissons pas la validation détaillée des chaînes vides et du cas « et si le statut n'est pas du tout envoyé », car notre objectif actuel est de construire un flux de création compréhensible et de fixer les limites des couches. Mais même sans validation complète, le DTO de requête est déjà utile : il sépare le contrat du JSON entrant de la façon dont nous stockons l'objet en interne.
3. Handler : body → CreateReadingItemRequest
Dans un monde sans Spring MVC, le handler est l'endroit où vous devenez soudainement à la fois « contrôleur », « binder », et un peu « la personne qui lit les octets depuis un InputStream ». C'est normal pour un projet pédagogique, mais il est important de garder le contrôle : le handler doit s'occuper de la partie HTTP (lire la requête → extraire les données → appeler le service), et non se transformer en sac de logique métier et de règles de stockage. Dans notre projet, c'est le même ReadingListHttpHandler dans lequel vivront ensuite les autres routes d'écriture ; pour l'instant, nous nous intéressons seulement à la branche create.
Commençons par une petite utilitaire : lire proprement le corps de la requête sous forme de chaîne. Oui, c'est le moment où vous rencontrez à nouveau un InputStream et comprenez pourquoi les frameworks aiment tant le cacher aux gens (ils se soucient de votre santé mentale).
package com.example.readlater.common.util;
import com.sun.net.httpserver.HttpExchange;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
public final class HttpBodyReader {
private HttpBodyReader() {
// Classe utilitaire : aucune instance nécessaire
}
public static String readBody(HttpExchange exchange) throws IOException {
// Important de fermer l'InputStream : on utilise try-with-resources
try (var is = exchange.getRequestBody()) {
// On lit le corps de la requête entièrement et on le décode en UTF-8
return new String(is.readAllBytes(), StandardCharsets.UTF_8);
}
}
}
Maintenant, dans le handler, nous faisons trois actions simples : lire le body, désérialiser le JSON en CreateReadingItemRequest, passer le DTO au service. Notez : il n'y a pas encore de « comment stocker », « comment générer l'id », « comment vérifier l'unicité ». Le handler n'est pas obligé de le savoir — il se contente de transmettre la requête plus loin.
package com.example.readlater.readinglist.http;
import com.example.readlater.common.util.HttpBodyReader;
import com.example.readlater.readinglist.domain.ReadingListItem;
import com.example.readlater.readinglist.dto.CreateReadingItemRequest;
import com.example.readlater.readinglist.service.ReadingListService;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.sun.net.httpserver.HttpExchange;
import java.io.IOException;
public class ReadingListHttpHandler {
private final ObjectMapper objectMapper;
private final ReadingListService readingListService;
public ReadingListHttpHandler(ObjectMapper objectMapper, ReadingListService readingListService) {
// Les dépendances sont explicitement passées de l'extérieur : le handler ne « crée » rien lui-même
this.objectMapper = objectMapper;
this.readingListService = readingListService;
}
public ReadingListItem handleCreate(HttpExchange exchange) throws IOException {
// 1) On récupère le body sous forme de chaîne
String body = HttpBodyReader.readBody(exchange);
// 2) On transforme le JSON en request DTO (contrat des données d'entrée)
CreateReadingItemRequest request = objectMapper.readValue(body, CreateReadingItemRequest.class);
// 3) On délègue le sens métier au service
return readingListService.create(request);
}
}
Remarquez : handleCreate ne nous mène ici que jusqu'à l'objet domaine créé. La réponse HTTP elle-même n'est pas encore construite : d'abord, nous devons obtenir un created correct, sans mélanger la logique de transport avec le service et le stockage.
4. Service : création de l'item et vérification du externalId
Le service est l'endroit où nous commençons enfin à faire du « métier », et non du « transport ». Si le handler est la réception (qui accueille poliment la requête et la dirige), alors le service est le gestionnaire de processus : il décide ce que signifie exactement « créer un élément de la liste de lecture » dans les termes de notre application. Le service ne doit pas lire le JSON et ne doit pas écrire d'octets dans le body de la réponse, mais il doit être le maître des règles métier.
Pour commencer, montrons à quoi ressemble l'entité domaine sous une forme simplifiée. Nous avons un id, des champs obligatoires, un statut et quelques valeurs optionnelles. Contrairement au DTO, l'objet domaine vit presque toujours « plus longtemps » et peut avoir des méthodes de changement d'état.
package com.example.readlater.readinglist.domain;
public class ReadingListItem {
private final long id;
private String title;
private String author;
private ReadingStatus status;
private String externalId;
private String comment;
public ReadingListItem(long id, String title, String author, ReadingStatus status, String externalId, String comment) {
// l'id est défini à la création et ne change pas ensuite
this.id = id;
// Les autres champs — l'état de l'objet domaine
this.title = title;
this.author = author;
this.status = status;
this.externalId = externalId;
this.comment = comment;
}
public long getId() {
// id est un identifiant technique, nécessaire au stockage/références
return id;
}
}
Maintenant, la méthode create du service elle-même. Elle fait exactement ce qu'elle promet : vérifie la règle métier d'unicité du externalId (si externalId est défini), demande au repository de fournir un nouvel id, assemble l'objet domaine et le sauvegarde. Pas de HTTP, pas d'annotations Jackson, seulement du sens métier.
package com.example.readlater.readinglist.service;
import com.example.readlater.readinglist.domain.ReadingListItem;
import com.example.readlater.readinglist.dto.CreateReadingItemRequest;
import com.example.readlater.readinglist.repository.ReadingListRepository;
public class ReadingListService {
private final ReadingListRepository repository;
public ReadingListService(ReadingListRepository repository) {
// Le service dépend de l'abstraction du repository, pas d'une implémentation concrète
this.repository = repository;
}
public ReadingListItem create(CreateReadingItemRequest request) {
// Règle métier : si externalId est défini, il doit être unique
if (request.externalId() != null && repository.existsByExternalId(request.externalId())) {
// Dans un projet pédagogique, simple exception ; plus tard, on pourra remplacer par une erreur domaine/HTTP
throw new IllegalStateException("externalId already exists: " + request.externalId());
}
// La génération de l'id fait partie de la stratégie de stockage, donc on la fait via le repository
long id = repository.nextId();
// Le mapping DTO -> entité domaine se fait dans le service
ReadingListItem item = new ReadingListItem(
id,
request.title(),
request.author(),
request.status(),
request.externalId(),
request.comment()
);
// La sauvegarde est une responsabilité séparée du repository
return repository.save(item);
}
}
Pourquoi l'id est généré ici (via le repository) et non dans le handler ? Parce que la génération de l'identifiant fait partie des « règles de stockage ». Aujourd'hui nous avons un in-memory AtomicLong, demain cela pourrait être un UUID, après-demain une base de données avec sa propre stratégie de génération. Le handler ne devrait même pas connaître le mécanisme qui délivre les nouveaux id ; sa tâche est de recevoir des requêtes HTTP, pas de gérer la numérotation interne.
Un mot sur externalId : c'est une « référence » vers un catalogue externe de livres. Nous avons convenu à l'avance d'un invariant : si externalId est défini, il doit être unique dans la liste de lecture. En pratique, cela évite les doublons du type « j'ai ajouté trois fois le même livre et maintenant je ne sais plus lequel est FINISHED ». Oui, on ne peut pas vaincre le facteur humain, mais on peut au moins ne pas l'aider.
5. Repository : stockage en mémoire
Le repository est la couche qui fait le travail désagréable mais nécessaire : « mettre l'objet quelque part pour pouvoir le récupérer plus tard ». Dans notre projet pédagogique, c'est un Map en mémoire, sans base de données ni magie. Mais même sous cette forme simple, le repository est utile : il sépare le service du mode de stockage concret et permet de conserver un point unique d'écriture/lecture.
Commençons par l'interface. Il est important d'y voir deux choses : la méthode nextId et la méthode save(...). Autrement dit, le repository n'est pas seulement une « table », mais aussi une source d'identifiants dans le cadre de la stratégie de stockage choisie.
package com.example.readlater.readinglist.repository;
import com.example.readlater.readinglist.domain.ReadingListItem;
import java.util.Optional;
public interface ReadingListRepository {
// Source de nouveaux ids (la stratégie peut changer : seq, UUID, BD, etc.)
long nextId();
// On sauvegarde l'entité domaine (pas le request DTO)
ReadingListItem save(ReadingListItem item);
// Requête au stockage : récupérer par id interne
Optional<ReadingListItem> findById(long id);
// Requête au stockage : vérifier l'invariant sur externalId
boolean existsByExternalId(String externalId);
}
Maintenant, une implémentation simple en mémoire. Il n'y a rien de « production », mais il y a une clarté importante : AtomicLong gère la séquence des id, Map gère le stockage, et le service ne sait pas du tout comment cela fonctionne.
package com.example.readlater.readinglist.repository;
import com.example.readlater.readinglist.domain.ReadingListItem;
import java.util.HashMap;
import java.util.Map;
import java.util.concurrent.atomic.AtomicLong;
public class InMemoryReadingListRepository implements ReadingListRepository {
// Stockage en mémoire : clé = id interne
private final Map<Long, ReadingListItem> storage = new HashMap<>();
// Séquence pour la génération d'id
private final AtomicLong seq = new AtomicLong(0);
@Override
public long nextId() {
// On incrémente le compteur et on retourne la nouvelle valeur
return seq.incrementAndGet();
}
@Override
public ReadingListItem save(ReadingListItem item) {
// On sauvegarde/met à jour par id
storage.put(item.getId(), item);
return item;
}
}
Et voici la vérification de externalId — un exemple du fait que même un repository en mémoire peut contenir des « requêtes » utiles vers le stockage. Oui, l'implémentation est linéaire (O(n)), mais pour un projet pédagogique et un petit nombre d'éléments, c'est acceptable, et le gain sémantique est grand : la règle d'unicité est exprimée explicitement.
package com.example.readlater.readinglist.repository;
public class InMemoryReadingListRepository implements ReadingListRepository {
// storage et seq — comme dans le fragment précédent
@Override
public boolean existsByExternalId(String externalId) {
// Recherche linéaire sur les valeurs : suffisant pour un projet pédagogique
return storage.values().stream()
.anyMatch(item -> externalId.equals(item.getExternalId()));
}
}
Si vous vous dites maintenant « pourquoi on ne garde pas une deuxième Map par externalId », alors félicitations : vous commencez à penser comme un ingénieur qui un jour optimisera tout… parfois même sans nécessité. Pour le cours, ce qui compte le plus, c'est la transparence et un ensemble minimal de constructions. Quand les bases de données et les volumes réels arriveront, les règles du jeu seront différentes.
6. Flux de données et contrat des couches
Quand le code devient plus volumineux, le cerveau a envie de garder une image : qui appelle qui, et où finit la responsabilité d'une couche et commence celle d'une autre. C'est normal : le cerveau aime l'ordre, et le chaos aime Main.java sur 2000 lignes. C'est pourquoi il est utile de fixer maintenant le « contrat » entre handler/service/repository et de voir le flux de données du JSON à l'objet domaine sauvegardé.
Voici un tableau compact qui sauve généralement de l'envie de « là, je vais ajouter un peu de logique métier dans le handler, vraiment un tout petit peu » :
| Couche | Ce qu'il reçoit en entrée | Ce qu'il transmet ensuite | Responsabilité métier |
|---|---|---|---|
| handler | HttpExchange (method, path, headers, body) | CreateReadingItemRequest → service | HTTP : lire la requête, parser le JSON, appeler le code métier |
| service | CreateReadingItemRequest | ReadingListItem → repository | Sens métier : règles de création, génération d'id, invariants |
| repository | ReadingListItem | ReadingListItem (sauvegardé) | Stockage : où mettre, comment trouver, comment vérifier l'unicité |
Et maintenant, la même idée sous forme de schéma. Si vous aimez les diagrammes, c'est votre moment ; si vous ne les aimez pas, sachez simplement que les diagrammes vous aiment (et vous les rencontrerez dans les projets des autres).
flowchart TD
%% Flux de données du client vers le stockage et retour
C["Postman / Client"] -->|POST /api/v1/reading-list body JSON| H[ReadingListHttpHandler]
H -->|CreateReadingItemRequest| S[ReadingListService]
S -->|nextId, save| R[ReadingListRepository]
R -->|ReadingListItem sauvegardé| S
S -->|ReadingListItem créé| H
H -->|HTTP response, pour l'instant minimal| C
Et pour que tout cela fonctionne réellement dans l'application, nous devons « connecter » les objets dans le composition root (chez nous, c'est généralement ReadLaterApplication ou une classe de câblage séparée, comme nous l'avons fait auparavant). L'exemple est le plus simple possible, sans conteneurs ni magie :
import com.example.readlater.readinglist.http.ReadingListHttpHandler;
import com.example.readlater.readinglist.repository.InMemoryReadingListRepository;
import com.example.readlater.readinglist.service.ReadingListService;
import com.fasterxml.jackson.databind.ObjectMapper;
// 1) Infrastructure de stockage
InMemoryReadingListRepository repo = new InMemoryReadingListRepository();
// 2) Couche métier
ReadingListService service = new ReadingListService(repo);
// 3) Infrastructure de sérialisation/désérialisation JSON
ObjectMapper objectMapper = new ObjectMapper();
// 4) Couche HTTP qui appelle le service
ReadingListHttpHandler handler = new ReadingListHttpHandler(objectMapper, service);
Oui, c'est un « assemblage manuel ». Oui, dans les grands projets, cela fatigue rapidement. Et oui, c'est précisément sur cette fatigue qu'on explique très bien pourquoi DI existe et pourquoi les gens aiment que le câblage soit fait par un outil plutôt que manuellement par le programmeur. Mais pour l'instant, il est plus important de voir clairement la mécanique et de comprendre ce qui est lié à quoi.
7. Erreurs typiques lors de l'opération create
Les erreurs dans les opérations create proviennent presque toujours d'une seule cause : nous avons mélangé les niveaux d'abstraction. Un peu de domaine dans le handler, un peu de HTTP dans le service, une goutte de JSON dans le repository, et voilà que vous n'avez plus un projet, mais une émission culinaire « mélange tout ce que tu trouves dans le frigo ». Au début, cela semble « plus rapide », mais ensuite, ça casse la tête à chaque modification.
Erreur n°1 : générer l'id dans le handler parce que « j'y suis déjà ».
C'est très tentant : vous avez lu le JSON, et tout de suite vous avez envie de faire long id = ..., d'assembler l'objet et de le mettre dans Map. Mais ainsi, la couche HTTP commence à connaître le stockage, et vous perdez en flexibilité. Aujourd'hui, AtomicLong, demain une autre stratégie, et soudain, vous devez réécrire la couche web à cause d'un changement dans la couche de stockage — c'est un couplage fort.
Erreur n°2 : enregistrer CreateReadingItemRequest dans le repository au lieu de ReadingListItem.
Un DTO est une forme de transport. Si vous mettez un DTO dans le Map, vous dites en fait : « mon modèle domaine, c'est ce que le client a envoyé ». Alors toute modification du contrat JSON casse immédiatement le stockage et la logique métier. En plus, vous perdez la possibilité d'ajouter proprement des champs internes (le même id) et des méthodes domaine, parce que tout devient « juste des données issues de la requête ».
Erreur n°3 : effectuer la vérification d'unicité de externalId après save(...).
On rencontre parfois une logique du genre « on sauvegarde d'abord, on vérifie ensuite ». C'est particulièrement dangereux car vous pouvez avoir le temps de corrompre l'état du stockage, puis essayer de « revenir en arrière ». Dans notre monde simplifié, les rollbacks ne sont pas prévus, donc la règle est plus simple : d'abord vérifier, ensuite sauvegarder, et seulement alors considérer l'opération comme réussie.
Erreur n°4 : disperser la logique de mapping dans tout le code.
Aujourd'hui, vous écrivez une fois new ReadingListItem(id, request.title(), ...), demain cela est copié à deux autres endroits, puis un nouveau champ est ajouté, et vous oubliez de mettre à jour l'un des endroits. Finalement, une partie des objets est créée avec un ensemble de champs, une autre avec un autre. Même dans un petit projet, il est préférable de garder le mapping à un seul endroit (généralement dans le service ou dans une petite méthode mapper à côté).
Erreur n°5 : considérer externalId comme « obligatoire » dans sa tête mais pas dans le code.
Si externalId est nullable dans votre request DTO, le code doit se comporter honnêtement avec cela. Un plantage fréquent est request.externalId().equals(...), qui lance une NullPointerException dès que l'utilisateur décide d'ajouter un livre « manuellement », sans catalogue externe. Même si nous ajoutons une validation complète plus tard, il faut déjà garder à l'esprit la gestion de base de null.
GO TO FULL VERSION