Spring GraphQL en entretien : resolvers, DataLoaders et gestion du problème N+1

Préparez vos entretiens Spring GraphQL avec ce guide complet. Resolvers, DataLoaders, gestion N+1, mutations et bonnes pratiques pour réussir vos questions techniques.

Spring GraphQL en entretien technique avec resolvers et DataLoaders

Spring for GraphQL simplifie l'intégration de GraphQL dans les applications Spring Boot. Cette technologie devient incontournable pour les APIs modernes, et les questions d'entretien sur ce sujet se multiplient. Ce guide couvre les concepts essentiels : resolvers, DataLoaders, problème N+1 et patterns avancés.

Focus entretien

Les recruteurs testent particulièrement la compréhension du problème N+1 et l'utilisation des DataLoaders. Ces deux sujets représentent 60% des questions Spring GraphQL en entretien.

Qu'est-ce que Spring for GraphQL ?

Spring for GraphQL constitue le successeur officiel de GraphQL Java Spring, intégré nativement au framework Spring depuis la version 2.7. Cette intégration apporte plusieurs avantages : support des annotations Spring, intégration avec Spring Security, et utilisation transparente de WebFlux ou MVC.

La configuration de base nécessite uniquement la dépendance spring-boot-starter-graphql et un schéma GraphQL.

build.gradle.ktsjava
dependencies {
    // Starter Spring GraphQL avec Spring MVC
    implementation("org.springframework.boot:spring-boot-starter-graphql")
    implementation("org.springframework.boot:spring-boot-starter-web")

    // Pour les tests GraphQL
    testImplementation("org.springframework.graphql:spring-graphql-test")
}

Le schéma GraphQL se place dans src/main/resources/graphql/ avec l'extension .graphqls.

graphql
# schema.graphqls
type Query {
    # Récupération d'un article par son identifiant
    article(id: ID!): Article

    # Liste paginée des articles
    articles(page: Int = 0, size: Int = 10): [Article!]!
}

type Article {
    id: ID!
    title: String!
    content: String!
    # Relation vers l'auteur
    author: Author!
    # Liste des commentaires
    comments: [Comment!]!
    createdAt: String!
}

type Author {
    id: ID!
    name: String!
    email: String!
    # Articles écrits par cet auteur
    articles: [Article!]!
}

type Comment {
    id: ID!
    content: String!
    author: Author!
    createdAt: String!
}

Cette architecture permet de déclarer les types et leurs relations. GraphQL génère automatiquement la documentation et valide les requêtes côté serveur.

Comment fonctionnent les resolvers Spring GraphQL ?

Les resolvers représentent le cœur de l'exécution GraphQL. Chaque champ du schéma peut avoir un resolver dédié. Spring utilise l'annotation @QueryMapping pour les requêtes racines et @SchemaMapping pour les relations.

ArticleController.javajava
@Controller
public class ArticleController {

    private final ArticleRepository articleRepository;
    private final AuthorRepository authorRepository;

    public ArticleController(ArticleRepository articleRepository,
                            AuthorRepository authorRepository) {
        this.articleRepository = articleRepository;
        this.authorRepository = authorRepository;
    }

    // Resolver pour Query.article(id)
    @QueryMapping
    public Article article(@Argument Long id) {
        return articleRepository.findById(id)
            .orElseThrow(() -> new ArticleNotFoundException(id));
    }

    // Resolver pour Query.articles(page, size)
    @QueryMapping
    public List<Article> articles(@Argument int page,
                                  @Argument int size) {
        Pageable pageable = PageRequest.of(page, size);
        return articleRepository.findAll(pageable).getContent();
    }

    // Resolver pour Article.author - appelé pour chaque article
    @SchemaMapping(typeName = "Article", field = "author")
    public Author author(Article article) {
        return authorRepository.findById(article.getAuthorId())
            .orElseThrow(() -> new AuthorNotFoundException(article.getAuthorId()));
    }
}

