Spring Boot GraphQL with Spring for GraphQL: Batch Loading, Error Shapes, and the Three Security Mistakes That Compile

Code tested with Spring Boot 3.5.16, Spring for GraphQL 1.4.6, graphql-java 24.3 and Java 17; 17 integration tests plus recorded curl sessions

Revision note (2026-09-15). The earlier version of this article described the starter as bundling "GraphQL Java 19.x", recommended a test module from a different project (graphql-spring-boot-test), and put @PreAuthorize("hasRole('ADMIN')") on a controller without @EnableMethodSecurity, with an admin user that had no roles, behind Spring Boot's default security chain, which rejects the article's own curl mutation. It also said unauthorized calls return HTTP 403 and that validation exceptions "propagate as GraphQL errors", neither of which matches what a client receives. This version is rebuilt around a project with 17 integration tests and recorded curl sessions on Spring Boot 3.5.16.

What the starter gives you in Spring Boot 3.5.16

spring-boot-starter-graphql pulls in Spring for GraphQL 1.4.6 and graphql-java 24.3 (with java-dataloader 5.0.3). The output of gradle dependencies is in the lab README. Tests use org.springframework.graphql:spring-graphql-test, which ships GraphQlTester; Spring Boot's testing reference also asks for spring-boot-starter-webflux on the test classpath because HttpGraphQlTester is built on WebTestClient. That does not turn a Spring MVC application into a WebFlux one; it only affects tests.

Tested versions: Spring Boot 3.5.16, Spring Framework 6.2.19, Spring Security 6.5.11, Hibernate 6.6.53 with H2, Java 17 (Amazon Corretto 17.0.14), Gradle 8.8.

Schema with one nested field

The bookstore schema keeps the earlier shape but adds a nested author field, because without a nested field there is nothing for a batch loader to do:

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

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

type Query {
  books(page: Int = 0, size: Int = 10): [Book!]!
  bookById(id: ID!): Book
}

input BookInput {
  title: String!
  authorId: ID!
  publishedYear: Int
}

type Mutation {
  addBook(book: BookInput!): Book!
  updateBook(id: ID!, book: BookInput!): Book!
  deleteBook(id: ID!): Boolean!
}

The Book entity stores authorId as a plain column on purpose. The GraphQL layer has to look the author up, which is exactly where N+1 appears.

Controllers

Queries and mutations are ordinary @Controller methods. The input type binds to a record; no Lombok needed:

public record BookInput(String title, Long authorId, Integer publishedYear) {
}

@Controller
public class BookController {

    private final BookService service;

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

    @QueryMapping
    public List<Book> books(@Argument int page, @Argument int size) {
        return service.getBooks(page, size);
    }

    @QueryMapping
    public Book bookById(@Argument Long id) {
        return service.getBook(id).orElse(null);
    }

    @PreAuthorize("hasRole('ADMIN')")
    @MutationMapping
    public Book addBook(@Argument BookInput book) {
        return service.addBook(book);
    }

    @PreAuthorize("hasRole('ADMIN')")
    @MutationMapping
    public Book updateBook(@Argument Long id, @Argument BookInput book) {
        return service.updateBook(id, book);
    }

    @PreAuthorize("hasRole('ADMIN')")
    @MutationMapping
    public boolean deleteBook(@Argument Long id) {
        return service.deleteBook(id);
    }
}

bookById for an unknown id returns null with no error (test unknownBookIsNullNotAnError); the field is nullable in the schema, so that is the contract.

N+1, measured

The obvious way to resolve Book.author is one @SchemaMapping method called per book:

@SchemaMapping(typeName = "Book", field = "author")
public Author author(Book book) {
    return authors.findById(book.getAuthorId()).orElse(null);
}

The batched way is @BatchMapping. Spring for GraphQL registers a DataLoader for the field and calls the method once per request with every Book that needs an author:

@BatchMapping(typeName = "Book", field = "author")
public Map<Book, Author> author(List<Book> books) {
    List<Long> ids = books.stream().map(Book::getAuthorId).distinct().toList();
    Map<Long, Author> byId = authors.findAllById(ids).stream()
        .collect(Collectors.toMap(Author::getId, Function.identity()));
    return books.stream().collect(Collectors.toMap(Function.identity(), b -> byId.get(b.getAuthorId())));
}

