1. Pagination manuelle : service vs contrôleur
Lorsque vous écrivez pour la première fois un GET /tasks, le contrôleur est tenté de devenir le « héros principal » : il voit les paramètres de requête, il renvoie la réponse, donc c’est lui qui devrait découper la liste en pages. Mais si nous faisons ainsi, le contrôleur se transforme rapidement en une cuisine avec dix casseroles où tout cuit en même temps — le tri, la pagination, le mapping, et « oh, et ma logique métier ».
Dans notre Task Tracker API, le contrôleur doit rester fin pour une raison simple : il est responsable de la partie web du contrat (recevoir l’entrée, renvoyer la sortie), et non du calcul « quel morceau de la liste renvoyer ». Ce morceau relève de la logique applicative de traitement de la collection et doit être placé dans la couche service. De plus, nous avons déjà mis en place un flux d’erreurs unifié : si quelque chose ne va pas dans le service (par exemple, un sort non valide), le service lève une exception compréhensible, et @ControllerAdvice la transforme en une réponse Problem Details correcte.
Au final, architecturalement, c’est très calme : le contrôleur reçoit TaskSearchCriteria, appelle taskService.findPage(criteria) et renvoie PagedResponse<TaskSummaryResponse>. Tout le côté « intelligent » se produit dans le service, ce qui nous évite de dupliquer les mêmes algorithmes sur différents points d’accès.
Un petit fragment « de contrôle » du contrôleur pour sentir à quel point il doit être simple :
import jakarta.validation.Valid;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.ModelAttribute;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequestMapping("/api/v1/tasks")
public class TaskController {
@GetMapping
public PagedResponse<TaskSummaryResponse> list(
@Valid @ModelAttribute TaskSearchCriteria criteria) {
// criteria est automatiquement collecté à partir des paramètres de requête et subit une validation
// le contrôleur ne découpe ni ne trie rien — il délègue seulement au service
return taskService.findPage(criteria);
}
}
Notez que le contrôleur, idéalement, ne sait pas du tout comment from et to sont calculés, comment totalPages est déterminé, ni même comment le stable sorting est implémenté. Il délègue simplement — comme un adulte qui sait dire « ce n’est pas mon travail » sans culpabilité.
2. Pipeline du point d’accès list
La pagination manuelle a l’avantage d’être honnête : vous écrivez littéralement « d’abord fais A, puis B, puis C ». La mauvaise nouvelle : si vous inversez l’ordre, tout se cassera aussi honnêtement. Il est donc important de fixer le pipeline — c’est-à-dire la séquence d’étapes qui transforme une « collection brute de tâches » en une « page de tâches avec métadonnées ».
Pour éviter d’en faire une philosophie abstraite, il est plus pratique de considérer l’algorithme comme un convoyeur. Dans notre service, il ressemble (simplifié) à ceci :
flowchart TD
%% Schéma du pipeline : d'abord préparation des paramètres, puis tri, puis découpage
A[TaskSearchCriteria] --> B["Normaliser page/size"]
B --> C["Analyser sort + vérifier la whitelist"]
C --> D[Récupérer toutes les tâches depuis le dépôt]
D --> E[Trier]
E --> F["Découper la page : from/to"]
F --> G[Mapper vers TaskSummaryResponse]
G --> H[Assembler PagedResponse]
Un point psychologique important pour un débutant : le tri doit avoir lieu avant la pagination. Si vous « coupez » d’abord 20 éléments, puis triez ces 20, vous ne triez pas la « liste des tâches » mais un « fragment aléatoire de la liste des tâches ». Le client changera page et obtiendra des chevauchements imprévisibles d’éléments entre les pages. C’est le cas où le bogue semble « fonctionner, mais je n’aime pas ça », et ces bogues sont particulièrement désagréables.
Un autre point : la normalisation (null -> valeur par défaut) est une petite étape distincte que l’on souhaite faire soigneusement et une seule fois. Nous ne considérons pas null comme une erreur ; null signifie « le client n’a pas spécifié le paramètre ». En revanche, les valeurs qui violent la plage doivent être filtrées par la couche de validation. Par conséquent, dans le service, nous ne faisons généralement pas « cent vérifications au cas où », nous appliquons simplement les valeurs par défaut.
3. Découpage de la page : from/to et subList
Lorsque vous implémentez la pagination pour la première fois, le cerveau essaie généralement de compliquer les choses : « et si size est le dernier index ? », « et si page commence à 1 ? », « et si to est inclusif ? ». Bonne nouvelle : nous n’avons pas besoin d’inventer des mathématiques. Nous avons une convention simple : page est le numéro de page (base zéro), size est le nombre d’éléments. Donc le premier élément de la page est page * size.
Dans le code du service, il est pratique de placer cela dans une petite méthode distincte slice(...) qui fait exactement une chose : prendre une liste triée et renvoyer le fragment souhaité. Et il est très important : si from est déjà en dehors de la liste, ce n’est pas une exception. C’est simplement un items vide (et non une « erreur » ou un « 404 »). Cela ressemble à ceci :
import java.util.List;
private <T> List<T> slice(List<T> source, int page, int size) {
// from — premier indice de la page (inclusif)
int from = page * size;
// Si la page est "loin", renvoyer une liste vide — c’est un scénario normal pour un endpoint de liste
if (from >= source.size()) {
return List.of();
}
// to — borne supérieure (NON inclusive), donc on utilise Math.min pour la fin de la liste
int to = Math.min(from + size, source.size());
return source.subList(from, to);
}
Il y a plusieurs choses qu’il vaut mieux énoncer clairement, car elles permettent d’économiser des heures de débogage. Premièrement, subList(from, to) fonctionne avec un demi-intervalle : from inclusif, to exclusif. Donc si vous voulez 20 éléments, to doit être from + 20, et non from + 19. Deuxièmement, to ne doit pas dépasser la taille de la liste, sinon vous obtiendrez une IndexOutOfBoundsException et regarderez tristement la stack trace, comme une lettre du passé avec des menaces.
Et troisièmement : la méthode slice(...) ne fonctionne que si page et size sont déjà normalisés et valides. Ainsi, dans notre pipeline, la normalisation et la validation se situent avant elle, sinon vous risquez d’obtenir, par exemple, size=0 et une division par zéro ailleurs.
4. Métadonnées : totalElements et totalPages
Dans une réponse paginée, ce ne sont pas seulement les items qui comptent, mais aussi les métadonnées. Si vous ne renvoyez au client que les éléments courants, le client ne sait pas combien de tâches existent au total ni combien de pages il lui reste. Cela transforme l’API en un jeu « devine s’il y a une page suivante », et nous essayons de ne pas jouer à ces jeux, même si nous en avons très envie.
Ici, la règle principale est : totalElements est la taille de l’ensemble de la sélection (après tri, mais avant découpage en pages). Autrement dit, calculer totalElements = items.size() est presque toujours une erreur, car sur la première page items.size() peut être 20, sur la deuxième aussi 20, alors qu’il y a 57 tâches au total. Le client a besoin du nombre 57, sinon il ne pourra pas construire correctement la pagination dans l’interface utilisateur.
totalPages doit également être calculé à partir de la sélection complète et de size. Dans notre contrat, il est pratique de fixer la règle : s’il y a 0 élément, alors 0 pages. Ainsi, une collection vide apparaît « honnêtement vide », sans une étrange « une page vide ». L’implémentation peut être mathématiquement simple :
private int totalPages(long totalElements, int size) {
// Sélection vide => 0 pages (et non "1 page vide")
if (totalElements == 0) {
return 0;
}
// Arrondi supérieur pour les entiers : (total + size - 1) / size
return (int) ((totalElements + size - 1) / size);
}
On utilise ici la formule classique d’arrondi supérieur pour les entiers. Pour les débutants, cela ressemble à de la magie, mais en pratique c’est simple : on ajoute size - 1 pour que toute queue incomplète donne également une page supplémentaire. Par exemple, 57 éléments avec size=20 donnent (57 + 19) / 20 = 3.
Et une petite réflexion d’ingénieur : il est préférable de stocker totalElements comme long plutôt que comme int. Même si nous avons actuellement un projet in-memory et pédagogique, l’habitude de considérer que « le nombre d’éléments peut être grand » est utile et presque gratuite.
5. Assemblage de PagedResponse<TaskSummaryResponse>
Nous avons maintenant toutes les « briques » : normalisation de page/size, analyse du tri, tri de la liste, découpage de la page, calcul des métadonnées. Il ne reste plus qu’à les assembler dans une seule méthode du service de manière à ce qu’elle soit lisible et non pas comme un « parchemin de 200 lignes écrit à 3 heures du matin ».
À cette étape, nous n’avons pas besoin de nouvelles entités. Pagination.of(...) convertit déjà null en valeurs par défaut, parseSort(...) transforme la chaîne de requête en SortSpec, et buildComparator(...) construit un ordre déterministe. Le service se contente de lier ces parties en un seul scénario.
import java.util.List;
public PagedResponse<TaskSummaryResponse> findPage(TaskSearchCriteria criteria) {
// Pagination.of(...) gère déjà les valeurs par défaut : null -> valeurs contractuelles
Pagination pagination = Pagination.of(criteria.page(), criteria.size());
// On analyse et valide le sort selon les mêmes règles déjà établies pour le contrat public
SortSpec sort = parseSort(criteria.sort());
// Le tri se fait AVANT le slice, sinon les pages vont « sauter »
List<Task> sorted = taskRepository.findAll().stream()
.sorted(buildComparator(sort))
.toList();
// Le mapping se fait APRÈS le slice pour ne pas faire de travail inutile
List<TaskSummaryResponse> items = slice(sorted, pagination.page(), pagination.size()).stream()
.map(taskMapper::toSummaryResponse)
.toList();
long totalElements = sorted.size();
return new PagedResponse<>(
items,
pagination.page(),
pagination.size(),
totalElements,
totalPages(totalElements, pagination.size()),
sort.toRaw()
);
}
On peut noter deux choses ici. Premièrement, Pagination.of(...) a déjà pris en charge toute la gestion de null -> valeur par défaut, donc le service travaille ensuite avec des nombres normaux. Deuxièmement, SortSpec n’est pas recréé dans le service : il connaît déjà le champ, la direction et peut retourner le sort appliqué sous forme de chaîne via toRaw().
Grâce à cela, findPage(...) se lit comme un scénario plutôt que comme un ensemble d’astuces. Et c’est exactement ce dont nous avons besoin dans un projet pédagogique : que vous puissiez ouvrir le code un mois plus tard et comprendre ce qu’il fait, sans avoir à danser autour du débogueur.
6. Exemple de réponse JSON PagedResponse
Lorsque vous créez un PagedResponse, il est utile de voir une fois de ses propres yeux ce que reçoit exactement le client. Cela dissipe de nombreuses questions du genre « mais pourquoi avons-nous ajouté totalPages ? ». Imaginons que le client ait effectué une requête sans paramètres, donc les valeurs par défaut s’appliquent : page=0, size=20, sort=updatedAt,desc.
La réponse (simplifiée) peut ressembler à ceci :
// Exemple simplifié : items raccourcis pour la compacité
{
"items": [
{
"id": "c2e2c437-0f6b-4c08-9e5b-8db4bfc2f2df",
"title": "Fix pagination bug",
"status": "IN_PROGRESS",
"priority": "HIGH",
"updatedAt": "2026-03-21T10:15:30Z"
}
],
"page": 0,
"size": 20,
"totalElements": 57,
"totalPages": 3,
"sort": "updatedAt,desc"
}
Ce qui importe ici, ce n’est pas le nombre d’éléments dans l’exemple (nous n’en avons qu’un pour la compacité), mais le fait que la réponse s’explique d’elle-même. Le client voit quelle page il a reçue, de quelle taille, combien d’éléments existent au total et selon quel tri il les voit. Ainsi, l’interface utilisateur du client peut facilement afficher la pagination, les boutons « suivant/précédent », et même le texte « affiché 1–20 sur 57 », sans rien deviner du côté serveur.
Et c’est exactement l’état de l’API auquel nous aspirons : le serveur ne force pas le client à deviner, le serveur communique clairement le contexte de la sélection. Si l’API pouvait parler, elle dirait : « oui, je suis adulte, je sais ce que tu vas en faire ».
7. Erreurs typiques de la pagination manuelle
La pagination manuelle a une bonne nouvelle : il n’y a pas tant d’erreurs que ça. La mauvaise nouvelle : presque toutes ressemblent à des « détails », mais elles cassent le contrat de manière très désagréable. Il est donc préférable de les énoncer une fois avec des mots plutôt que de les attraper trois fois dans les logs et de faire comme si c’était voulu.
Erreur n°1 : découper la page avant le tri.
Cela semble logique si l’on pense « j’ai besoin d’une page, donc je prends d’abord 20 éléments, puis je les trie ». Mais pour le client, cela se transforme en chaos : les éléments sauteront d’une page à l’autre, le même objet peut apparaître soudainement à la fois sur la première et la deuxième page avec le même sort. L’ordre correct pour une liste est presque toujours : trier d’abord l’ensemble de la sélection, puis faire le slice.
Erreur n°2 : calculer totalElements à partir de la taille de items.
Sur la première page, items.size() peut être 20, et on se dit « voilà le total ». Mais dès qu’une deuxième page apparaît, on découvre que le total « saute » et le client ne comprend pas combien de données il y a au total. totalElements est une information sur l’ensemble de la sélection, pas sur le morceau courant. Pour vous vérifier, demandez : « Puis-je, à partir des métadonnées, savoir s’il y a une page suivante ? » Si non, les métadonnées sont cassées.
Erreur n°3 : off-by-one dans les indices from/to.
L’histoire la plus classique : quelqu’un décide que to est inclusif et écrit to = from + size - 1. Puis il le passe à subList(from, to) et obtient une page de 19 éléments au lieu de 20. Ou, à l’inverse, il obtient une erreur sur le dernier élément. subList(from, to) en Java fonctionne avec l’intervalle [from, to). Si vous ne retenez qu’une chose de cette leçon, que ce soit celle-ci.
Erreur n°4 : lever une exception lorsque la page est « loin en dehors de la liste ».
Parfois, un développeur voit from >= total et pense : « c’est clairement une erreur du client ». Puis il s’avère que le client se contente de parcourir les pages dans l’interface utilisateur, et au moment où les données sur le serveur ont changé (dans notre projet pédagogique ne serait-ce qu’à cause des données de seed ou d’actions de test), l’interface peut demander la « page 10 » alors qu’il y a moins d’éléments. Dans le cadre d’un endpoint de liste, il est plus correct de renvoyer un items vide pour une requête valide plutôt que de planter. Nous traitons ce cas spécifiquement dans slice(...).
Erreur n°5 : disperser l’assemblage de PagedResponse dans différentes couches.
Un morceau calcule totalPages dans le contrôleur, un deuxième morceau découpe la liste dans le dépôt, un troisième morceau assemble la chaîne sort dans le service. Ensuite, vous modifiez la valeur par défaut de size et commencez à chercher trois endroits où elle est codée en dur. Il est bien plus tranquille de garder l’algorithme du point d’accès de liste à un seul endroit, généralement dans le service, et de laisser le contrôleur comme « entrée-sortie ».
GO TO FULL VERSION