Le resolver author s'exécute pour chaque article retourné. Cette architecture flexible permet de charger les données à la demande, mais introduit le problème N+1.

Piège classique en entretien

Un candidat qui implémente un resolver @SchemaMapping sans mentionner le problème N+1 montre une compréhension incomplète de GraphQL. Les recruteurs attendent systématiquement cette analyse.

Qu'est-ce que le problème N+1 en GraphQL ?

Le problème N+1 survient lorsqu'une requête GraphQL déclenche N requêtes supplémentaires pour charger les relations. Ce pattern destructeur se produit systématiquement avec les resolvers basiques.

Prenons une requête récupérant 50 articles avec leurs auteurs :

graphql
# Requête GraphQL
query {
    articles(size: 50) {
        id
        title
        author {
            name
        }
    }
}

Avec le resolver précédent, cette requête exécute :

  • 1 requête pour les 50 articles
  • 50 requêtes pour charger chaque auteur individuellement
sql
-- Requête 1 : récupération des articles
SELECT * FROM articles LIMIT 50

-- Requêtes 2 à 51 : chaque auteur individuellement
SELECT * FROM authors WHERE id = 1
SELECT * FROM authors WHERE id = 2
SELECT * FROM authors WHERE id = 3
-- ... 47 requêtes supplémentaires

Cette multiplication des requêtes dégrade drastiquement les performances. Un endpoint répondant en 50ms peut passer à 2 secondes avec le N+1.

Comment fonctionnent les DataLoaders ?

Les DataLoaders regroupent les requêtes individuelles en requêtes par lots (batching). Au lieu de charger chaque auteur séparément, le DataLoader collecte tous les IDs demandés et exécute une seule requête.

AuthorBatchLoader.javajava
@Component
public class AuthorBatchLoader {

    private final AuthorRepository authorRepository;

    public AuthorBatchLoader(AuthorRepository authorRepository) {
        this.authorRepository = authorRepository;
    }

    // Méthode de chargement par lot
    public Mono<Map<Long, Author>> loadAuthors(Set<Long> authorIds) {
        // Une seule requête pour tous les auteurs
        List<Author> authors = authorRepository.findAllById(authorIds);

        // Transformation en Map pour lookup rapide
        Map<Long, Author> authorMap = authors.stream()
            .collect(Collectors.toMap(Author::getId, Function.identity()));

        return Mono.just(authorMap);
    }
}

L'enregistrement du DataLoader s'effectue via BatchLoaderRegistry dans une configuration dédiée.

GraphQLConfig.javajava
@Configuration
public class GraphQLConfig {

    @Bean
    public BatchLoaderRegistry batchLoaderRegistry(
            AuthorBatchLoader authorBatchLoader) {

        return registry -> {
            // Enregistrement du DataLoader pour les auteurs
            registry.forTypePair(Long.class, Author.class)
                .registerMappedBatchLoader((authorIds, env) ->
                    authorBatchLoader.loadAuthors(authorIds));
        };
    }
}

Le resolver modifié utilise maintenant le DataLoader au lieu d'un accès direct :

ArticleController.javajava
@Controller
public class ArticleController {

    private final ArticleRepository articleRepository;

    public ArticleController(ArticleRepository articleRepository) {
        this.articleRepository = articleRepository;
    }

    @QueryMapping
    public List<Article> articles(@Argument int page,
                                  @Argument int size) {
        Pageable pageable = PageRequest.of(page, size);
        return articleRepository.findAll(pageable).getContent();
    }

    // Resolver optimisé avec DataLoader
    @SchemaMapping(typeName = "Article", field = "author")
    public CompletableFuture<Author> author(
            Article article,
            DataLoader<Long, Author> authorDataLoader) {

        // Le DataLoader regroupe automatiquement les appels
        return authorDataLoader.load(article.getAuthorId());
    }
}

Avec cette approche, la même requête pour 50 articles exécute seulement 2 requêtes SQL :

