Back to blog

GraphQL with Spring for GraphQL: Complete Guide

javaspring-bootgraphqlapibackend
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


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: false

What 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": nullfield 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

ScenarioGraphQLREST
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

Foundation Posts


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.