Week 21: GraphQL with Spring for GraphQL

Every endpoint since Week 3 returns a fixed response shape — the server decides exactly which fields come back, and a client that only needs two of twelve fields still receives all twelve, or needs a brand-new endpoint if the built-in ones don't fit. GraphQL inverts that: clients specify exactly the shape of data they want in the query itself. This week builds a schema-first GraphQL API with Spring for GraphQL, and confronts GraphQL's own version of the N+1 problem Week 15 solved for REST.

Module 18 of 22 Week 21 of 26 ~4–5 Hours Hands-on Exercise Included

By the end of this week, you'll be able to

  • Design a GraphQL schema with the GraphQL Schema Definition Language
  • Implement query and field resolvers with @QueryMapping and @SchemaMapping
  • Solve GraphQL's own N+1 query problem with DataLoader batching

1. Schema-First Design with SDL

Spring for GraphQL is schema-first: you write the API's contract in the GraphQL Schema Definition Language (SDL) before writing any Java, and the framework maps your code onto that contract — the reverse direction from springdoc-openapi in Week 16, which generates documentation from existing code. The schema is the actual source of truth here, not a description of it.

src/main/resources/graphql/schema.graphqls
type Task {
    id: ID!
    title: String!
    status: TaskStatus!
    assignee: User          # resolved separately -- see Section 2
    tags: [String!]!
}

type User {
    id: ID!
    name: String!
    tasks: [Task!]!          # the reverse relationship -- Section 3's N+1 case
}

enum TaskStatus {
    OPEN
    IN_PROGRESS
    DONE
}

type Query {
    task(id: ID!): Task
    tasks(status: TaskStatus): [Task!]!
}

type Mutation {
    createTask(title: String!): Task!
    updateTaskStatus(id: ID!, status: TaskStatus!): Task!
}

This single schema answers a question REST always leaves implicit — exactly what shape every response can take, and exactly what every mutation accepts — as a machine-readable contract both the client and server agree on, without needing a separately maintained OpenAPI spec to stay in sync with the code.

The schema is a design decision, not a mechanical translation of your entities

It's tempting to make the schema mirror your JPA entities field-for-field. Resist that — a GraphQL schema is a public API contract, exactly like the REST DTOs from Week 3, and should expose what clients actually need, not every internal column. A schema that's a 1:1 entity mirror tends to leak internal implementation details and makes future refactoring of the entity model a breaking API change.

2. Query & Field Resolvers

A resolver is the Java method that fulfills one field or query from the schema. @QueryMapping implements a top-level Query field; @SchemaMapping implements a field on any other type — including one that isn't a direct column on the underlying entity at all.

TaskGraphQLController.java — the top-level queries
@Controller
class TaskGraphQLController {

    private final TaskRepository taskRepository;

    @QueryMapping
    Task task(@Argument Long id) {
        return taskRepository.findById(id).orElse(null);
    }

    @QueryMapping
    List<Task> tasks(@Argument TaskStatus status) {
        return status == null
            ? taskRepository.findAll()
            : taskRepository.findByStatus(status.name());
    }

    @MutationMapping
    Task createTask(@Argument String title) {
        return taskRepository.save(new Task(null, title, TaskStatus.OPEN, null, List.of()));
    }
}

@Argument binds a schema field's argument directly to a method parameter by name — no manual parsing of a request body the way a REST controller's @RequestBody DTO would need. A field resolver fills in a value that isn't a plain column — assignee on Task, resolved from a separate User lookup rather than being a column on the task table itself:

a field resolver for a related type
@SchemaMapping(typeName = "Task", field = "assignee")
User assignee(Task task) {
    return task.assigneeId() == null ? null : userRepository.findById(task.assigneeId()).orElse(null);
}

This is GraphQL's core value proposition made concrete: a client asking only for { task(id: "1") { title } } never triggers the assignee resolver at all — only the fields actually present in the query get resolved, so a client that doesn't need the assignee never pays the cost of fetching it. A REST endpoint returning a full TaskResponse DTO has no equivalent mechanism; it either always includes every field or needs a separate, purpose-built endpoint for every different shape a client might want.

3. Solving N+1 with DataLoader

