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.
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.
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.
@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:
@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.
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.
@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;
}
}
@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.
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
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:
- Write a schema covering tasks and their assignee, with at least one query, one field resolver for the relationship, and one mutation.
- 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.
- 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. - Add a batched DataLoader for the assignee lookup and confirm the same query now fires exactly two database queries total, regardless of list size.
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?
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?
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?
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.