Spring Boot GraphQL: Schema-First APIs

Spring for GraphQL (spring-boot-starter-graphql) is the official Spring integration for building GraphQL APIs. It uses a schema-first approach where you define your types in .graphqls files and map them to Java methods with @QueryMapping, @MutationMapping, and @SchemaMapping annotations. Built on top of GraphQL Java, it provides DataLoader support, security integration, and a dedicated testing API.

Contents

Dependency & Schema Setup

Add the spring-boot-starter-graphql starter alongside spring-boot-starter-web. Spring Boot auto-configures a GraphQL endpoint at /graphql and picks up all .graphqls files from src/main/resources/graphql/.

XML
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-graphql</artifactId>
</dependency>
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-web</artifactId>
</dependency>

Enable the built-in GraphiQL UI for testing queries during development:

Properties
# application.properties
spring.graphql.graphiql.enabled=true
spring.graphql.graphiql.path=/graphiql
spring.graphql.schema.locations=classpath:graphql/
Place schema files in src/main/resources/graphql/. Spring Boot merges all .graphqls files found in that directory into a single schema, so you can split types across multiple files for better organization.

Project structure for a typical schema-first GraphQL application:

Java
src/main/resources/
  graphql/
    schema.graphqls        // Query and Mutation root types
    book.graphqls          // Book type definition
    author.graphqls        // Author type definition
src/main/java/
  com.example.graphql/
    controller/
      BookController.java  // @QueryMapping, @MutationMapping
      AuthorController.java
    model/
      Book.java
      Author.java
    service/
      BookService.java
    repository/
      BookRepository.java

Defining Types & Queries

GraphQL uses a Schema Definition Language (SDL) to declare types, queries, and mutations. Spring Boot reads these .graphqls files and wires them to Java controllers automatically.

GraphQL
# schema.graphqls — root Query and Mutation types

type Query {
    books: [Book!]!
    bookById(id: ID!): Book
    booksByGenre(genre: String!): [Book!]!
}

type Mutation {
    createBook(input: CreateBookInput!): Book!
    updateBook(id: ID!, input: UpdateBookInput!): Book
    deleteBook(id: ID!): Boolean!
}

Define the object types and input types in separate files or in the same schema file:

GraphQL
# book.graphqls — Book type and input types

type Book {
    id: ID!
    title: String!
    isbn: String
    genre: String!
    pageCount: Int
    publishedYear: Int
    author: Author!
}

type Author {
    id: ID!
    name: String!
    bio: String
    books: [Book!]!
}

input CreateBookInput {
    title: String!
    isbn: String
    genre: String!
    pageCount: Int
    publishedYear: Int
    authorId: ID!
}

input UpdateBookInput {
    title: String
    isbn: String
    genre: String
    pageCount: Int
    publishedYear: Int
}

GraphQL scalar types map directly to Java types:

Java
/*
  GraphQL Scalar    Java Type
  ─────────────────────────────
  ID                String
  String            String
  Int               Integer / int
  Float             Double / double
  Boolean           Boolean / boolean
*/
The ! suffix in SDL marks a field as non-null. [Book!]! means the list itself is non-null and each element in the list is also non-null. Use non-null types to communicate required fields clearly in your API contract.

@QueryMapping & @MutationMapping

Spring for GraphQL maps SDL queries and mutations to Java methods using @QueryMapping and @MutationMapping. The method name must match the field name in the schema unless you specify it explicitly.

Java
import org.springframework.graphql.data.method.annotation.Argument;
import org.springframework.graphql.data.method.annotation.MutationMapping;
import org.springframework.graphql.data.method.annotation.QueryMapping;
import org.springframework.stereotype.Controller;

@Controller
public class BookController {

    private final BookService bookService;

    public BookController(BookService bookService) {
        this.bookService = bookService;
    }

    // maps to Query.books in schema
    @QueryMapping
    public List<Book> books() {
        return bookService.findAll();
    }

    // maps to Query.bookById(id) in schema
    @QueryMapping
    public Book bookById(@Argument ID id) {
        return bookService.findById(id);
    }

    // maps to Query.booksByGenre(genre) in schema
    @QueryMapping
    public List<Book> booksByGenre(@Argument String genre) {
        return bookService.findByGenre(genre);
    }

    // maps to Mutation.createBook(input) in schema
    @MutationMapping
    public Book createBook(@Argument CreateBookInput input) {
        return bookService.create(input);
    }

    // maps to Mutation.updateBook(id, input) in schema
    @MutationMapping
    public Book updateBook(@Argument ID id, @Argument UpdateBookInput input) {
        return bookService.update(id, input);
    }

