Spring GraphQL Sollicitatiegesprek: Resolvers, DataLoaders en Oplossingen voor het N+1-probleem

Voorbereiding op Spring GraphQL sollicitatiegesprekken met deze complete gids. Resolvers, DataLoaders, omgaan met het N+1-probleem, mutaties en best practices voor technische vragen.

Spring GraphQL technisch sollicitatiegesprek met resolvers en DataLoaders

Spring for GraphQL vereenvoudigt de integratie van GraphQL in Spring Boot applicaties. Deze technologie is onmisbaar geworden voor moderne API's, en sollicitatievragen over dit onderwerp komen steeds vaker voor. Deze gids behandelt de fundamentele concepten: resolvers, DataLoaders, het N+1-probleem en geavanceerde patterns.

Focus van het gesprek

Recruiters testen vooral het begrip van het N+1-probleem en het gebruik van DataLoaders. Deze twee onderwerpen vertegenwoordigen 60% van de vragen in Spring GraphQL sollicitatiegesprekken.

Wat is Spring for GraphQL?

Spring for GraphQL is de officiële opvolger van GraphQL Java Spring, native geïntegreerd in het Spring framework sinds versie 2.7. Deze integratie biedt verschillende voordelen: ondersteuning voor Spring annotaties, integratie met Spring Security en transparant gebruik van WebFlux of MVC.

De basisconfiguratie vereist alleen de afhankelijkheid spring-boot-starter-graphql en een GraphQL schema.

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

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

Het GraphQL schema bevindt zich in src/main/resources/graphql/ met de extensie .graphqls.

graphql
# schema.graphqls
type Query {
    # Een artikel ophalen via zijn identifier
    article(id: ID!): Article

    # Gepagineerde artikellijst
    articles(page: Int = 0, size: Int = 10): [Article!]!
}

type Article {
    id: ID!
    title: String!
    content: String!
    # Relatie met de auteur
    author: Author!
    # Lijst van reacties
    comments: [Comment!]!
    createdAt: String!
}

type Author {
    id: ID!
    name: String!
    email: String!
    # Artikelen geschreven door deze auteur
    articles: [Article!]!
}

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

Deze architectuur declareert types en hun relaties. GraphQL genereert automatisch de documentatie en valideert queries aan de serverkant.

Hoe werken Spring GraphQL Resolvers?

Resolvers vormen de kern van GraphQL-uitvoering. Elk schemaveld kan een dedicated resolver hebben. Spring gebruikt de annotatie @QueryMapping voor root queries en @SchemaMapping voor relaties.

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 voor Query.article(id)
    @QueryMapping
    public Article article(@Argument Long id) {
        return articleRepository.findById(id)
            .orElseThrow(() -> new ArticleNotFoundException(id));
    }

    // Resolver voor 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 voor Article.author - aangeroepen voor elk artikel
    @SchemaMapping(typeName = "Article", field = "author")
    public Author author(Article article) {
        return authorRepository.findById(article.getAuthorId())
            .orElseThrow(() -> new AuthorNotFoundException(article.getAuthorId()));
    }
}

De author resolver wordt voor elk geretourneerd artikel uitgevoerd. Deze flexibele architectuur laadt data on-demand maar introduceert het N+1-probleem.

Klassieke valkuil bij sollicitatiegesprekken

Een kandidaat die een @SchemaMapping resolver implementeert zonder het N+1-probleem te noemen, toont onvolledig GraphQL begrip. Recruiters verwachten deze analyse systematisch.

Wat is het N+1-probleem in GraphQL?

Het N+1-probleem ontstaat wanneer een GraphQL query N extra queries activeert om de relaties te laden. Dit destructieve patroon doet zich systematisch voor bij basis resolvers.

Beschouw een query die 50 artikelen met hun auteurs ophaalt:

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

Met de vorige resolver voert deze query uit:

  • 1 query voor de 50 artikelen
  • 50 queries om elke auteur individueel te laden
sql
-- Query 1: artikelen ophalen
SELECT * FROM articles LIMIT 50

-- Queries 2-51: elke auteur individueel
SELECT * FROM authors WHERE id = 1
SELECT * FROM authors WHERE id = 2
SELECT * FROM authors WHERE id = 3
-- ... 47 extra queries

Deze vermenigvuldiging van queries verslechtert de prestaties drastisch. Een endpoint dat in 50ms reageert kan met N+1 oplopen tot 2 seconden.

Hoe werken DataLoaders?

DataLoaders bundelen individuele queries in batchverzoeken. In plaats van elke auteur afzonderlijk te laden, verzamelt de DataLoader alle gevraagde ID's en voert één enkele query uit.

AuthorBatchLoader.javajava
@Component
public class AuthorBatchLoader {

    private final AuthorRepository authorRepository;

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