sql
-- Requête 1 : récupération des articles
SELECT * FROM articles LIMIT 50

-- Requête 2 : tous les auteurs en une fois
SELECT * FROM authors WHERE id IN (1, 2, 3, 4, 5, ..., 50)

Prêt à réussir tes entretiens Spring Boot ?

Entraîne-toi avec nos simulateurs interactifs, fiches express et tests techniques.

Comment implémenter un DataLoader avec contexte ?

Les DataLoaders avancés nécessitent parfois un contexte additionnel, comme le tenant ID pour une application multi-tenant ou des filtres de sécurité.

SecuredAuthorBatchLoader.javajava
@Component
public class SecuredAuthorBatchLoader {

    private final AuthorRepository authorRepository;
    private final SecurityContextHolder securityContext;

    public SecuredAuthorBatchLoader(AuthorRepository authorRepository,
                                    SecurityContextHolder securityContext) {
        this.authorRepository = authorRepository;
        this.securityContext = securityContext;
    }

    public Mono<Map<Long, Author>> loadAuthorsForTenant(
            Set<Long> authorIds,
            BatchLoaderEnvironment env) {

        // Récupération du contexte GraphQL
        GraphQLContext context = env.getContext();
        String tenantId = context.get("tenantId");

        // Requête filtrée par tenant
        List<Author> authors = authorRepository
            .findAllByIdInAndTenantId(authorIds, tenantId);

        Map<Long, Author> authorMap = authors.stream()
            .collect(Collectors.toMap(Author::getId, Function.identity()));

        return Mono.just(authorMap);
    }
}

La configuration du contexte s'effectue via un intercepteur WebGraphQL :

TenantInterceptor.javajava
@Component
public class TenantInterceptor implements WebGraphQlInterceptor {

    @Override
    public Mono<WebGraphQlResponse> intercept(
            WebGraphQlRequest request,
            Chain chain) {

        // Extraction du tenant depuis les headers
        String tenantId = request.getHeaders()
            .getFirst("X-Tenant-ID");

        // Ajout au contexte GraphQL
        request.configureExecutionInput((input, builder) ->
            builder.graphQLContext(ctx ->
                ctx.put("tenantId", tenantId)
            ).build()
        );

        return chain.next(request);
    }
}

Quelles sont les différences entre @QueryMapping et @SchemaMapping ?

Cette question classique d'entretien vérifie la compréhension de la hiérarchie des resolvers.

| Annotation | Usage | Équivalent | |------------|-------|------------| | @QueryMapping | Champs racine du type Query | @SchemaMapping(typeName = "Query") | | @MutationMapping | Champs racine du type Mutation | @SchemaMapping(typeName = "Mutation") | | @SubscriptionMapping | Abonnements temps réel | @SchemaMapping(typeName = "Subscription") | | @SchemaMapping | Tous les champs de n'importe quel type | Forme générique |

EquivalenceDemo.javajava
@Controller
public class EquivalenceDemo {

    // Ces deux déclarations sont équivalentes
    @QueryMapping
    public Article article(@Argument Long id) {
        return findArticle(id);
    }

    @SchemaMapping(typeName = "Query", field = "article")
    public Article articleEquivalent(@Argument Long id) {
        return findArticle(id);
    }

    // Pour les champs imbriqués, seul @SchemaMapping fonctionne
    @SchemaMapping(typeName = "Article", field = "author")
    public Author author(Article article) {
        return findAuthor(article.getAuthorId());
    }

    // Syntaxe alternative avec le type en paramètre
    @SchemaMapping
    public Author author(Article article) {
        // Spring déduit typeName = "Article" depuis le paramètre
        return findAuthor(article.getAuthorId());
    }
}

Comment gérer les mutations avec validation ?

Les mutations GraphQL modifient les données. Spring GraphQL s'intègre avec Bean Validation pour valider les entrées.

