Skip to content
Spring Boot sb graphql 4 min read

Schema & Resolvers

Spring for GraphQL follows a schema-first workflow: you describe the API in a schema file, then write Spring @Controller beans whose methods resolve each field. This page covers schema definition, query and nested-field resolvers, argument binding, and how to avoid the dreaded N+1 query problem with batch loading.

Defining the schema

Schema files use the GraphQL Schema Definition Language (SDL) and live under src/main/resources/graphql/. Any file ending in .graphqls (or .gqls) is auto-loaded and merged, so you can split a large schema across files.

# src/main/resources/graphql/schema.graphqls
type Query {
    books: [Book!]!
    book(id: ID!): Book
    booksByAuthor(authorId: ID!): [Book!]!
}

type Book {
    id: ID!
    title: String!
    pages: Int
    author: Author!
}

type Author {
    id: ID!
    name: String!
}

A few SDL essentials: ! marks a field as non-null, [Book!]! is a non-null list of non-null books, and ID is a serialized-as-string scalar. The root Query type lists every entry point a client can request.

Query resolvers with @QueryMapping

A resolver is a @Controller. Annotate a method with @QueryMapping and Spring maps it to the schema field with the same name. The return type is converted to the GraphQL type by matching field names.

@Controller
@RequiredArgsConstructor
public class BookController {

    private final BookService bookService;

    @QueryMapping
    public List<Book> books() {
        return bookService.findAll();
    }

    @QueryMapping
    public Book book(@Argument String id) {
        return bookService.findById(id);
    }
}

If the Java method name differs from the field, set it explicitly: @QueryMapping("book").

Binding arguments with @Argument

@Argument binds a named GraphQL argument to a method parameter. Scalars map directly; input objects map to records or POJOs by field name.

@QueryMapping
public List<Book> booksByAuthor(@Argument String authorId) {
    return bookService.findByAuthor(authorId);
}

Tip: Argument names are matched by parameter name, so compile with -parameters (Spring Boot’s parent POM enables this for you). Otherwise specify @Argument("authorId") explicitly.

Nested fields with @SchemaMapping

The Book.author field is not a simple property lookup — it needs its own resolution logic (a service call, perhaps). A @SchemaMapping method resolves a field on a specific type. The source object (the parent Book) is injected as the first parameter.

@Controller
@RequiredArgsConstructor
public class BookController {

    private final AuthorService authorService;

    @SchemaMapping(typeName = "Book", field = "author")
    public Author author(Book book) {
        return authorService.findById(book.authorId());
    }
}

You can omit typeName/field: Spring infers the type from the first parameter’s class and the field from the method name. So public Author author(Book book) on a controller is enough.

A query that traverses both types:

query {
  book(id: "1") {
    title
    author { name }
  }
}

Output:

{
  "data": {
    "book": {
      "title": "Clean Code",
      "author": { "name": "Robert C. Martin" }
    }
  }
}

The N+1 problem

@SchemaMapping resolves the nested field once per parent object. Fetch a list of 50 books and request each book’s author, and the author resolver fires 50 times — 1 query for the books plus N queries for authors. This is the classic N+1 problem, the same one described in Spring Data JPA: N+1.

query {
  books {        # 1 query
    title
    author { name }   # N more queries, one per book
  }
}

Batch loading with @BatchMapping

@BatchMapping collects all parent objects of one type and resolves the field for the whole batch in a single call. Spring for GraphQL builds on a DataLoader under the hood and automatically registers it.

@BatchMapping(typeName = "Book", field = "author")
public Map<Book, Author> author(List<Book> books) {
    List<String> authorIds = books.stream()
            .map(Book::authorId)
            .distinct()
            .toList();

    Map<String, Author> byId = authorService.findAllById(authorIds).stream()
            .collect(Collectors.toMap(Author::id, a -> a));

    return books.stream()
            .collect(Collectors.toMap(b -> b, b -> byId.get(b.authorId())));
}

Now the same query above runs 2 queries total: one for books, one batched lookup for all authors. The method returns a Map<Book, Author> keyed by the source object so Spring can route each author back to its book.

ApproachAnnotationQueries for N parents
Per-field resolution@SchemaMapping1 + N
Batched resolution@BatchMapping2 (1 + 1 batch)

Note: Use @BatchMapping whenever a nested field is backed by a repository or remote call and the parent can appear in a list. For trivial in-memory lookups, @SchemaMapping is fine. See Java Streams for the collector patterns used above.

Putting it together

@Controller
@RequiredArgsConstructor
public class BookController {

    private final BookService bookService;
    private final AuthorService authorService;

    @QueryMapping
    public List<Book> books() {
        return bookService.findAll();
    }

    @QueryMapping
    public Book book(@Argument String id) {
        return bookService.findById(id);
    }

    @BatchMapping(typeName = "Book", field = "author")
    public Map<Book, Author> author(List<Book> books) {
        Map<String, Author> byId = authorService
                .findAllById(books.stream().map(Book::authorId).distinct().toList())
                .stream().collect(Collectors.toMap(Author::id, a -> a));
        return books.stream().collect(Collectors.toMap(b -> b, b -> byId.get(b.authorId())));
    }
}

Warning: Keep resolvers thin. Push data access into services and repositories exactly as you would for a REST controller — the controller layer only maps schema fields to calls.

Last updated June 13, 2026
Was this helpful?