The lab counts JDBC statements with Hibernate's Statistics (hibernate.generate_statistics=true) around one query that lists 10 books by 10 different authors:

[per-book @SchemaMapping] JDBC statements for 10 books: 11
[batch @BatchMapping] JDBC statements for 10 books: 2

One statement for the books plus one per author, versus one for the books plus one IN query. The tests pin both numbers. @BatchMapping needs nothing else: no BatchLoaderRegistry code, no DataLoader argument in the method.

What errors look like to the client

Validation lives in the service and throws IllegalArgumentException(&quot;Title must not be empty&quot;). Without any exception handling, this is what the client gets for a blank title:

classification: INTERNAL_ERROR
message: INTERNAL_ERROR for 7b1c3f2e-...

Spring for GraphQL documents this: an unresolved exception is reported as INTERNAL_ERROR with a deliberately opaque message that only carries the execution id, and the real message goes to the server log. Test illegalArgumentExceptionBecomesInternalErrorWithoutItsMessage asserts that "Title must not be empty" never reaches the client.

To surface it, map the exception in a @ControllerAdvice:

@ControllerAdvice
public class GraphQlExceptionAdvice {

    @GraphQlExceptionHandler
    public GraphQLError handle(IllegalArgumentException ex, DataFetchingEnvironment env) {
        return GraphqlErrorBuilder.newError(env)
            .errorType(ErrorType.BAD_REQUEST)
            .message(ex.getMessage())
            .build();
    }

    @GraphQlExceptionHandler
    public GraphQLError handle(BookNotFoundException ex, DataFetchingEnvironment env) {
        return GraphqlErrorBuilder.newError(env)
            .errorType(ErrorType.NOT_FOUND)
            .message(ex.getMessage())
            .build();
    }
}

With the advice in place:

$ curl -s -u admin:admin -w "nHTTP %{http_code}n" localhost:8092/graphql -H "Content-Type: application/json" 
    -d '{"query":"mutation { addBook(book: {title: " ", authorId: 1}) { id } }"}'
{"errors":[{"message":"Title must not be empty","locations":[{"line":1,"column":12}],"path":["addBook"],"extensions":{"classification":"BAD_REQUEST"}}],"data":null}
HTTP 200

Note the HTTP status: 200. GraphQL errors travel inside a successful HTTP response.

Security: three mistakes that compile and run

The earlier version had all three. Each is reproduced by a test in the lab.

1. @PreAuthorize without @EnableMethodSecurity does nothing. Spring Security's reference states that Spring Boot does not activate method-level authorization by default. In the lab, MethodSecurityConfig carries the annotation and can be switched off with a property. With it off, a user with no roles adds a book through the "admin" mutation, and an anonymous caller runs deleteBook (tests preAuthorizeIsIgnoredWithoutEnableMethodSecurity, evenAnonymousCallersCanMutate).

@Configuration
@EnableMethodSecurity
public class MethodSecurityConfig {
}

2. spring.security.user.name=admin does not make an admin. The properties give the user a name and a password, not a role. With method security on and no spring.security.user.roles, admin/admin is denied:

classification: FORBIDDEN, path: deleteBook

The lab sets spring.security.user.roles=ADMIN (test adminWithoutRoleAdminIsForbidden covers the omission).

3. The auto-configured chain rejects the article's own curl. With spring-boot-starter-security and no SecurityFilterChain bean, Spring Boot's default chain applies: every request authenticated, form login plus HTTP basic, CSRF on. POST /graphql with valid basic credentials and no CSRF token is refused. Under MockMvc that is HTTP 403. Against the running server it shows up as 401 with WWW-Authenticate: Basic:

$ curl -s -u admin:admin -D - -o /dev/null localhost:8092/graphql -H "Content-Type: application/json" -d '{"query":"{ books { id } }"}'
HTTP/1.1 401
WWW-Authenticate: Basic realm="Realm"
Content-Length: 0