    // maps to Mutation.deleteBook(id) in schema
    @MutationMapping
    public boolean deleteBook(@Argument ID id) {
        return bookService.delete(id);
    }
}

The @Argument annotation binds GraphQL arguments to method parameters. For input types, Spring automatically maps the GraphQL input object to the corresponding Java class:

Java
public record CreateBookInput(
    String title,
    String isbn,
    String genre,
    Integer pageCount,
    Integer publishedYear,
    String authorId
) {}

public record UpdateBookInput(
    String title,
    String isbn,
    String genre,
    Integer pageCount,
    Integer publishedYear
) {}
Use @Controller (not @RestController) for GraphQL controllers. The @RestController annotation adds @ResponseBody, which is irrelevant for GraphQL since the framework handles serialization through its own transport layer.

@SchemaMapping for Nested Types

When a GraphQL type has a field that references another type (e.g., Book.author), use @SchemaMapping to resolve that relationship. Spring calls the annotated method for each parent object that needs the nested field.

Java
import org.springframework.graphql.data.method.annotation.SchemaMapping;
import org.springframework.stereotype.Controller;

@Controller
public class AuthorController {

    private final AuthorService authorService;
    private final BookService bookService;

    public AuthorController(AuthorService authorService, BookService bookService) {
        this.authorService = authorService;
        this.bookService = bookService;
    }

    // resolves Book.author — called for each Book returned by a query
    @SchemaMapping(typeName = "Book", field = "author")
    public Author author(Book book) {
        return authorService.findById(book.getAuthorId());
    }

    // resolves Author.books — called for each Author
    @SchemaMapping(typeName = "Author", field = "books")
    public List<Book> books(Author author) {
        return bookService.findByAuthorId(author.getId());
    }
}

The method parameter type determines the parent type. Spring injects the parent object so you can use its fields to load the related data.

Each @SchemaMapping method is called once per parent object. If a query returns 50 books, the author(Book book) method fires 50 times, resulting in 50 separate database queries. This is the N+1 problem. Use @BatchMapping or DataLoaders (covered next) to batch these calls.

You can also use @SchemaMapping as a shorthand without specifying typeName and field if the method name matches the field name and the parameter type matches the parent type:

Java
@Controller
public class BookFieldController {

    private final AuthorService authorService;

    public BookFieldController(AuthorService authorService) {
        this.authorService = authorService;
    }

    // inferred: typeName = "Book" (from parameter), field = "author" (from method name)
    @SchemaMapping
    public Author author(Book book) {
        return authorService.findById(book.getAuthorId());
    }
}

DataLoader & BatchMapping

The N+1 problem is the most common performance issue in GraphQL. @BatchMapping solves it by batching all parent objects into a single method call, allowing you to load related data in one query.

Java
import org.springframework.graphql.data.method.annotation.BatchMapping;
import org.springframework.stereotype.Controller;

@Controller
public class BookBatchController {

    private final AuthorService authorService;

    public BookBatchController(AuthorService authorService) {
        this.authorService = authorService;
    }

    // called once with ALL Book objects that need their author resolved
    @BatchMapping(typeName = "Book", field = "author")
    public Map<Book, Author> author(List<Book> books) {
        // collect all unique author IDs
        List<String> authorIds = books.stream()
            .map(Book::getAuthorId)
            .distinct()
            .toList();

        // single batch query
        Map<String, Author> authorMap = authorService.findByIds(authorIds)
            .stream()
            .collect(Collectors.toMap(Author::getId, Function.identity()));

        // map each book to its author
        return books.stream()
            .collect(Collectors.toMap(
                Function.identity(),
                book -> authorMap.get(book.getAuthorId())
            ));
    }
}

The return type of a @BatchMapping method can be Map<Parent, Child> for single-valued fields or Map<Parent, List<Child>> for list fields:

Java
@Controller
public class AuthorBatchController {

    private final BookService bookService;

    public AuthorBatchController(BookService bookService) {
        this.bookService = bookService;
    }

    // resolves Author.books for all authors in one batch
    @BatchMapping(typeName = "Author", field = "books")
    public Map<Author, List<Book>> books(List<Author> authors) {
        List<String> authorIds = authors.stream()
            .map(Author::getId)
            .toList();

        // single query: SELECT * FROM books WHERE author_id IN (...)
        Map<String, List<Book>> booksByAuthorId = bookService
            .findByAuthorIds(authorIds)
            .stream()
            .collect(Collectors.groupingBy(Book::getAuthorId));

        return authors.stream()
            .collect(Collectors.toMap(
                Function.identity(),
                author -> booksByAuthorId.getOrDefault(author.getId(), List.of())
            ));
    }
}