    // Batch laadmethode
    public Mono<Map<Long, Author>> loadAuthors(Set<Long> authorIds) {
        // Eén enkele query voor alle auteurs
        List<Author> authors = authorRepository.findAllById(authorIds);

        // Transformatie naar Map voor snelle lookup
        Map<Long, Author> authorMap = authors.stream()
            .collect(Collectors.toMap(Author::getId, Function.identity()));

        return Mono.just(authorMap);
    }
}

De DataLoader registratie gebeurt via BatchLoaderRegistry in een dedicated configuratie.

GraphQLConfig.javajava
@Configuration
public class GraphQLConfig {

    @Bean
    public BatchLoaderRegistry batchLoaderRegistry(
            AuthorBatchLoader authorBatchLoader) {

        return registry -> {
            // Registratie van DataLoader voor auteurs
            registry.forTypePair(Long.class, Author.class)
                .registerMappedBatchLoader((authorIds, env) ->
                    authorBatchLoader.loadAuthors(authorIds));
        };
    }
}

De gewijzigde resolver gebruikt nu de DataLoader in plaats van directe toegang:

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();
    }

    // Geoptimaliseerde resolver met DataLoader
    @SchemaMapping(typeName = "Article", field = "author")
    public CompletableFuture<Author> author(
            Article article,
            DataLoader<Long, Author> authorDataLoader) {

        // De DataLoader bundelt automatisch de aanroepen
        return authorDataLoader.load(article.getAuthorId());
    }
}

Met deze aanpak voert dezelfde query voor 50 artikelen slechts 2 SQL queries uit:

sql
-- Query 1: artikelen ophalen
SELECT * FROM articles LIMIT 50

-- Query 2: alle auteurs in één keer
SELECT * FROM authors WHERE id IN (1, 2, 3, 4, 5, ..., 50)

Klaar om je Spring Boot gesprekken te halen?

Oefen met onze interactieve simulatoren, flashcards en technische tests.

Hoe een DataLoader implementeren met context?

Geavanceerde DataLoaders vereisen soms aanvullende context, zoals een tenant identifier voor multi-tenant applicaties of beveiligingsfilters.

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) {

        // GraphQL context ophalen
        GraphQLContext context = env.getContext();
        String tenantId = context.get("tenantId");

        // Query gefilterd op 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);
    }
}

De context configuratie gebeurt via een WebGraphQL interceptor:

TenantInterceptor.javajava
@Component
public class TenantInterceptor implements WebGraphQlInterceptor {

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

        // Tenant uit headers extraheren
        String tenantId = request.getHeaders()
            .getFirst("X-Tenant-ID");

        // Toevoegen aan GraphQL context
        request.configureExecutionInput((input, builder) ->
            builder.graphQLContext(ctx ->
                ctx.put("tenantId", tenantId)
            ).build()
        );

        return chain.next(request);
    }
}

Wat zijn de verschillen tussen @QueryMapping en @SchemaMapping?

Deze klassieke sollicitatievraag verifieert het begrip van de resolver hiërarchie.

| Annotatie | Gebruik | Equivalent | |-----------|---------|------------| | @QueryMapping | Root velden van type Query | @SchemaMapping(typeName = "Query") | | @MutationMapping | Root velden van type Mutation | @SchemaMapping(typeName = "Mutation") | | @SubscriptionMapping | Real-time subscriptions | @SchemaMapping(typeName = "Subscription") | | @SchemaMapping | Alle velden van elk type | Generieke vorm |

EquivalenceDemo.javajava
@Controller
public class EquivalenceDemo {

    // Deze twee declaraties zijn equivalent
    @QueryMapping
    public Article article(@Argument Long id) {
        return findArticle(id);
    }

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

    // Voor geneste velden werkt alleen @SchemaMapping
    @SchemaMapping(typeName = "Article", field = "author")
    public Author author(Article article) {
        return findAuthor(article.getAuthorId());
    }

    // Alternatieve syntaxis met type als parameter
    @SchemaMapping
    public Author author(Article article) {
        // Spring leidt typeName = "Article" af van de parameter
        return findAuthor(article.getAuthorId());
    }
}

Hoe mutaties beheren met validatie?

GraphQL mutaties wijzigen data. Spring GraphQL integreert met Bean Validation om inputs te valideren.

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
}

De controller gebruikt @Valid om de validatie te activeren:

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) {
        // Validatie wordt automatisch uitgevoerd
        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 = "Titel is verplicht")
    @Size(min = 5, max = 200, message = "Titel moet tussen 5 en 200 tekens zijn")
    String title,

    @NotBlank(message = "Inhoud is verplicht")
    @Size(min = 100, message = "Inhoud moet minstens 100 tekens zijn")
    String content,

    @NotNull(message = "Auteur is verplicht")
    Long authorId
) {}
Foutafhandeling

Validatiefouten retourneren automatisch een gestructureerde GraphQL-fout met het pad van het ongeldige veld. Spring GraphQL formatteert deze fouten volgens de GraphQL specificatie.