graphql
# schema.graphqls
type Mutation {
    createArticle(input: CreateArticleInput!): Article!
    updateArticle(id: ID!, input: UpdateArticleInput!): Article!
    deleteArticle(id: ID!): Boolean!
}

input CreateArticleInput {
    title: String!
    content: String!
    authorId: ID!
}

input UpdateArticleInput {
    title: String
    content: String
}

Le controller utilise @Valid pour déclencher la validation :

ArticleMutationController.javajava
@Controller
public class ArticleMutationController {

    private final ArticleService articleService;

    public ArticleMutationController(ArticleService articleService) {
        this.articleService = articleService;
    }

    @MutationMapping
    public Article createArticle(@Argument @Valid CreateArticleInput input) {
        // La validation s'exécute automatiquement
        return articleService.create(input);
    }

    @MutationMapping
    public Article updateArticle(@Argument Long id,
                                 @Argument @Valid UpdateArticleInput input) {
        return articleService.update(id, input);
    }

    @MutationMapping
    public boolean deleteArticle(@Argument Long id) {
        articleService.delete(id);
        return true;
    }
}
CreateArticleInput.javajava
public record CreateArticleInput(
    @NotBlank(message = "Le titre est obligatoire")
    @Size(min = 5, max = 200, message = "Le titre doit contenir entre 5 et 200 caractères")
    String title,

    @NotBlank(message = "Le contenu est obligatoire")
    @Size(min = 100, message = "Le contenu doit contenir au moins 100 caractères")
    String content,

    @NotNull(message = "L'auteur est obligatoire")
    Long authorId
) {}
Gestion des erreurs

Les erreurs de validation retournent automatiquement une erreur GraphQL structurée avec le chemin du champ invalide. Spring GraphQL formate ces erreurs selon la spécification GraphQL.

Comment optimiser les requêtes avec @BatchMapping ?

L'annotation @BatchMapping simplifie la création de DataLoaders directement dans les controllers. Cette approche évite la configuration explicite du BatchLoaderRegistry.

OptimizedArticleController.javajava
@Controller
public class OptimizedArticleController {

    private final ArticleRepository articleRepository;
    private final AuthorRepository authorRepository;
    private final CommentRepository commentRepository;

    public OptimizedArticleController(ArticleRepository articleRepository,
                                      AuthorRepository authorRepository,
                                      CommentRepository commentRepository) {
        this.articleRepository = articleRepository;
        this.authorRepository = authorRepository;
        this.commentRepository = commentRepository;
    }

    @QueryMapping
    public List<Article> articles(@Argument int page,
                                  @Argument int size) {
        return articleRepository.findAll(PageRequest.of(page, size))
            .getContent();
    }

    // BatchMapping pour les auteurs - traite tous les articles en une fois
    @BatchMapping
    public Map<Article, Author> author(List<Article> articles) {
        // Collecte des IDs d'auteurs uniques
        Set<Long> authorIds = articles.stream()
            .map(Article::getAuthorId)
            .collect(Collectors.toSet());

        // Une seule requête pour tous les auteurs
        Map<Long, Author> authorsById = authorRepository
            .findAllById(authorIds)
            .stream()
            .collect(Collectors.toMap(Author::getId, Function.identity()));

        // Association article -> auteur
        return articles.stream()
            .collect(Collectors.toMap(
                Function.identity(),
                article -> authorsById.get(article.getAuthorId())
            ));
    }

    // BatchMapping pour les commentaires
    @BatchMapping
    public Map<Article, List<Comment>> comments(List<Article> articles) {
        List<Long> articleIds = articles.stream()
            .map(Article::getId)
            .toList();

        // Récupération groupée des commentaires
        List<Comment> allComments = commentRepository
            .findByArticleIdIn(articleIds);

        // Groupement par article
        Map<Long, List<Comment>> commentsByArticle = allComments.stream()
            .collect(Collectors.groupingBy(Comment::getArticleId));

        return articles.stream()
            .collect(Collectors.toMap(
                Function.identity(),
                article -> commentsByArticle
                    .getOrDefault(article.getId(), List.of())
            ));
    }
}

