> ## Content Index
> Fetch the complete content index at: https://www.sfeir.dev/llms.txt
> Use this file to discover other available public pages before exploring further.

# La Taverne GraphQL : maitriser les arts avancés du comptoir.
- URL: https://www.sfeir.dev/back/la-taverne-graphql-maitriser-les-arts-avances-du-comptoir/
- Published: 2026-08-20T07:40:32.000Z
- Updated: 2026-08-20T08:19:17.000Z
- Description: La taverne se remplit, et le comptoir doit apprendre à tenir la cadence. Pagination par offset ou curseur, directives et résolution du problème N+1 : trois techniques pour garder une API GraphQL rapide, lisible et prête à accueillir toujours plus d’aventuriers.
- Author: Erwan Le Tutour
- Tags: Back, Java, quarkus, GraphQL

[L'antenne de la guilde parle désormais couramment GraphQL](https://www.sfeir.dev/back/la-taverne-graphql-aller-plus-loin-avec-le-client-quarkus/), avec ou sans interface type-safe.

Mais pendant que les messagers couraient d'un royaume à l'autre, la taverne principale, elle, n'a pas chômé.   
Le bouche-à-oreille a fait son travail : de plus en plus d'aventuriers poussent la porte chaque soir.   
Certains veulent la liste complète du registre, d'autres n'en veulent qu'une poignée de noms à la fois. Certains posent toujours la même question, encore et encore, sans que le tavernier ait le temps de préparer sa réponse à l'avance.

Le tavernier soupire derrière son comptoir.

« Il est temps d'apprendre les arts avancés du service. »

Ce quatrième épisode revient du côté du comptoir, pas de l'antenne, pour explorer trois techniques qui permettent à une API [GraphQL](https://www.sfeir.dev/graphql-definition/) de rester rapide et lisible même quand la salle se remplit : 

- la pagination
- les directives
- la chasse au fameux problème N+1.

## Servir la salle par vagues : la pagination

Le registre de la taverne s'est étoffé depuis le premier épisode. Baldric n'est plus seul : Sylvane l'Archère et Grendel le Nain ont rejoint la liste, avec leurs propres quêtes.   
Renvoyer tout le monde d'un coup à chaque requête `aventuriers` commence à peser, autant sur le réseau que sur la mémoire du client qui doit tout afficher.

GraphQL, contrairement à certains frameworks REST outillés, n'impose aucune pagination par défaut. Ce n'est pas un oubli : c'est un choix délibéré de la spécification, qui laisse chaque API décider de la stratégie la mieux adaptée à son registre. Une [demande d'évolution](https://github.com/smallrye/smallrye-graphql/issues/455?ref=sfeir.dev) traîne d'ailleurs depuis plusieurs années du côté de SmallRye GraphQL pour standardiser cela, sans qu'elle ait encore abouti. En attendant, c'est au tavernier de s'organiser.

### Offset et limite : la méthode du tavernier pressé

La première approche est la plus intuitive : on indique par où commencer, et combien de noms lire. Plutôt que de figer la taille des pages dans le code avec un simple `@DefaultValue`, autant la rendre ajustable depuis la configuration : un tavernier qui change d'équipe un soir de forte affluence peut vouloir servir des portions plus petites sans recompiler la taverne.

```java
@Inject
@ConfigProperty(name = "tavern.pagination.default-limit", defaultValue = "10")
int defaultLimit;

@Query("aventuriers")
@Description("Liste les aventuriers présents dans la taverne, par vagues successives.")
public @NonNull List<@NonNull AventurierResponse> aventuriers(
        @Description("Index du premier aventurier à servir") Integer offset,
        @Description("Nombre maximum d'aventuriers à servir") Integer limit) {
    int effectiveOffset = offset != null ? offset : 0;
    int effectiveLimit = limit != null ? limit : defaultLimit;
    return taverneService.getAventuriers(effectiveOffset, effectiveLimit).stream()
            .map(AventurierResponse::fromDomain)
            .toList();
}

```

Le guichet, version paginée et configurable

```properties
tavern.pagination.default-limit=10

```

Le service, lui, se contente d'un `skip` et d'un `limit` sur la liste, comme un tavernier qui compte les têtes avant de servir :

```java
public List<Aventurier> getAventuriers(int offset, int limit) {
    return getAllAventuriers().stream()
            .skip(Math.max(offset, 0))
            .limit(Math.max(limit, 0))
            .toList();
}

```

```graphql
query {
  aventuriers(offset: 0, limit: 2) {
    nom
    niveau
  }
}
```

Première page

![](https://storage.ghost.io/c/0b/31/0b31478e-a66a-46f1-8c34-d44388e21192/content/images/2026/08/image.png)

```graphql
query {
  aventuriers(offset: 2, limit: 2) {
    nom
    niveau
  }
}
```

Page suivante

![](https://storage.ghost.io/c/0b/31/0b31478e-a66a-46f1-8c34-d44388e21192/content/images/2026/08/image-1.png)

C'est simple, ça se comprend en une phrase, et pour un registre qui ne bouge pas trop vite, c'est largement suffisant.   
Mais un tavernier honnête doit prévenir ses clients d'un défaut : si un aventurier quitte la taverne entre deux pages, tout le monde derrière lui se décale d'un rang, et le client qui redemande la « page suivante » risque de recevoir un nom qu'il avait déjà vu, ou d'en manquer un. L'offset compte des rangs dans une file qui bouge, pas des repères fixes.

### Le curseur : la méthode qui ne perd jamais sa place

Pour un registre qui change souvent, mieux vaut donner à chaque aventurier un repère stable plutôt qu'un simple numéro de rang. C'est l'idée derrière la pagination par curseur, popularisée par Relay : chaque élément renvoyé porte un jeton opaque qui dit « reprends la lecture juste après moi ».

SmallRye GraphQL ne fournit aucune annotation magique pour ça non plus. On construit les types à la main, exactement comme n'importe quel autre objet du registre :

```java
@Description("Une page du registre des aventuriers")
public record AventurierConnection(
    @NonNull List<@NonNull AventurierEdge> edges,
    @NonNull PageInfo pageInfo) {}

@Description("Un aventurier accompagné de son curseur de lecture")
public record AventurierEdge(
    @NonNull String cursor,
    @NonNull AventurierResponse node) {}

@Description("Métadonnées de pagination")
public record PageInfo(
    @NonNull Boolean hasNextPage,
    String endCursor) {}

```

Le plan de salle, façon Relay

Le tavernier choisit d'encoder le curseur en Base64 autour de la position de l'aventurier dans le registre. Ce n'est pas la seule option (un identifiant de base de données ferait tout aussi bien l'affaire), mais elle a le mérite d'être facile à illustrer :

```java
public AventurierConnection getAventuriersConnection(String after, int first) {
    List<Aventurier> tous = getAllAventuriers();
    int start = decodeCursor(after);
    int end = Math.min(start + first, tous.size());

    List<AventurierEdge> edges = new ArrayList<>();
    for (int i = start; i < end; i++) {
        edges.add(new AventurierEdge(encodeCursor(i), AventurierResponse.fromDomain(tous.get(i))));
    }

    boolean hasNext = end < tous.size();
    String endCursor = edges.isEmpty() ? null : edges.get(edges.size() - 1).cursor();

    return new AventurierConnection(edges, new PageInfo(hasNext, endCursor));
}

private String encodeCursor(int index) {
    return Base64.getEncoder().encodeToString(("aventurier:" + index).getBytes());
}

private int decodeCursor(String cursor) {
    if (cursor == null) {
        return 0;
    }
    String decoded = new String(Base64.getDecoder().decode(cursor));
    return Integer.parseInt(decoded.substring("aventurier:".length())) + 1;
}

```

Encoder et décoder les curseurs, dans TaverneService

Côté guichet, une nouvelle Query expose cette lecture par curseur, indépendante de `aventuriers` :

```java
@Inject
@ConfigProperty(name = "tavern.pagination.default-first", defaultValue = "10")
int defaultFirst;

@Query("aventuriersConnection")
@Description("Parcourt le registre par curseur, page après page.")
public AventurierConnection aventuriersConnection(
        @Description("Curseur après lequel reprendre la lecture") String after,
        @Description("Nombre d'aventuriers à servir") Integer first) {
    return taverneService.getAventuriersConnection(after, first != null ? first : defaultFirst);
}

```

Le client, lui, n'a besoin de rien savoir de l'encodage du curseur. Il lit le registre page après page :

```graphql
query {
  aventuriersConnection(first: 2) {
    edges {
      cursor
      node {
        nom
        niveau
      }
    }
    pageInfo {
      hasNextPage
      endCursor
    }
  }
}

```

![](https://storage.ghost.io/c/0b/31/0b31478e-a66a-46f1-8c34-d44388e21192/content/images/2026/08/image-2.png)

Pour continuer la lecture, il suffit de renvoyer `endCursor` comme valeur d'`after` dans la requête suivante. Le registre peut évoluer entre-temps, un nouvel aventurier peut s'inscrire, un autre repartir : le curseur, lui, continue de désigner la même place dans la file, et non plus un rang qui glisse.

```graphql
query {
  aventuriersConnection(
    first: 2
    after: "YXZlbnR1cmllcjox"
  ) {
    edges {
      cursor
      node {
        nom
        niveau
      }
    }
    pageInfo {
      hasNextPage
      endCursor
    }
  }
}
```

![](https://storage.ghost.io/c/0b/31/0b31478e-a66a-46f1-8c34-d44388e21192/content/images/2026/08/image-3.png)

Entre les deux, le choix n'est pas une question de mode. L'offset convient à un tableau d'administration qui saute directement à la page 7\. Le curseur convient à un flux qui défile, un fil d'actualité des quêtes ou une liste qui ne s'arrête jamais vraiment.

## Les sceaux du registre : ce que les directives font vraiment

Le registre s'enrichit ce soir-là d'une information plus délicate : le solde du coffre personnel de chaque aventurier. Une donnée qu'on préfère signaler comme sensible dans le schéma, sans pour autant la cacher purement et simplement.

SmallRye GraphQL permet effectivement de créer des directives personnalisées, en écrivant une annotation Java et en la marquant avec `@Directive` :

```java
package fr.eletutour.tavern.directive;

import io.smallrye.graphql.api.Directive;
import io.smallrye.graphql.api.DirectiveLocation;
import org.eclipse.microprofile.graphql.Description;

import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;

@Directive(on = DirectiveLocation.FIELD_DEFINITION)
@Description("Signale un champ sensible, à traiter avec prudence côté client.")
@Retention(RetentionPolicy.RUNTIME)
public @interface Sensible {
}

```

Notre premier sceau

Posé sur le champ concerné du DTO :

```java
@Description("Données publiques d'un aventurier")
public record AventurierResponse(@NonNull @Description("Identifiant unique") Long id,
    @NonNull @Description("Nom de l'aventurier") String nom,
    @NonNull @Description("Classe (ex: Guerrier, Mage)") String classe,
    @NonNull @Description("Niveau d'expérience") Integer niveau,
    @Sensible @Description("Solde du coffre personnel de l'aventurier") Integer soldeOr) {
    public static AventurierResponse fromDomain(Aventurier a) {
        if (a == null) return null;
        return new AventurierResponse(a.id, a.nom, a.classe, a.niveau, a.soldeOr);
    }
}

```

Le sceau apparaît alors dans le schéma généré :

```graphql
soldeOr: Int @sensible

```

Et c'est là qu'il faut être honnête sur ce qu'un sceau fait réellement : rien, tout seul. Contrairement à ce que suggère l'intuition (et contrairement à ce que proposent certains serveurs GraphQL en JavaScript, où une directive peut transformer la valeur d'un champ à la volée), une directive personnalisée dans SmallRye GraphQL reste une étiquette posée sur le schéma.   
Elle documente une intention, elle informe les outils et les clients qui savent la lire, mais elle ne déclenche aucun comportement côté serveur par elle-même. Si `soldeOr` doit réellement être masqué pour certains rôles, c'est au tavernier de coder cette logique dans son resolver, le sceau ne fera que la signaler après coup dans le parchemin.

C'est une nuance qui a son importance, et qui a d'ailleurs déjà causé des sueurs froides à d'autres taverniers du royaume : sur des directives posées sur des types imbriqués, un [bug historique de l'extension Quarkus](https://github.com/quarkusio/quarkus/issues/20802?ref=sfeir.dev) a longtemps empêché certains schémas de démarrer correctement, faute d'enregistrer les classes associées à la directive. Mieux vaut donc garder ses sceaux simples, sans paramètres complexes, tant que cette zone du framework reste un peu fragile.

Une seule directive, en revanche, vient avec un comportement réellement pris en charge sans rien coder de plus : `@deprecated`.   
Il suffit [d'annoter une méthode Java](https://www.sfeir.dev/back/comprendre-les-annotations-dans-quarkus-guide-et-exemples/) avec le classique `@Deprecated`, et SmallRye GraphQL l'ajoute automatiquement au schéma, avec une description reprise si elle est disponible.

```java
@Deprecated
@Description("Préférer aventuriersConnection, pour un parcours plus stable du registre.")
@Query("aventuriers")
public @NonNull List<@NonNull AventurierResponse> aventuriers(Integer offset, Integer limit) {
    // ...
}

```

Les clients qui explorent le schéma depuis l'UI GraphQL voient alors immédiatement qu'une porte du comptoir ferme bientôt, sans que le tavernier ait eu à écrire une ligne de documentation séparée.

## Éviter de surcharger le comptoir : le problème N+1

Depuis le premier épisode, chaque aventurier porte directement sa liste de quêtes, embarquée dans l'objet `Aventurier` lui-même. Pratique tant que le registre tient dans une simple liste en mémoire, mais dangereux dès que le grand livre déménagera vers une vraie base de données : demander les quêtes de chaque aventurier une par une reviendrait à envoyer un scribe séparé consulter le registre pour chaque nom de la liste. Dix aventuriers, dix allers-retours en base. Cent aventuriers, cent allers-retours. C'est le fameux problème N+1 : une requête pour la liste, puis une par élément de la liste.

Pour s'en prémunir avant même que la base de données n'arrive, on sépare la responsabilité dès maintenant : `AventurierResponse` ne porte plus directement ses quêtes, et un resolver dédié s'en charge, marqué par `@Source` :

```java
public List<QueteResponse> quetes(@Source AventurierResponse aventurier) {
    return taverneService.getQuetesForAventuriers(List.of(aventurier.id()))
            .getOrDefault(aventurier.id(), List.of())
            .stream()
            .map(QueteResponse::fromDomain)
            .toList();
}

```

Version naïve, un scribe par aventurier

Cette version fonctionne, mais c'est exactement le scribe surmené décrit plus haut : un appel à `getQuetesForAventuriers` par aventurier retourné dans la requête. SmallRye GraphQL sait résoudre ce problème, à condition d'accepter la liste complète des sources d'un coup plutôt qu'une par une :

```java
public List<List<QueteResponse>> quetes(@Source List<AventurierResponse> aventuriers) {
    Map<Long, List<Quete>> quetesParAventurier = taverneService.getQuetesForAventuriers(
            aventuriers.stream().map(AventurierResponse::id).toList());

    return aventuriers.stream()
            .map(a -> quetesParAventurier.getOrDefault(a.id(), List.of()).stream()
                    .map(QueteResponse::fromDomain)
                    .toList())
            .toList();
}

```

Version batchée, un seul passage au grand livre

Côté service, un seul filtrage regroupe tous les identifiants demandés :

```java
public Map<Long, List<Quete>> getQuetesForAventuriers(List<Long> aventurierIds) {
    return getAllAventuriers().stream()
            .filter(a -> aventurierIds.contains(a.id))
            .collect(Collectors.toMap(a -> a.id, a -> a.quetes != null ? a.quetes : List.of()));
}

```

Aujourd'hui, la source reste une simple liste en mémoire : ce regroupement ne change donc rien aux performances réelles, tout au plus économise-t-il quelques itérations. Mais le jour où `getAllAventuriers` ira réellement interroger une base de données, ce sera exactement la même signature de méthode, avec un seul `SELECT ... WHERE aventurier_id IN (...)` à la place de la boucle. Le batching se prépare avant que la douleur n'arrive, pas après.

Le principe : au lieu d'un resolver appelé une fois par aventurier, SmallRye GraphQL détecte qu'il peut regrouper tous les aventuriers de la requête en cours et n'appeler la méthode qu'une seule fois, avec la liste complète en paramètre. 

Un point mérite toute l'attention du tavernier : la liste renvoyée doit avoir exactement la même taille, et le même ordre, que la liste des sources reçue en paramètre.   
C'est ce qui permet à SmallRye GraphQL de faire correspondre chaque aventurier à ses quêtes sans ambiguïté. D'où l'usage de `getOrDefault(..., List.of())` plutôt que de filtrer la map : un aventurier sans quête doit tout de même apparaître dans la liste de résultats, avec une liste vide à sa place, jamais une case manquante.

### Poser une limite à la curiosité des clients

Le batching évite de multiplier les scribes, mais rien n'empêche un client d'écrire une requête qui plonge très profondément dans le registre, avec les quêtes des aventuriers, les récompenses des quêtes, et ainsi de suite sur plusieurs niveaux. 

Quarkus permet de fixer une limite raisonnable à la profondeur et au nombre de champs demandés, et comme toute limite de ce genre, elle mérite de varier selon l'environnement :

```properties
quarkus.smallrye-graphql.instrumentation-query-complexity=150

# En production, on reste strict.
quarkus.smallrye-graphql.instrumentation-query-depth=8

# En dev, une limite trop basse peut gêner l'introspection utilisée par l'UI GraphQL elle-même.
%dev.quarkus.smallrye-graphql.instrumentation-query-depth=20
%test.quarkus.smallrye-graphql.instrumentation-query-depth=20

```

La première borne le nombre total de champs demandés, tous niveaux confondus. La seconde borne le nombre de niveaux d'imbrication acceptés dans une requête. Au-delà, la taverne refuse poliment de servir la commande plutôt que de s'épuiser à la préparer.   
Une protection discrète, mais qui évite qu'un client un peu trop curieux, ou un peu malveillant, ne transforme une simple requête en interrogatoire sans fin du registre.

## Conclusion

La taverne a appris, dans cet épisode, à tenir un comptoir qui ne s'essouffle pas quand la salle se remplit. 

La pagination lui permet de servir les aventuriers par vagues plutôt que de vider le registre entier à chaque requête, avec un choix entre la simplicité de l'offset et la stabilité du curseur selon ce que le client attend vraiment, et des tailles de page qui se règlent désormais depuis la configuration plutôt que dans le code. 

Les directives lui offrent un moyen d'apposer des sceaux sur son schéma, en gardant à l'esprit qu'un sceau documente une intention et ne remplace jamais le code qui doit la faire respecter. 

Et le batching des resolvers lui évite d'envoyer un scribe séparé pour chaque aventurier, en regroupant les lectures du grand livre en un seul passage, prêt pour le jour où ce grand livre sera une vraie base de données.

Rien de tout cela n'est automatique ni gratuit. Chaque technique demande au tavernier de faire un choix conscient, d'accepter une limite pour en gagner une autre. C'est peut-être ça, au fond, la vraie leçon des arts avancés du comptoir : GraphQL ne résout pas les problèmes de passage à l'échelle à la place du développeur, il lui donne simplement les bons outils pour les résoudre lui-même, sans jamais l'obliger à deviner.

---

Le code relatif à cet article peut être consulté ici

[GitHub - ErwanLT/quarkus-demo: Demo project for quarkus possibilityDemo project for quarkus possibility. Contribute to ErwanLT/quarkus-demo development by creating an account on GitHub.![](https://storage.ghost.io/c/0b/31/0b31478e-a66a-46f1-8c34-d44388e21192/content/images/icon/favicon-c6baa3fa-4dbc-4c9e-9d26-6e93e6791bd1.svg)GitHubErwanLT![](https://storage.ghost.io/c/0b/31/0b31478e-a66a-46f1-8c34-d44388e21192/content/images/thumbnail/5635256a-85fe-4dc6-8c3a-7e4df754b269-3d7e242f-01bf-475b-b169-b12889492a47)](https://github.com/ErwanLT/quarkus-demo?ref=sfeir.dev)