Hoe queries optimaliseren met @BatchMapping?

De annotatie @BatchMapping vereenvoudigt het maken van DataLoaders direct in de controllers. Deze aanpak vermijdt expliciete BatchLoaderRegistry configuratie.

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 voor auteurs - verwerkt alle artikelen tegelijk
    @BatchMapping
    public Map<Article, Author> author(List<Article> articles) {
        // Unieke auteur ID's verzamelen
        Set<Long> authorIds = articles.stream()
            .map(Article::getAuthorId)
            .collect(Collectors.toSet());

        // Eén enkele query voor alle auteurs
        Map<Long, Author> authorsById = authorRepository
            .findAllById(authorIds)
            .stream()
            .collect(Collectors.toMap(Author::getId, Function.identity()));

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

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

        // Batch ophalen van reacties
        List<Comment> allComments = commentRepository
            .findByArticleIdIn(articleIds);

        // Groepering per artikel
        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 biedt een beknoptere syntaxis dan handmatige DataLoader configuratie, met dezelfde performance garanties.

Hoe GraphQL Resolvers testen?

Spring GraphQL biedt GraphQlTester om expressieve en leesbare tests te schrijven.

ArticleControllerTest.javajava
@SpringBootTest
@AutoConfigureGraphQlTester
class ArticleControllerTest {

    @Autowired
    private GraphQlTester graphQlTester;

    @Test
    void shouldReturnArticleById() {
        // Given: GraphQL document
        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: "Nieuwe titel",
                    content: "Test inhoud lang genoeg om de validatiebeperkingen te halen",
                    authorId: 1
                }) {
                    id
                    title
                }
            }
            """;

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

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

        graphQlTester.document(mutation)
            .execute()
            .errors()
            .expect(error -> error.getMessage()
                .contains("Titel is verplicht"));
    }

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

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

Begin met oefenen!

Test je kennis met onze gespreksimulatoren en technische tests.

Hoe real-time subscriptions beheren?

Subscriptions maken het mogelijk om real-time data naar clients te pushen 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));
    }

    // Methode aangeroepen bij het maken van een artikel
    public void publishArticle(Article article) {
        articleSink.tryEmitNext(article);
    }

    // Methode aangeroepen bij het toevoegen van een reactie
    public void publishComment(Comment comment) {
        commentSink.tryEmitNext(comment);
    }
}

De WebSocket configuratie vereist een aanvullende afhankelijkheid:

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();
    }
}

Wat zijn de best practices voor GraphQL beveiliging?

GraphQL beveiliging vereist meerdere beschermingslagen tegen denial-of-service aanvallen en ongeautoriseerde toegang.

SecurityConfig.javajava
@Configuration
@EnableMethodSecurity
public class SecurityConfig {

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        return http
            .authorizeHttpRequests(auth -> auth
                // Beveiligd GraphQL endpoint
                .requestMatchers("/graphql").authenticated()
                // GraphiQL alleen toegankelijk in dev
                .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;
    }

    // Toegang beperkt tot geauthenticeerde gebruikers
    @QueryMapping
    @PreAuthorize("isAuthenticated()")
    public List<Article> articles(@Argument int page,
                                  @Argument int size) {
        return articleRepository.findAll(PageRequest.of(page, size))
            .getContent();
    }

    // Alleen administrators kunnen verwijderen
    @MutationMapping
    @PreAuthorize("hasRole('ADMIN')")
    public boolean deleteArticle(@Argument Long id) {
        articleRepository.deleteById(id);
        return true;
    }

    // Fijnmazige controle: alleen de auteur kan wijzigen
    @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();
    }
}

Bescherming tegen complexe queries:

yaml
# application.yml
spring:
  graphql:
    schema:
      introspection:
        # Introspectie uitschakelen in productie
        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 te complex: " + complexity + " (max: " + MAX_COMPLEXITY + ")"
            );
        }

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

Conclusie

Spring for GraphQL biedt een elegante GraphQL integratie in het Spring ecosysteem. De beheersing van resolvers, DataLoaders en het beheer van het N+1-probleem vormen de kennisbasis die wordt verwacht bij sollicitatiegesprekken.

Spring GraphQL sollicitatie checklist:

  • ✅ De rol uitleggen van annotaties @QueryMapping, @MutationMapping en @SchemaMapping
  • ✅ Het N+1-probleem en de impact op GraphQL prestaties beschrijven
  • ✅ Een DataLoader implementeren met @BatchMapping of BatchLoaderRegistry
  • ✅ Mutation inputs valideren met Bean Validation
  • ✅ Resolvers beveiligen met @PreAuthorize en Spring Security
  • ✅ Resolvers testen met GraphQlTester
  • ✅ Subscriptions configureren voor real-time functionaliteit
  • ✅ De API beschermen tegen te complexe queries

Tags

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

Delen

Gerelateerde artikelen