For more advanced scenarios, you can register a DataLoader directly with the GraphQL engine using BatchLoaderRegistry:

Java
import org.springframework.graphql.execution.BatchLoaderRegistry;
import org.springframework.stereotype.Component;
import reactor.core.publisher.Mono;

@Component
public class AuthorDataLoaderConfig {

    public AuthorDataLoaderConfig(BatchLoaderRegistry registry,
                                   AuthorService authorService) {
        registry.forTypePair(String.class, Author.class)
            .registerMappedBatchLoader((authorIds, env) -> {
                // returns Mono<Map<String, Author>>
                return Mono.fromCallable(() ->
                    authorService.findByIds(authorIds.stream().toList())
                        .stream()
                        .collect(Collectors.toMap(Author::getId, Function.identity()))
                );
            });
    }
}
@BatchMapping is usually simpler and sufficient for most cases. Use BatchLoaderRegistry directly when you need to share a DataLoader across multiple controllers or when you need access to the DataLoader environment for caching configuration.

Error Handling

GraphQL returns errors in a structured errors array alongside partial data. Spring for GraphQL provides DataFetcherExceptionResolver to map Java exceptions to GraphQL error responses.

Java
import graphql.GraphQLError;
import graphql.schema.DataFetchingEnvironment;
import org.springframework.graphql.execution.DataFetcherExceptionResolverAdapter;
import org.springframework.graphql.execution.ErrorType;
import org.springframework.stereotype.Component;

@Component
public class GraphQLExceptionResolver extends DataFetcherExceptionResolverAdapter {

    @Override
    protected GraphQLError resolveToSingleError(Throwable ex,
                                                 DataFetchingEnvironment env) {
        if (ex instanceof BookNotFoundException) {
            return GraphQLError.newError()
                .errorType(ErrorType.NOT_FOUND)
                .message(ex.getMessage())
                .path(env.getExecutionStepInfo().getPath())
                .location(env.getField().getSourceLocation())
                .build();
        }

        if (ex instanceof ValidationException) {
            return GraphQLError.newError()
                .errorType(ErrorType.BAD_REQUEST)
                .message(ex.getMessage())
                .path(env.getExecutionStepInfo().getPath())
                .location(env.getField().getSourceLocation())
                .build();
        }

        // return null to let other resolvers handle it
        return null;
    }
}

Spring for GraphQL provides several built-in ErrorType classifications:

Java
/*
  ErrorType            HTTP Analogy    Use Case
  ────────────────────────────────────────────────────────────
  BAD_REQUEST          400             Invalid input, validation errors
  UNAUTHORIZED         401             Missing authentication
  FORBIDDEN            403             Insufficient permissions
  NOT_FOUND            404             Entity not found
  INTERNAL_ERROR       500             Unexpected server errors
*/

For multiple errors (e.g., input validation), override resolveToMultipleErrors instead:

Java
@Override
protected List<GraphQLError> resolveToMultipleErrors(Throwable ex,
                                                       DataFetchingEnvironment env) {
    if (ex instanceof ConstraintViolationException cve) {
        return cve.getConstraintViolations().stream()
            .map(violation -> GraphQLError.newError()
                .errorType(ErrorType.BAD_REQUEST)
                .message(violation.getPropertyPath() + ": " + violation.getMessage())
                .path(env.getExecutionStepInfo().getPath())
                .location(env.getField().getSourceLocation())
                .build())
            .toList();
    }
    return null;
}
By default, Spring for GraphQL classifies unhandled exceptions as INTERNAL_ERROR and masks the message to prevent leaking implementation details. Always register a resolver for expected exceptions so clients receive meaningful error messages.

Testing with GraphQlTester

Spring for GraphQL provides GraphQlTester for fluent, type-safe testing. Use @GraphQlTest to slice-test controllers or HttpGraphQlTester for integration tests.

Java
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.graphql.GraphQlTest;
import org.springframework.graphql.test.tester.GraphQlTester;
import org.springframework.boot.test.mock.mockbean.MockBean;
import org.junit.jupiter.api.Test;

@GraphQlTest(BookController.class)
class BookControllerTest {

    @Autowired
    private GraphQlTester graphQlTester;

    @MockBean
    private BookService bookService;

    @Test
    void shouldReturnAllBooks() {
        when(bookService.findAll()).thenReturn(List.of(
            new Book("1", "Spring in Action", "978-1617297571", "Technology", 520, 2022, "a1"),
            new Book("2", "GraphQL in Action", "978-1617295683", "Technology", 375, 2021, "a2")
        ));

        graphQlTester.documentName("books")     // loads graphql-test/books.graphql
            .execute()
            .path("books")
            .entityList(Book.class)
            .hasSize(2)
            .satisfies(books -> {
                assertThat(books.get(0).getTitle()).isEqualTo("Spring in Action");
                assertThat(books.get(1).getTitle()).isEqualTo("GraphQL in Action");
            });
    }