@BatchMapping offre une syntaxe plus concise que la configuration manuelle des DataLoaders, tout en fournissant les mêmes garanties de performance.

Comment tester les resolvers GraphQL ?

Spring GraphQL fournit GraphQlTester pour écrire des tests expressifs et lisibles.

ArticleControllerTest.javajava
@SpringBootTest
@AutoConfigureGraphQlTester
class ArticleControllerTest {

    @Autowired
    private GraphQlTester graphQlTester;

    @Test
    void shouldReturnArticleById() {
        // Given : document GraphQL
        String query = """
            query {
                article(id: 1) {
                    id
                    title
                    author {
                        name
                    }
                }
            }
            """;

        // When & Then
        graphQlTester.document(query)
            .execute()
            .path("article.id").entity(String.class).isEqualTo("1")
            .path("article.title").entity(String.class).isNotEmpty()
            .path("article.author.name").entity(String.class).isNotEmpty();
    }

    @Test
    void shouldCreateArticle() {
        String mutation = """
            mutation {
                createArticle(input: {
                    title: "Nouveau titre",
                    content: "Contenu de test suffisamment long pour validation",
                    authorId: 1
                }) {
                    id
                    title
                }
            }
            """;

        graphQlTester.document(mutation)
            .execute()
            .path("createArticle.id").entity(String.class).isNotEmpty()
            .path("createArticle.title").entity(String.class)
                .isEqualTo("Nouveau titre");
    }

    @Test
    void shouldReturnErrorForInvalidInput() {
        String mutation = """
            mutation {
                createArticle(input: {
                    title: "",
                    content: "court",
                    authorId: 1
                }) {
                    id
                }
            }
            """;

        graphQlTester.document(mutation)
            .execute()
            .errors()
            .expect(error -> error.getMessage()
                .contains("Le titre est obligatoire"));
    }

    @Test
    void shouldHandleVariables() {
        String query = """
            query GetArticle($id: ID!) {
                article(id: $id) {
                    id
                    title
                }
            }
            """;

        graphQlTester.document(query)
            .variable("id", 42)
            .execute()
            .path("article").valueIsNull();
    }
}

Passe à la pratique !

Teste tes connaissances avec nos simulateurs d'entretien et tests techniques.

Comment gérer les subscriptions temps réel ?

Les subscriptions permettent de pousser des données en temps réel vers les clients via WebSocket.

graphql
# schema.graphqls
type Subscription {
    articleCreated: Article!
    commentAdded(articleId: ID!): Comment!
}
SubscriptionController.javajava
@Controller
public class SubscriptionController {

    private final Sinks.Many<Article> articleSink =
        Sinks.many().multicast().onBackpressureBuffer();

    private final Sinks.Many<Comment> commentSink =
        Sinks.many().multicast().onBackpressureBuffer();

    @SubscriptionMapping
    public Flux<Article> articleCreated() {
        return articleSink.asFlux();
    }

    @SubscriptionMapping
    public Flux<Comment> commentAdded(@Argument Long articleId) {
        return commentSink.asFlux()
            .filter(comment -> comment.getArticleId().equals(articleId));
    }

    // Méthode appelée lors de la création d'un article
    public void publishArticle(Article article) {
        articleSink.tryEmitNext(article);
    }

    // Méthode appelée lors de l'ajout d'un commentaire
    public void publishComment(Comment comment) {
        commentSink.tryEmitNext(comment);
    }
}

La configuration WebSocket requiert une dépendance supplémentaire :

build.gradle.ktsjava
dependencies {
    implementation("org.springframework.boot:spring-boot-starter-websocket")
}
WebSocketConfig.javajava
@Configuration
public class WebSocketConfig {

    @Bean
    public ServerEndpointExporter serverEndpointExporter() {
        return new ServerEndpointExporter();
    }
}

