GraphQL with Spring for GraphQL: Complete Guide

Introduction
REST APIs are great — until they're not. Mobile apps fetching a user profile get 40 fields when they need 5. A dashboard makes 12 separate API calls to build a single page. A field rename breaks every client. These are the everyday frustrations that led Facebook to build GraphQL in 2012 and open-source it in 2015.
Spring for GraphQL (introduced in Spring Boot 2.7, officially stable since 3.0) is the official Spring integration for GraphQL APIs. It builds on top of GraphQL Java, provides tight integration with Spring Security and Spring Data, and feels natural to any Spring developer.
New to GraphQL? Read What is GraphQL? Complete Guide first — it covers schemas, queries, mutations, and the key differences from REST.
What You'll Learn
✅ Set up Spring for GraphQL with Spring Boot 3
✅ Define GraphQL schemas with types, queries, mutations, and subscriptions
✅ Build @QueryMapping, @MutationMapping, and @SubscriptionMapping controllers
✅ Solve the N+1 problem with DataLoader and @BatchMapping
✅ Secure GraphQL endpoints with Spring Security
✅ Handle errors and validation properly
✅ Write GraphQL tests with GraphQlTester
✅ Apply production best practices: query complexity limits, depth limits, persisted queries
Prerequisites
- Spring Boot fundamentals (Getting Started)
- JPA & database integration (Database Integration)
- GraphQL concepts (What is GraphQL?)
- JWT authentication (Security & JWT)
1. Project Setup
Dependencies
<!-- pom.xml -->
<dependencies>
<!-- Spring for GraphQL -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-graphql</artifactId>
</dependency>
<!-- Web layer -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<!-- JPA for database -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<!-- Security -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<!-- PostgreSQL driver -->
<dependency>
<groupId>org.postgresql</groupId>
<artifactId>postgresql</artifactId>
<scope>runtime</scope>
</dependency>
<!-- GraphQL testing -->
<dependency>
<groupId>org.springframework.graphql</groupId>
<artifactId>spring-graphql-test</artifactId>
<scope>test</scope>
</dependency>
<!-- WebSocket for subscriptions (optional) -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-websocket</artifactId>
</dependency>
</dependencies>Application Configuration
# application.yml
spring:
graphql:
graphiql:
enabled: true # Enable GraphiQL browser IDE
path: /graphiql
path: /graphql # GraphQL endpoint path
schema:
locations: classpath:graphql/ # Schema files location
file-extensions: .graphqls, .gqls
websocket:
path: /graphql-ws # WebSocket path for subscriptions
datasource:
url: jdbc:postgresql://localhost:5432/blogdb
username: postgres
password: postgres
jpa:
hibernate:
ddl-auto: validate
show-sql: falseWhat We'll Build
A Blog API with:
- Authors who write posts
- Posts with comments and tags
- Real-time comment subscriptions
- Authentication-protected mutations
2. Schema Definition
GraphQL schemas live in src/main/resources/graphql/. Spring for GraphQL automatically loads all .graphqls files from this directory.
schema.graphqls
# src/main/resources/graphql/schema.graphqls
# Scalar types
scalar DateTime
# ── Types ──────────────────────────────────
type Author {
id: ID!
name: String!
email: String!
bio: String
posts: [Post!]!
postCount: Int!
createdAt: DateTime!
}
type Post {
id: ID!
title: String!
content: String!
published: Boolean!
author: Author!
comments: [Comment!]!
tags: [String!]!
viewCount: Int!
createdAt: DateTime!
updatedAt: DateTime!
}
type Comment {
id: ID!
content: String!
author: Author!
post: Post!
createdAt: DateTime!
}
# ── Input Types ────────────────────────────
input CreatePostInput {
title: String!
content: String!
tags: [String!]
}
input UpdatePostInput {
title: String
content: String
tags: [String!]
published: Boolean
}
input CreateCommentInput {
postId: ID!
content: String!
}
# ── Pagination ─────────────────────────────
type PostConnection {
edges: [PostEdge!]!
pageInfo: PageInfo!
totalCount: Int!
}
type PostEdge {
node: Post!
cursor: String!
}
type PageInfo {
hasNextPage: Boolean!
hasPreviousPage: Boolean!
startCursor: String
endCursor: String
}
# ── Query ──────────────────────────────────
type Query {
# Single records
author(id: ID!): Author
post(id: ID!): Post
me: Author
# Collections
authors: [Author!]!
posts(
published: Boolean
authorId: ID
tag: String
first: Int = 10
after: String
): PostConnection!
searchPosts(query: String!): [Post!]!
}
# ── Mutation ───────────────────────────────
type Mutation {
createPost(input: CreatePostInput!): Post!
updatePost(id: ID!, input: UpdatePostInput!): Post!
deletePost(id: ID!): Boolean!
publishPost(id: ID!): Post!
createComment(input: CreateCommentInput!): Comment!
deleteComment(id: ID!): Boolean!
}
# ── Subscription ───────────────────────────
type Subscription {
commentAdded(postId: ID!): Comment!
postPublished: Post!
}3. Domain Model & Repositories
// Author.java
@Entity
@Table(name = "authors")
public class Author {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false)
private String name;
@Column(nullable = false, unique = true)
private String email;
private String bio;
@OneToMany(mappedBy = "author",
fetch = FetchType.LAZY)
private List<Post> posts = new ArrayList<>();
@Column(name = "created_at")
private Instant createdAt = Instant.now();
// Getters/setters
}
// Post.java
@Entity
@Table(name = "posts")
public class Post {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false)
private String title;
@Column(nullable = false, columnDefinition = "TEXT")
private String content;
private boolean published = false;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "author_id", nullable = false)
private Author author;
@OneToMany(mappedBy = "post",
cascade = CascadeType.ALL,
fetch = FetchType.LAZY)
private List<Comment> comments = new ArrayList<>();
@ElementCollection
@CollectionTable(name = "post_tags",
joinColumns = @JoinColumn(name = "post_id"))
@Column(name = "tag")
private List<String> tags = new ArrayList<>();
@Column(name = "view_count")
private int viewCount = 0;
@Column(name = "created_at")
private Instant createdAt = Instant.now();
@Column(name = "updated_at")
private Instant updatedAt = Instant.now();
// Getters/setters
}
// Comment.java
@Entity
@Table(name = "comments")
public class Comment {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false, columnDefinition = "TEXT")
private String content;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "author_id", nullable = false)
private Author author;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "post_id", nullable = false)
private Post post;
@Column(name = "created_at")
private Instant createdAt = Instant.now();
// Getters/setters
}// AuthorRepository.java
public interface AuthorRepository
extends JpaRepository<Author, Long> {
Optional<Author> findByEmail(String email);
@Query("""
SELECT a FROM Author a
WHERE a.id IN :ids
""")
List<Author> findAllByIds(
@Param("ids") Collection<Long> ids);
}
// PostRepository.java
public interface PostRepository
extends JpaRepository<Post, Long> {
Page<Post> findByPublished(
boolean published, Pageable pageable);
@Query("""
SELECT p FROM Post p
WHERE LOWER(p.title) LIKE LOWER(:query)
OR LOWER(p.content) LIKE LOWER(:query)
""")
List<Post> searchByQuery(
@Param("query") String query);
@Query("""
SELECT p FROM Post p
WHERE :tag MEMBER OF p.tags
""")
List<Post> findByTag(
@Param("tag") String tag);
List<Post> findByAuthorId(Long authorId);
}
// CommentRepository.java
public interface CommentRepository
extends JpaRepository<Comment, Long> {
List<Comment> findByPostId(Long postId);
@Query("""
SELECT c FROM Comment c
WHERE c.post.id IN :postIds
""")
List<Comment> findByPostIds(
@Param("postIds") Collection<Long> postIds);
}4. Query Controllers
Spring for GraphQL uses @Controller with @QueryMapping, @MutationMapping, and @SchemaMapping annotations.
@Controller
public class AuthorController {
@Autowired
private AuthorService authorService;
// Resolves Query.author
@QueryMapping
public Optional<Author> author(
@Argument Long id) {
return authorService.findById(id);
}
// Resolves Query.authors
@QueryMapping
public List<Author> authors() {
return authorService.findAll();
}
// Resolves Query.me (requires authentication)
@QueryMapping
@PreAuthorize("isAuthenticated()")
public Author me(
@AuthenticationPrincipal
UserDetails userDetails) {
return authorService
.findByEmail(userDetails.getUsername())
.orElseThrow(() ->
new GraphQLException("Author not found"));
}
// Resolves Author.postCount — computed field
@SchemaMapping(typeName = "Author",
field = "postCount")
public int postCount(Author author) {
return author.getPosts().size();
}
}@Controller
public class PostController {
@Autowired
private PostService postService;
@QueryMapping
public Optional<Post> post(@Argument Long id) {
return postService.findById(id);
}
// Cursor-based pagination
@QueryMapping
public PostConnection posts(
@Argument Boolean published,
@Argument Long authorId,
@Argument String tag,
@Argument Integer first,
@Argument String after) {
return postService.findPosts(
published, authorId, tag, first, after);
}
@QueryMapping
public List<Post> searchPosts(
@Argument String query) {
return postService.search(query);
}
}5. Mutation Controllers
@Controller
public class PostMutationController {
@Autowired
private PostService postService;
@MutationMapping
@PreAuthorize("isAuthenticated()")
public Post createPost(
@Argument CreatePostInput input,
@AuthenticationPrincipal
UserDetails userDetails) {
return postService.createPost(
input, userDetails.getUsername());
}
@MutationMapping
@PreAuthorize("isAuthenticated()")
public Post updatePost(
@Argument Long id,
@Argument UpdatePostInput input,
@AuthenticationPrincipal
UserDetails userDetails) {
return postService.updatePost(
id, input, userDetails.getUsername());
}
@MutationMapping
@PreAuthorize("isAuthenticated()")
public boolean deletePost(
@Argument Long id,
@AuthenticationPrincipal
UserDetails userDetails) {
postService.deletePost(
id, userDetails.getUsername());
return true;
}
@MutationMapping
@PreAuthorize("isAuthenticated()")
public Post publishPost(
@Argument Long id,
@AuthenticationPrincipal
UserDetails userDetails) {
return postService.publishPost(
id, userDetails.getUsername());
}
}
@Controller
public class CommentMutationController {
@Autowired
private CommentService commentService;
@Autowired
private ApplicationEventPublisher eventPublisher;
@MutationMapping
@PreAuthorize("isAuthenticated()")
public Comment createComment(
@Argument CreateCommentInput input,
@AuthenticationPrincipal
UserDetails userDetails) {
Comment comment = commentService.createComment(
input, userDetails.getUsername());
// Publish event for subscription
eventPublisher.publishEvent(
new CommentAddedEvent(comment));
return comment;
}
@MutationMapping
@PreAuthorize("isAuthenticated()")
public boolean deleteComment(
@Argument Long id,
@AuthenticationPrincipal
UserDetails userDetails) {
commentService.deleteComment(
id, userDetails.getUsername());
return true;
}
}Service Layer
@Service
@Transactional
public class PostService {
@Autowired
private PostRepository postRepository;
@Autowired
private AuthorRepository authorRepository;
@Transactional(readOnly = true)
public PostConnection findPosts(
Boolean published, Long authorId,
String tag, int first, String after) {
// Decode cursor (base64-encoded ID)
Long afterId = after != null
? decodeCursor(after) : null;
List<Post> posts;
if (authorId != null) {
posts = postRepository
.findByAuthorId(authorId);
} else if (tag != null) {
posts = postRepository.findByTag(tag);
} else if (published != null) {
posts = postRepository
.findByPublished(published,
PageRequest.of(0, first + 1,
Sort.by("createdAt").descending()))
.getContent();
} else {
posts = postRepository
.findAll(PageRequest.of(0, first + 1,
Sort.by("createdAt").descending()))
.getContent();
}
// Apply cursor filtering
if (afterId != null) {
final Long id = afterId;
posts = posts.stream()
.filter(p -> p.getId() > id)
.collect(Collectors.toList());
}
boolean hasNextPage = posts.size() > first;
List<Post> pageItems = hasNextPage
? posts.subList(0, first) : posts;
List<PostEdge> edges = pageItems.stream()
.map(p -> new PostEdge(p, encodeCursor(p)))
.collect(Collectors.toList());
PageInfo pageInfo = new PageInfo(
hasNextPage,
afterId != null,
edges.isEmpty() ? null
: edges.get(0).cursor(),
edges.isEmpty() ? null
: edges.get(edges.size() - 1).cursor()
);
return new PostConnection(
edges, pageInfo, posts.size());
}
public Post createPost(
CreatePostInput input,
String authorEmail) {
Author author = authorRepository
.findByEmail(authorEmail)
.orElseThrow(() ->
new IllegalArgumentException(
"Author not found"));
Post post = new Post();
post.setTitle(input.title());
post.setContent(input.content());
post.setAuthor(author);
post.setTags(input.tags() != null
? input.tags() : List.of());
return postRepository.save(post);
}
public Post publishPost(
Long id, String authorEmail) {
Post post = postRepository.findById(id)
.orElseThrow(() ->
new IllegalArgumentException(
"Post not found: " + id));
if (!post.getAuthor().getEmail()
.equals(authorEmail)) {
throw new AccessDeniedException(
"You don't own this post");
}
post.setPublished(true);
post.setUpdatedAt(Instant.now());
return postRepository.save(post);
}
private String encodeCursor(Post post) {
return Base64.getEncoder().encodeToString(
post.getId().toString().getBytes());
}
private Long decodeCursor(String cursor) {
return Long.parseLong(new String(
Base64.getDecoder().decode(cursor)));
}
}6. Solving N+1 with DataLoader & @BatchMapping
This is the most critical performance topic in GraphQL. Without DataLoader, fetching a list of posts and their authors fires one query per post.
The N+1 Problem
# This query triggers N+1 without DataLoader:
query {
posts(first: 20) {
edges {
node {
title
author { # ← One DB query per post!
name
}
}
}
}
}@BatchMapping — Spring's Elegant Solution
Spring for GraphQL provides @BatchMapping which automatically batches field resolution:
@Controller
public class PostBatchController {
@Autowired
private AuthorRepository authorRepository;
@Autowired
private CommentRepository commentRepository;
// Batches Author loading for all posts at once
@BatchMapping(typeName = "Post", field = "author")
public Map<Post, Author> postAuthors(
List<Post> posts) {
// Collect all author IDs
Set<Long> authorIds = posts.stream()
.map(p -> p.getAuthor().getId())
.collect(Collectors.toSet());
// Single query for all authors
Map<Long, Author> authorsById =
authorRepository.findAllByIds(authorIds)
.stream()
.collect(Collectors.toMap(
Author::getId,
Function.identity()));
// Map each post to its author
return posts.stream()
.collect(Collectors.toMap(
Function.identity(),
p -> authorsById.get(
p.getAuthor().getId())));
}
// Batches Comments loading for all posts at once
@BatchMapping(typeName = "Post", field = "comments")
public Map<Post, List<Comment>> postComments(
List<Post> posts) {
List<Long> postIds = posts.stream()
.map(Post::getId)
.collect(Collectors.toList());
// Single query for all comments
Map<Long, List<Comment>> commentsByPostId =
commentRepository.findByPostIds(postIds)
.stream()
.collect(Collectors.groupingBy(
c -> c.getPost().getId()));
return posts.stream()
.collect(Collectors.toMap(
Function.identity(),
p -> commentsByPostId.getOrDefault(
p.getId(), List.of())));
}
}Result: 21 queries → 2 queries, regardless of how many posts are fetched.
Manual DataLoader (for complex cases)
For more complex batching logic, use the DataLoader API directly:
@Configuration
public class DataLoaderConfig {
@Bean
public BatchLoaderRegistry batchLoaderRegistry(
AuthorRepository authorRepository) {
return registrar -> {
registrar.forTypePair(
Long.class, Author.class)
.registerBatchLoader(
(authorIds, environment) ->
Mono.fromCallable(() ->
authorRepository
.findAllByIds(authorIds))
.map(authors -> {
Map<Long, Author> byId =
authors.stream()
.collect(
Collectors.toMap(
Author::getId,
Function.identity()));
return authorIds.stream()
.map(id -> byId.get(id))
.collect(
Collectors.toList());
}));
};
}
}
// Using DataLoader in a controller
@SchemaMapping(typeName = "Post", field = "author")
public CompletableFuture<Author> author(
Post post,
DataLoader<Long, Author> dataLoader) {
return dataLoader.load(
post.getAuthor().getId());
}7. Subscriptions
Subscriptions enable real-time updates over WebSocket. A client subscribes to events and receives data as it happens.
@Controller
public class SubscriptionController {
// Publisher for comment events
private final Sinks.Many<Comment> commentSink =
Sinks.many().multicast()
.onBackpressureBuffer();
// Publisher for post events
private final Sinks.Many<Post> postSink =
Sinks.many().multicast()
.onBackpressureBuffer();
// Called when a comment is created
public void onCommentAdded(Comment comment) {
commentSink.tryEmitNext(comment);
}
// Called when a post is published
public void onPostPublished(Post post) {
postSink.tryEmitNext(post);
}
@SubscriptionMapping
public Flux<Comment> commentAdded(
@Argument Long postId) {
return commentSink.asFlux()
.filter(c -> c.getPost().getId()
.equals(postId));
}
@SubscriptionMapping
public Flux<Post> postPublished() {
return postSink.asFlux()
.filter(Post::isPublished);
}
}// Event-based approach using Spring events
@Component
public class GraphQLEventBridge
implements ApplicationListener<
CommentAddedEvent> {
@Autowired
private SubscriptionController subscriptionController;
@Override
public void onApplicationEvent(
CommentAddedEvent event) {
subscriptionController
.onCommentAdded(event.getComment());
}
}Subscription Client Example
// Frontend WebSocket subscription (JavaScript)
import { createClient } from 'graphql-ws';
const client = createClient({
url: 'ws://localhost:8080/graphql-ws',
});
// Subscribe to new comments on post 1
const unsubscribe = client.subscribe(
{
query: `
subscription OnCommentAdded($postId: ID!) {
commentAdded(postId: $postId) {
id
content
author {
name
}
createdAt
}
}
`,
variables: { postId: '1' },
},
{
next: (data) => console.log('New comment:', data),
error: (err) => console.error(err),
complete: () => console.log('Subscription ended'),
}
);8. Error Handling
Spring for GraphQL provides structured error handling with DataFetcherExceptionResolver.
@Component
public class GraphQLExceptionHandler
implements DataFetcherExceptionResolver {
@Override
public Mono<List<GraphQLError>> resolveException(
Throwable exception,
DataFetchingEnvironment environment) {
GraphQLError error = resolveError(exception);
if (error != null) {
return Mono.just(List.of(error));
}
return Mono.empty(); // Let default handler process
}
private GraphQLError resolveError(
Throwable exception) {
if (exception instanceof IllegalArgumentException) {
return GraphqlErrorBuilder.newError()
.errorType(ErrorType.BAD_REQUEST)
.message(exception.getMessage())
.build();
}
if (exception instanceof AccessDeniedException) {
return GraphqlErrorBuilder.newError()
.errorType(ErrorType.FORBIDDEN)
.message("Access denied")
.build();
}
if (exception instanceof EntityNotFoundException) {
return GraphqlErrorBuilder.newError()
.errorType(ErrorType.NOT_FOUND)
.message(exception.getMessage())
.build();
}
return null; // Unhandled — default handling
}
}Validation with Custom Scalars
@Configuration
public class ScalarConfig
implements RuntimeWiringConfigurer {
@Override
public void configure(
RuntimeWiring.Builder builder) {
builder.scalar(ExtendedScalars.DateTime);
}
}Field-Level Errors (Partial Results)
GraphQL allows returning partial results when only one field fails:
# Query
query {
post(id: 1) {
title
author {
name
}
}
}
# Response with partial error
{
"data": {
"post": {
"title": "My Post",
"author": null ← field failed
}
},
"errors": [
{
"message": "Author not found",
"path": ["post", "author"],
"extensions": {
"classification": "NOT_FOUND"
}
}
]
}9. Security
Spring Security Configuration
@Configuration
@EnableWebSecurity
@EnableMethodSecurity
public class SecurityConfig {
@Autowired
private JwtAuthenticationFilter jwtFilter;
@Bean
public SecurityFilterChain filterChain(
HttpSecurity http) throws Exception {
return http
.csrf(csrf -> csrf.disable())
.sessionManagement(session -> session
.sessionCreationPolicy(
SessionCreationPolicy.STATELESS))
.authorizeHttpRequests(auth -> auth
// GraphQL endpoint — allow all
// (field-level auth via @PreAuthorize)
.requestMatchers("/graphql").permitAll()
.requestMatchers("/graphiql").permitAll()
.requestMatchers("/graphql-ws").permitAll()
.anyRequest().authenticated())
.addFilterBefore(jwtFilter,
UsernamePasswordAuthenticationFilter.class)
.build();
}
}Field-Level Authorization
@Controller
public class AuthorController {
// Anyone can query public posts
@QueryMapping
public List<Post> posts() {
return postService.findPublished();
}
// Only authenticated users see their own drafts
@QueryMapping
@PreAuthorize("isAuthenticated()")
public List<Post> myDrafts(
@AuthenticationPrincipal
UserDetails userDetails) {
return postService.findDraftsByAuthor(
userDetails.getUsername());
}
// Only admins can delete any post
@MutationMapping
@PreAuthorize("hasRole('ADMIN')")
public boolean adminDeletePost(
@Argument Long id) {
postService.forceDelete(id);
return true;
}
}Hiding Sensitive Fields
@Controller
public class AuthorController {
// Email only visible to the author themselves
@SchemaMapping(typeName = "Author", field = "email")
public String email(
Author author,
@AuthenticationPrincipal
UserDetails userDetails) {
if (userDetails != null &&
author.getEmail().equals(
userDetails.getUsername())) {
return author.getEmail();
}
return "***@***.***"; // Masked for others
}
}10. Testing
Spring for GraphQL provides GraphQlTester for clean, readable GraphQL tests.
Setup
@SpringBootTest
@AutoConfigureGraphQlTester
class PostControllerTest {
@Autowired
private GraphQlTester graphQlTester;
@Autowired
private PostRepository postRepository;
@Autowired
private AuthorRepository authorRepository;
@BeforeEach
void setUp() {
Author author = new Author();
author.setName("Alice");
author.setEmail("alice@example.com");
authorRepository.save(author);
Post post = new Post();
post.setTitle("GraphQL in Spring Boot");
post.setContent("Great tutorial content...");
post.setAuthor(author);
post.setPublished(true);
postRepository.save(post);
}
}Query Tests
@Test
void shouldFetchPublishedPosts() {
graphQlTester.document("""
query {
posts(published: true, first: 10) {
edges {
node {
title
published
author {
name
}
}
}
totalCount
}
}
""")
.execute()
.path("posts.edges[0].node.title")
.entity(String.class)
.isEqualTo("GraphQL in Spring Boot")
.path("posts.edges[0].node.author.name")
.entity(String.class)
.isEqualTo("Alice")
.path("posts.totalCount")
.entity(Integer.class)
.satisfies(count -> assertThat(count)
.isGreaterThan(0));
}
@Test
void shouldFetchSinglePost() {
Long postId = postRepository.findAll()
.get(0).getId();
graphQlTester.document("""
query GetPost($id: ID!) {
post(id: $id) {
title
content
}
}
""")
.variable("id", postId)
.execute()
.path("post.title")
.entity(String.class)
.isEqualTo("GraphQL in Spring Boot");
}
@Test
void shouldReturnNullForMissingPost() {
graphQlTester.document("""
query {
post(id: 99999) {
title
}
}
""")
.execute()
.path("post")
.valueIsNull();
}Mutation Tests
@Test
@WithMockUser(username = "alice@example.com")
void shouldCreatePost() {
graphQlTester.document("""
mutation CreatePost($input: CreatePostInput!) {
createPost(input: $input) {
id
title
published
author {
name
}
}
}
""")
.variable("input", Map.of(
"title", "My New Post",
"content", "Post content here",
"tags", List.of("java", "graphql")))
.execute()
.path("createPost.title")
.entity(String.class)
.isEqualTo("My New Post")
.path("createPost.published")
.entity(Boolean.class)
.isEqualTo(false)
.path("createPost.author.name")
.entity(String.class)
.isEqualTo("Alice");
}
@Test
void shouldRejectUnauthenticatedMutation() {
graphQlTester.document("""
mutation {
createPost(input: {
title: "Hack",
content: "Attempt"
}) {
id
}
}
""")
.execute()
.errors()
.satisfy(errors ->
assertThat(errors).isNotEmpty());
}Error Tests
@Test
void shouldReturnErrorForInvalidInput() {
graphQlTester.document("""
mutation {
createPost(input: {
title: "",
content: ""
}) {
id
}
}
""")
.execute()
.errors()
.satisfy(errors ->
assertThat(errors)
.anyMatch(e -> e.getErrorType()
== ErrorType.BAD_REQUEST));
}11. Production Best Practices
Query Complexity & Depth Limits
Without limits, malicious clients can send deeply nested queries that overload your server:
# Malicious query — deeply nested
query {
posts {
edges { node {
comments {
author {
posts {
edges { node {
comments {
author { name }
}
}}
}
}
}
}}
}
}Configure limits in Spring for GraphQL:
spring:
graphql:
schema:
introspection:
enabled: false # Disable in production@Configuration
public class GraphQLConfig implements
GraphQlSourceBuilderCustomizer {
@Override
public void customize(
GraphQlSource.SchemaResourceBuilder builder) {
builder.configureRuntimeWiring(wiringBuilder ->
wiringBuilder
.instrumentations(List.of(
new MaxQueryDepthInstrumentation(10),
new MaxQueryComplexityInstrumentation(
200)))
);
}
}Persisted Queries
Persisted queries prevent arbitrary query execution in production:
@Configuration
public class PersistedQueryConfig {
@Bean
public PersistedQueryCache persistedQueryCache() {
// In-memory cache of allowed queries
Map<String, String> allowedQueries = Map.of(
"getPosts",
"query { posts(first: 10) { ... } }",
"getPost",
"query GetPost($id: ID!) { post(id: $id) { ... } }"
);
return new InMemoryPersistedQueryCache(
allowedQueries);
}
}Query Logging & Monitoring
@Component
public class GraphQLLoggingInstrumentation
extends SimpleInstrumentation {
private static final Logger log =
LoggerFactory.getLogger(
GraphQLLoggingInstrumentation.class);
@Override
public InstrumentationContext<ExecutionResult>
beginExecution(
InstrumentationExecutionParameters params,
InstrumentationState state) {
long startTime = System.currentTimeMillis();
String operationName =
params.getOperation();
String query = params.getQuery();
return SimpleInstrumentationContext
.whenCompleted((result, throwable) -> {
long duration =
System.currentTimeMillis()
- startTime;
if (throwable != null || !result
.getErrors().isEmpty()) {
log.warn("GraphQL {} failed in {}ms: {}",
operationName, duration,
result.getErrors());
} else {
log.debug("GraphQL {} completed in {}ms",
operationName, duration);
}
});
}
}GraphQL vs REST: When to Use Each
| Scenario | GraphQL | REST |
|---|---|---|
| Mobile apps (bandwidth-sensitive) | ✅ Fetch only needed fields | ❌ Over-fetches |
| Multiple clients with different data needs | ✅ Single endpoint, flexible | ❌ Multiple endpoints needed |
| Real-time data | ✅ Built-in subscriptions | ⚠️ Need WebSocket setup |
| Simple CRUD APIs | ⚠️ Extra complexity | ✅ Simple and familiar |
| File uploads | ❌ Complex setup | ✅ Native multipart |
| HTTP caching | ❌ POST-based, hard to cache | ✅ GET requests cache easily |
| Public APIs (documented, stable) | ⚠️ Schema introspection needed | ✅ OpenAPI/Swagger standard |
| Microservices (internal) | ✅ Federation possible | ✅ Simple contracts |
Summary and Key Takeaways
✅ Spring for GraphQL is the official, production-ready GraphQL integration for Spring Boot — built on GraphQL Java with full Spring ecosystem support
✅ Schema-first development — define your GraphQL schema in .graphqls files first, then implement resolvers
✅ @QueryMapping / @MutationMapping / @SubscriptionMapping map controller methods to GraphQL operations cleanly
✅ @BatchMapping solves N+1 automatically by batching field resolution — the single most important performance optimization in GraphQL
✅ DataLoader gives fine-grained control over batching for complex scenarios beyond @BatchMapping
✅ Subscriptions over WebSocket enable real-time features using Reactor Flux — same pattern as Spring WebFlux
✅ @PreAuthorize on GraphQL controllers provides field-level security integrated with Spring Security
✅ GraphQlTester makes GraphQL testing as clean and readable as MockMvc for REST
✅ Query complexity and depth limits are essential production safeguards against malicious or accidental expensive queries
✅ Use GraphQL when clients have diverse data needs, you're building for mobile, or you need real-time subscriptions
What's Next?
Now that you can build GraphQL APIs in Spring Boot, continue the series:
Continue the Spring Boot Series
- API Versioning & HATEOAS: Design REST APIs that evolve gracefully alongside your GraphQL API
- Structured Logging & Centralized Logging: Instrument your GraphQL resolver performance and errors
- Monitoring with Actuator, Prometheus & Grafana: Add GraphQL-specific metrics to your dashboards
Related Spring Boot Posts
- API Documentation with OpenAPI/Swagger — document your REST API alongside GraphQL
- JWT Authentication & Authorization — the JWT setup used for GraphQL security
- Advanced JPA Optimization — complement
@BatchMappingwith JPA-level optimizations - Async Processing & Scheduled Tasks — async patterns useful with GraphQL subscriptions
Foundation Posts
- What is GraphQL? Complete Guide — GraphQL concepts for any stack
- What is REST API? — understand REST to know when to choose GraphQL instead
Part of the Spring Boot Learning Roadmap series
📬 Subscribe to Newsletter
Get the latest blog posts delivered to your inbox every week. No spam, unsubscribe anytime.
We respect your privacy. Unsubscribe at any time.
💬 Comments
Sign in to leave a comment
We'll never post without your permission.