    @Test
    void shouldReturnBookById() {
        when(bookService.findById("1")).thenReturn(
            new Book("1", "Spring in Action", "978-1617297571", "Technology", 520, 2022, "a1")
        );

        graphQlTester.document("""
                query {
                    bookById(id: "1") {
                        id
                        title
                        genre
                    }
                }
            """)
            .execute()
            .path("bookById.title")
            .entity(String.class)
            .isEqualTo("Spring in Action");
    }
}

Place reusable test queries in src/test/resources/graphql-test/ and reference them with documentName():

GraphQL
# src/test/resources/graphql-test/books.graphql
query {
    books {
        id
        title
        genre
        pageCount
    }
}

For full integration tests with a running server, use HttpGraphQlTester:

Java
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.web.server.LocalServerPort;
import org.springframework.graphql.test.tester.HttpGraphQlTester;
import org.springframework.test.web.reactive.server.WebTestClient;

@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
class BookIntegrationTest {

    private HttpGraphQlTester graphQlTester;

    @BeforeEach
    void setUp(@LocalServerPort int port) {
        WebTestClient client = WebTestClient.bindToServer()
            .baseUrl("http://localhost:" + port + "/graphql")
            .build();
        graphQlTester = HttpGraphQlTester.create(client);
    }

    @Test
    void shouldCreateAndQueryBook() {
        // create
        graphQlTester.document("""
                mutation {
                    createBook(input: {
                        title: "Test Book"
                        genre: "Fiction"
                        authorId: "a1"
                    }) {
                        id
                        title
                    }
                }
            """)
            .execute()
            .path("createBook.title")
            .entity(String.class)
            .isEqualTo("Test Book");
    }

    @Test
    void shouldReturnErrorForMissingBook() {
        graphQlTester.document("""
                query {
                    bookById(id: "nonexistent") {
                        id
                        title
                    }
                }
            """)
            .execute()
            .errors()
            .satisfy(errors -> {
                assertThat(errors).hasSize(1);
                assertThat(errors.get(0).getMessage()).contains("not found");
            });
    }
}

Security & Authorization

Spring for GraphQL integrates with Spring Security. Use @PreAuthorize on resolver methods to enforce role-based access control on individual queries and mutations.

XML
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-security</artifactId>
</dependency>
Java
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.web.SecurityFilterChain;

@Configuration
@EnableMethodSecurity
public class SecurityConfig {

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http
            .csrf(csrf -> csrf.disable())
            .authorizeHttpRequests(auth -> auth
                .requestMatchers("/graphiql").permitAll()
                .requestMatchers("/graphql").authenticated()
                .anyRequest().authenticated()
            )
            .httpBasic(Customizer.withDefaults());
        return http.build();
    }
}

Apply @PreAuthorize directly on resolver methods for fine-grained control:

Java
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.graphql.data.method.annotation.QueryMapping;
import org.springframework.graphql.data.method.annotation.MutationMapping;
import org.springframework.stereotype.Controller;

@Controller
public class SecuredBookController {

    private final BookService bookService;

    public SecuredBookController(BookService bookService) {
        this.bookService = bookService;
    }

    // any authenticated user can query books
    @QueryMapping
    public List<Book> books() {
        return bookService.findAll();
    }

    // only users with ADMIN role can create books
    @PreAuthorize("hasRole('ADMIN')")
    @MutationMapping
    public Book createBook(@Argument CreateBookInput input) {
        return bookService.create(input);
    }

    // only users with ADMIN role can delete books
    @PreAuthorize("hasRole('ADMIN')")
    @MutationMapping
    public boolean deleteBook(@Argument ID id) {
        return bookService.delete(id);
    }

    // owner or admin can update
    @PreAuthorize("hasRole('ADMIN') or @bookService.isOwner(#id, authentication.name)")
    @MutationMapping
    public Book updateBook(@Argument ID id, @Argument UpdateBookInput input) {
        return bookService.update(id, input);
    }
}
Spring Security context is automatically propagated to GraphQL resolvers. The @AuthenticationPrincipal annotation can also be used in resolver methods to inject the current user directly as a parameter.

Access the authenticated user inside any resolver:

Java
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.security.core.userdetails.UserDetails;

@QueryMapping
public List<Book> myBooks(@AuthenticationPrincipal UserDetails user) {
    return bookService.findByOwner(user.getUsername());
}