Quelles sont les bonnes pratiques de sécurité GraphQL ?

La sécurité GraphQL nécessite plusieurs couches de protection contre les attaques par déni de service et les accès non autorisés.

SecurityConfig.javajava
@Configuration
@EnableMethodSecurity
public class SecurityConfig {

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        return http
            .authorizeHttpRequests(auth -> auth
                // Endpoint GraphQL protégé
                .requestMatchers("/graphql").authenticated()
                // GraphiQL accessible en dev uniquement
                .requestMatchers("/graphiql").permitAll()
            )
            .oauth2ResourceServer(oauth2 -> oauth2.jwt(Customizer.withDefaults()))
            .build();
    }
}
SecuredArticleController.javajava
@Controller
public class SecuredArticleController {

    private final ArticleRepository articleRepository;

    public SecuredArticleController(ArticleRepository articleRepository) {
        this.articleRepository = articleRepository;
    }

    // Accès restreint aux utilisateurs authentifiés
    @QueryMapping
    @PreAuthorize("isAuthenticated()")
    public List<Article> articles(@Argument int page,
                                  @Argument int size) {
        return articleRepository.findAll(PageRequest.of(page, size))
            .getContent();
    }

    // Seuls les admins peuvent supprimer
    @MutationMapping
    @PreAuthorize("hasRole('ADMIN')")
    public boolean deleteArticle(@Argument Long id) {
        articleRepository.deleteById(id);
        return true;
    }

    // Vérification fine : seul l'auteur peut modifier
    @MutationMapping
    @PreAuthorize("@articleSecurity.isAuthor(#id, authentication)")
    public Article updateArticle(@Argument Long id,
                                 @Argument UpdateArticleInput input) {
        return articleRepository.findById(id)
            .map(article -> {
                article.setTitle(input.title());
                article.setContent(input.content());
                return articleRepository.save(article);
            })
            .orElseThrow();
    }
}

Protection contre les requêtes complexes :

yaml
# application.yml
spring:
  graphql:
    schema:
      introspection:
        # Désactiver l'introspection en production
        enabled: false
QueryComplexityInstrumentation.javajava
@Component
public class QueryComplexityInstrumentation extends SimplePerformantInstrumentation {

    private static final int MAX_COMPLEXITY = 100;

    @Override
    public InstrumentationState createState(InstrumentationCreateStateParameters parameters) {
        return new ComplexityState();
    }

    @Override
    public InstrumentationContext<ExecutionResult> beginExecution(
            InstrumentationExecutionParameters parameters,
            InstrumentationState state) {

        int complexity = calculateComplexity(parameters.getDocument());

        if (complexity > MAX_COMPLEXITY) {
            throw new GraphQLException(
                "Query too complex: " + complexity + " (max: " + MAX_COMPLEXITY + ")"
            );
        }

        return super.beginExecution(parameters, state);
    }
}

Conclusion

Spring for GraphQL offre une intégration élégante de GraphQL dans l'écosystème Spring. La maîtrise des resolvers, des DataLoaders et de la gestion du problème N+1 constitue le socle de connaissances attendu en entretien.

Checklist entretien Spring GraphQL :

  • ✅ Expliquer le rôle des annotations @QueryMapping, @MutationMapping et @SchemaMapping
  • ✅ Décrire le problème N+1 et son impact sur les performances GraphQL
  • ✅ Implémenter un DataLoader avec @BatchMapping ou BatchLoaderRegistry
  • ✅ Valider les entrées de mutation avec Bean Validation
  • ✅ Sécuriser les resolvers avec @PreAuthorize et Spring Security
  • ✅ Tester les resolvers avec GraphQlTester
  • ✅ Configurer les subscriptions pour le temps réel
  • ✅ Protéger l'API contre les requêtes excessivement complexes

Tags

#spring graphql
#dataloaders
#n+1 problem
#interview
#graphql java

Partager

Articles similaires