The reason is Spring Boot's error handling. The CSRF filter calls sendError(403), Tomcat dispatches to /error, and Spring Security authorizes that ERROR dispatch as well (the reference documents that AuthorizationFilter runs on every dispatch). At that point the caller is still anonymous, because basic credentials are processed after the CSRF check, so the entry point answers 401. Starting the server with spring.security.filter.dispatcher-types=request shows the underlying 403 with Boot's error JSON. That flag is a diagnostic, not a fix.

The fix for a token-authenticated JSON API is a stateless chain with CSRF off:

@Configuration
public class StatelessSecurityConfig {

    @Bean
    SecurityFilterChain api(HttpSecurity http) throws Exception {
        return http
            .csrf(csrf -> csrf.disable())
            .sessionManagement(s -> s.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
            .authorizeHttpRequests(auth -> auth
                .requestMatchers("/graphql", "/graphiql").permitAll()
                .anyRequest().authenticated())
            .httpBasic(Customizer.withDefaults())
            .build();
    }
}

/graphql is open at the HTTP level so that queries work anonymously; mutations are protected by method security. An anonymous mutation then produces a GraphQL error, not an HTTP error:

$ curl -s -w "nHTTP %{http_code}n" localhost:8092/graphql -H "Content-Type: application/json" -d '{"query":"mutation { deleteBook(id: 1) }"}'
{"errors":[{"message":"Unauthorized","locations":[{"line":1,"column":12}],"path":["deleteBook"],"extensions":{"classification":"UNAUTHORIZED"}}],"data":null}
HTTP 200

and the admin mutation works:

$ curl -s -u admin:admin -w "nHTTP %{http_code}n" localhost:8092/graphql -H "Content-Type: application/json" 
    -d '{"query":"mutation { addBook(book: {title: "Refactoring", authorId: 1, publishedYear: 1999}) { id title author { name } } }"}'
{"data":{"addBook":{"id":"4","title":"Refactoring","author":{"name":"Eric Evans"}}}}
HTTP 200

Disabling CSRF is correct only because nothing here relies on a browser session. If you keep form login and sessions, keep CSRF and send the token.

Testing with GraphQlTester

The lab uses @SpringBootTest(webEnvironment = RANDOM_PORT) with @AutoConfigureHttpGraphQlTester for anything that involves security, so the real filter chain and the real transport are exercised:

@Test
void blankTitleIsReportedAsBadRequestWithTheMessage() {
    admin.document("mutation { addBook(book: {title: " ", authorId: 1}) { id } }")
        .execute()
        .errors().satisfy(errors -> {
            assertThat(errors).hasSize(1);
            assertThat(errors.get(0).getErrorType()).isEqualTo(ErrorType.BAD_REQUEST);
            assertThat(errors.get(0).getMessage()).isEqualTo("Title must not be empty");
        });
}

where admin is tester.mutate().headers(h -&gt; h.setBasicAuth(&quot;admin&quot;, &quot;admin&quot;)).build(). The N+1 tests use the transport-less @AutoConfigureGraphQlTester because they only need the execution engine. Spring Boot also offers @GraphQlTest for controller slices; the lab does not use it because the assertions depend on JPA.

The full run: 17 tests across 7 Spring contexts, BUILD SUCCESSFUL in 19s.

What this does not cover

  • Subscriptions and the WebSocket transport.
  • Query depth or complexity limits; nothing in the lab instruments graphql-java for that.
  • GraphiQL behind a session-based chain with CSRF. With the default chain GET /graphiql answered 401 for an anonymous request; the stateless chain redirects to /graphiql?path=/graphql.
  • Field-level authorization and custom directives.
  • A production database; the lab uses H2 in memory with ddl-auto=create-drop. The N+1 statement counts are H2 numbers; the ratio does not depend on the database.

Reproduce it

gradle test --no-daemon                                     # 17 tests
gradle bootRun --no-daemon --args='--server.port=8092'
curl -s localhost:8092/graphql -H "Content-Type: application/json" -d '{"query":"{ books { id title author { name } } }"}'
gradle bootRun --no-daemon --args='--server.port=8092 --demo.security=default'   # the 401 behavior

Sources