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-graphqlspring-boot-starter-web/graphql.graphqlssrc/main/resources/graphql/
<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:
# application.properties
spring.graphql.graphiql.enabled=true
spring.graphql.graphiql.path=/graphiql
spring.graphql.schema.locations=classpath:graphql/
src/main/resources/graphql/.graphqlsProject structure for a typical schema-first GraphQL application:
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
# 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:
# 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:
/*
GraphQL Scalar Java Type
─────────────────────────────
ID String
String String
Int Integer / int
Float Double / double
Boolean Boolean / boolean
*/
![Book!]!@QueryMapping & @MutationMapping
Spring for GraphQL maps SDL queries and mutations to Java methods using @QueryMapping@MutationMapping
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
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
) {}
@Controller@RestController@RestController@ResponseBody@SchemaMapping for Nested Types
When a GraphQL type has a field that references another type (e.g., Book.author@SchemaMapping
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.
@SchemaMappingauthor(Book book)@BatchMappingYou can also use @SchemaMappingtypeNamefield
@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
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 @BatchMappingMap<Parent, Child>Map<Parent, List<Child>>
@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 DataLoaderBatchLoaderRegistry
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()))
);
});
}
}
@BatchMappingBatchLoaderRegistryError Handling
GraphQL returns errors in a structured errorsDataFetcherExceptionResolver
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
/*
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
@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;
}
INTERNAL_ERRORTesting with GraphQlTester
Spring for GraphQL provides GraphQlTester@GraphQlTestHttpGraphQlTester
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/documentName()
# src/test/resources/graphql-test/books.graphql
query {
books {
id
title
genre
pageCount
}
}
For full integration tests with a running server, use HttpGraphQlTester
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
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
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
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);
}
}
@AuthenticationPrincipalAccess the authenticated user inside any resolver:
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());
}