Frontend teams often request “just give us these specific fields for this screen.” With REST APIs, you tend to run into over-fetching (returning unnecessary fields) or under-fetching (having to hit multiple endpoints). That’s where GraphQL comes in.

Spring Boot 3.x now officially supports Spring for GraphQL, making it easy to implement with an annotation-based approach. Let’s walk through everything from schema definition to Resolver implementation, N+1 mitigation, and Security integration.

GraphQL vs REST API

Let’s start with a quick comparison.

AspectREST APIGraphQL
EndpointsMultiple per resourceSingle (/graphql)
Response shapeFixed by the serverSpecified by the client
Over-fetchingCommonRare
Schema definitionSeparately via OpenAPISDL is the standard

GraphQL is a good fit for BFF (Backend for Frontend) architectures or when you need to fetch deeply nested data in a single request. For simple CRUD operations or APIs centered on file uploads, REST is usually the simpler choice. For REST API implementation, see Tutorial: Building a REST API with Spring Boot. For automatic documentation generation with OpenAPI, see Auto-generating REST API Docs with OpenAPI (Swagger UI) in Spring Boot.

Project Setup

Add the dependencies to build.gradle.

dependencies {
    implementation 'org.springframework.boot:spring-boot-starter-graphql'
    implementation 'org.springframework.boot:spring-boot-starter-web'
    implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
}

Configure application.properties as follows.

spring.graphql.graphiql.enabled=true
spring.graphql.graphiql.path=/graphiql

GraphiQL is a development-only tool. In production, leave spring.graphql.graphiql.enabled=false (the default), or control it with a Spring Profile.

Place schema files under src/main/resources/graphql/ with the .graphqls extension. This directory is automatically scanned by Spring for GraphQL.

Writing the Schema (SDL)

Let’s define Book and Author in schema.graphqls.

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

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

type Query {
  books: [Book!]!
  book(id: ID!): Book
}

type Mutation {
  createBook(input: CreateBookInput!): Book!
}

input CreateBookInput {
  title: String!
  authorId: ID!
}

! means non-null. The author field lacks ! to allow for cases where no matching author is found.

Implementing Queries with @QueryMapping

With Spring for GraphQL, simply annotate a method with @QueryMapping inside a @Controller class to make it a Resolver. Note that you must use @Controller, not @RestController. @RestController is designed to return response bodies directly, which conflicts with GraphQL’s return value handling.

@Controller
public class BookController {

    private final BookService bookService;

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

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

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

If the method name does not match the Query field name in the schema, an IllegalStateException will be thrown at startup. Always verify that SDL field names match your method names.

At this point, open http://localhost:8080/graphiql to verify everything works. If authentication is required, add Authorization: Bearer <token> in the Headers section.

Implementing Mutations with @MutationMapping

Data creation is handled via Mutations. Representing InputTypes as Java records keeps things concise.

public record CreateBookInput(String title, Long authorId) {}

// Add to BookController
@MutationMapping
public Book createBook(@Argument CreateBookInput input) {
    return bookService.create(input);
}

Resolving Nested Types with @SchemaMapping

To resolve a child field from a parent type — such as Book.author — use @SchemaMapping. The Java Book class must include an authorId field.

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

However, if the books query returns 10 Books, the author resolution runs 10 times — resulting in 11 queries total including the initial one. This is the N+1 problem.

Solving N+1 with @BatchMapping

@BatchMapping allows you to process a list of IDs in a single batch.

@BatchMapping(typeName = "Book", field = "author")
public Map<Book, Author> author(List<Book> books) {
    List<Long> ids = books.stream().map(Book::authorId).toList();
    Map<Long, Author> authorMap = authorService.findAllByIds(ids)
        .stream()
        .collect(Collectors.toMap(Author::getId, a -> a));

    // Collectors.toMap does not allow null values, so collect manually into a HashMap
    Map<Book, Author> result = new HashMap<>();
    books.forEach(b -> result.put(b, authorMap.get(b.authorId())));
    return result;
}

Using Collectors.toMap will throw a NullPointerException if authorMap.get(...) returns null (i.e., a Book with no corresponding Author). Since author is nullable in the schema, this case is entirely possible. Manually putting into a HashMap handles null values safely.

Also, using Book as a map key requires a correct implementation of equals() / hashCode(). Using record generates these automatically. For JPA entities, an id-based implementation is recommended.

For JPA-side performance improvements, see Spring Data JPA Performance Optimization.

Spring Security Integration

To protect the GraphQL endpoint, configure the /graphql path to require authentication in your SecurityConfig.

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

CSRF is disabled for /graphql because GraphQL clients typically authenticate via the Authorization header rather than cookies, making browser-based CSRF attacks unlikely. However, if your setup calls /graphql directly from a browser without that header, you should consider this separately.

To retrieve the authenticated user inside a Resolver, use SecurityContextHolder.getContext().getAuthentication(). You can also apply @PreAuthorize to Resolver methods. For JWT authentication integration, see Implementing JWT Authentication in Spring Boot.

Error Handling

GraphQL errors are returned together in the errors field. To return custom errors, implement DataFetcherExceptionResolverAdapter. In Spring Boot 3.2 and later, the @GraphQlExceptionHandler annotation offers a more concise alternative.

@Component
public class CustomExceptionResolver extends DataFetcherExceptionResolverAdapter {

    @Override
    protected GraphQLError resolveToSingleError(Throwable ex, DataFetchingEnvironment env) {
        if (ex instanceof BookNotFoundException) {
            return GraphqlErrorBuilder.newError(env)
                .message(ex.getMessage())
                .errorType(ErrorType.NOT_FOUND)
                .build();
        }
        return null;
    }
}

For exception handling in REST APIs, see Exception Handling in Spring Boot.

Summary

Spring for GraphQL lets you implement everything intuitively with annotations. Queries and Mutations use @QueryMapping / @MutationMapping, nested resolution uses @SchemaMapping, and N+1 issues are addressed by swapping in @BatchMapping.

GraphQL truly shines in BFF scenarios where each frontend screen needs different data, or when you want to fetch multiple resources in a single request. For simple CRUD, REST is easier to implement and operate — so choose the right tool based on your requirements.