That same per-field resolution is also exactly where GraphQL reintroduces the N+1 problem Week 15 solved for REST — in a new, GraphQL-specific shape. Querying a list of 20 tasks, each with its assignee field requested, calls the assignee resolver from Section 2 once per task — 20 separate database queries for 20 tasks' assignees, exactly the pattern an entity graph fixed for JPA, except an entity graph has no direct equivalent here because GraphQL resolves each field independently by design.

the problem, made concrete
query {
  tasks {
    title
    assignee { name }   # fires the "assignee" resolver once PER task in the list
  }
}
// 1 query for the task list, then N more queries -- one per task -- for assignees

DataLoader solves this by batching: instead of each field resolver immediately querying the database, it registers the ID it needs with a shared DataLoader and returns a not-yet-resolved value. DataLoader collects every ID requested during the current execution "tick," then fires a single batched query for all of them at once.

registering a batched DataLoader
@Configuration
class DataLoaderConfig {

    @Bean
    BatchLoaderRegistry userBatchLoaderRegistry(BatchLoaderRegistry registry,
                                                 UserRepository userRepository) {
        registry.forTypePair(Long.class, User.class)
            .registerMappedBatchLoader((userIds, env) ->
                Mono.fromCallable(() ->
                    userRepository.findAllById(userIds).stream()
                        .collect(Collectors.toMap(User::id, u -> u))));
        return registry;
    }
}
the resolver, rewritten to use the batched loader
@SchemaMapping(typeName = "Task", field = "assignee")
CompletableFuture<User> assignee(Task task, DataLoader<Long, User> userLoader) {
    return task.assigneeId() == null ? null : userLoader.load(task.assigneeId());
}

With the DataLoader in place, the same 20-task query with every assignee requested fires exactly 2 queries total — one for the tasks, one batched query fetching every distinct requested user ID at once — regardless of whether the list has 20 tasks or 2,000.

Any field resolver returning a related entity is a DataLoader candidate by default

It's easy to write a working resolver like Section 2's plain userRepository.findById() version and only discover the N+1 cost once real client queries request that field across a list. Treat any resolver that looks up a related entity by ID as something that needs DataLoader batching from the start, the same instinct Week 15 built for JPA associations — don't wait for a slow query log to reveal it.

4. Hands-on Exercise

Hands-on

Build a GraphQL API over the task service, then fix its N+1

Add a GraphQL layer alongside your existing REST API and eliminate a real batching problem.

Requirements:

  1. Write a schema covering tasks and their assignee, with at least one query, one field resolver for the relationship, and one mutation.
  2. Implement the resolvers over your existing task/user repositories, and confirm a query requesting only top-level fields never triggers the field resolver for the relationship.
  3. Reproduce the N+1: query a list of at least 20 tasks with assignee { name } requested on every one, and count the queries fired with SQL logging.
  4. Add a batched DataLoader for the assignee lookup and confirm the same query now fires exactly two database queries total, regardless of list size.
Hint

Spring for GraphQL ships a GraphiQL UI at /graphiql when enabled in properties — use it to write and run test queries interactively while you build resolvers, rather than crafting raw HTTP POST bodies by hand.

5. Knowledge Check

Three quick questions. Expand each to check your answer.

Q1

What does it mean for Spring for GraphQL to be "schema-first," compared to how springdoc-openapi generates its documentation?

The schema written in SDL is the source of truth that's authored first, and Java resolver code is written to fulfill it — the framework maps code onto an existing contract. springdoc-openapi works in the opposite direction: it generates documentation by inspecting already-written controller and DTO code, meaning the code is the source of truth and the docs are derived from it.

Q2

Why does a client requesting only { task(id: "1") { title } } never trigger the assignee field resolver?

GraphQL resolves each field independently and only executes a field's resolver if that field actually appears in the client's query. A field that isn't requested simply never gets resolved — no code path for it runs at all — which is the mechanism that lets a client fetch only the exact fields it needs without a server-side flag or a separate endpoint for every possible response shape.

Q3

How does DataLoader reduce N separate per-item queries down to one batched query?

Instead of each field resolver immediately querying the database for its one ID, it registers that ID with a shared DataLoader and returns a value that resolves later. DataLoader collects every ID requested across the whole batch of resolver calls during the current execution cycle, then fires a single query fetching all of them at once, and distributes each result back to the resolver that asked for it.