Building GraphQL APIs with Spring for GraphQL: A Comprehensive Guide

Building GraphQL APIs with Spring for GraphQL: A Comprehensive Guide

Introduction

GraphQL has revolutionized the way we design APIs, offering a flexible and efficient alternative to REST. With its client-driven query language and runtime, GraphQL empowers developers to fetch exactly the data they need, eliminating over-fetching and under-fetching. For Java developers, the Spring ecosystem provides a first-class integration with Spring for GraphQL, a module that simplifies building GraphQL servers on top of Spring Boot. This guide will walk you through the essential concepts, setup, and advanced techniques to build production-ready GraphQL APIs with Spring for GraphQL.

Whether you’re a seasoned Spring developer or new to GraphQL, this article will provide practical insights and code examples that you can directly apply to your projects.

Getting Started with Spring for GraphQL

1. Adding Dependencies

To begin, create a new Spring Boot project and add the following dependencies to your pom.xml (Maven) or build.gradle (Gradle).

Maven:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
<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>
<!-- Optional: For data fetching (e.g., JPA) -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<dependency>
<groupId>com.h2database</groupId>
<artifactId>h2</artifactId>
<scope>runtime</scope>
</dependency>

Gradle:

1
2
3
4
5
implementation 'org.springframework.boot:spring-boot-starter-graphql'
implementation 'org.springframework.boot:spring-boot-starter-web'
// Optional
implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
runtimeOnly 'com.h2database:h2'

2. Defining Your GraphQL Schema

GraphQL APIs are driven by a schema, which defines the types, queries, mutations, and subscriptions. Spring for GraphQL uses schema files (.graphqls) placed in src/main/resources/graphql.

Let’s create a simple schema for a blog platform:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
type Query {
posts(first: Int, after: String): PostConnection!
post(id: ID!): Post
}

type Mutation {
createPost(input: PostInput!): Post!
updatePost(id: ID!, input: PostInput!): Post!
deletePost(id: ID!): Boolean!
}

type Post {
id: ID!
title: String!
content: String!
author: Author!
comments: [Comment!]!
createdAt: String!
}

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

type Comment {
id: ID!
content: String!
author: Author!
}

input PostInput {
title: String!
content: String!
authorId: ID!
}

type PostConnection {
edges: [PostEdge!]!
pageInfo: PageInfo!
}

type PostEdge {
node: Post!
cursor: String!
}

type PageInfo {
hasNextPage: Boolean!
endCursor: String
}

3. Implementing Resolvers

In Spring for GraphQL, you can implement resolvers using @QueryMapping, @MutationMapping, and @SchemaMapping annotations. The framework uses the schema to map Java methods to fields.

Create a controller-like component:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
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.graphql.data.method.annotation.SchemaMapping;
import org.springframework.stereotype.Controller;

import java.time.LocalDateTime;
import java.util.List;
import java.util.UUID;

@Controller
public class PostController {

private final PostService postService;
private final AuthorService authorService;

public PostController(PostService postService, AuthorService authorService) {
this.postService = postService;
this.authorService = authorService;
}

@QueryMapping
public List<Post> posts() {
return postService.getAllPosts();
}

@QueryMapping
public Post post(@Argument String id) {
return postService.getPostById(id);
}

@MutationMapping
public Post createPost(@Argument(name = "input") PostInput input) {
return postService.createPost(input);
}

@MutationMapping
public Post updatePost(@Argument String id, @Argument(name = "input") PostInput input) {
return postService.updatePost(id, input);
}

@MutationMapping
public boolean deletePost(@Argument String id) {
return postService.deletePost(id);
}

// Resolver for nested fields
@SchemaMapping
public Author author(Post post) {
return authorService.getAuthorById(post.getAuthorId());
}
}

4. Data Classes and Services

Define your domain models and services. Here’s an example using a simple in-memory store for brevity:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
public record Post(String id, String title, String content, String authorId, LocalDateTime createdAt) {}

public record Author(String id, String name, String email) {}

public record Comment(String id, String content, String authorId, String postId) {}

@Service
public class PostService {
private final List<Post> posts = new ArrayList<>();
private final Map<String, List<Comment>> comments = new HashMap<>();

public List<Post> getAllPosts() {
return posts;
}

public Post getPostById(String id) {
return posts.stream()
.filter(p -> p.id().equals(id))
.findFirst()
.orElseThrow(() -> new PostNotFoundException(id));
}

public Post createPost(PostInput input) {
Post post = new Post(UUID.randomUUID().toString(), input.title(), input.content(), input.authorId(), LocalDateTime.now());
posts.add(post);
return post;
}

public Post updatePost(String id, PostInput input) {
Post existing = getPostById(id);
Post updated = new Post(existing.id(), input.title(), input.content(), input.authorId(), existing.createdAt());
posts.replaceAll(p -> p.id().equals(id) ? updated : p);
return updated;
}

public boolean deletePost(String id) {
return posts.removeIf(p -> p.id().equals(id));
}
}

Advanced Features

5. Error Handling

GraphQL requires a structured error response. Spring for GraphQL automatically maps exceptions to GraphQL errors, but you can customize the behavior using @GraphQlExceptionHandler or by implementing DataFetcherExceptionResolver.

Create a custom exception:

1
2
3
4
5
public class PostNotFoundException extends RuntimeException {
public PostNotFoundException(String id) {
super("Post not found with id: " + id);
}
}

Then create a handler:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
import graphql.GraphQLError;
import graphql.GraphqlErrorBuilder;
import org.springframework.graphql.data.method.annotation.GraphQlExceptionHandler;
import org.springframework.graphql.execution.ErrorType;
import org.springframework.web.bind.annotation.ControllerAdvice;

@ControllerAdvice
public class GraphQLExceptionHandler {

@GraphQlExceptionHandler
public GraphQLError handlePostNotFound(PostNotFoundException ex) {
return GraphqlErrorBuilder.newError()
.message(ex.getMessage())
.errorType(ErrorType.NOT_FOUND)
.build();
}
}

6. Pagination and Sorting

For scalable APIs, implement pagination using the Relay connection pattern. Spring for GraphQL provides CursorStrategy and Window utilities.

Modify your resolver to accept pagination arguments:

1
2
3
4
5
6
7
8
9
10
11
12
import org.springframework.data.domain.Window;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Sort;
import org.springframework.graphql.data.query.annotation.QueryMapping;
import org.springframework.graphql.data.query.annotation.Querydsl;

@QueryMapping
public Window<Post> posts(@Argument int first, @Argument String after) {
// Convert cursor to offset (simplified)
int offset = after != null ? Integer.parseInt(after) : 0;
return postService.getPosts(offset, first);
}

For a more robust solution, use Spring Data’s Window support with JPA:

1
2
3
public interface PostRepository extends JpaRepository<Post, Long> {
Window<Post> findAllBy(Pageable pageable);
}

7. Validation and Input Sanitization

Use Bean Validation (jakarta.validation) to validate inputs. Add @Validated to your controller and use constraints on your input records.

1
2
3
4
5
public record PostInput(
@NotBlank String title,
@NotBlank @Size(max = 5000) String content,
@NotBlank String authorId
) {}

Then in your controller:

1
2
3
4
@MutationMapping
public Post createPost(@Valid @Argument(name = "input") PostInput input) {
// ...
}

8. Security

Secure your GraphQL endpoint using Spring Security. You can protect the endpoint itself or add field-level security.

Add dependency:

1
2
3
4
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>

Configure security:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
@Configuration
@EnableWebSecurity
public class SecurityConfig {

@Bean
SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.authorizeHttpRequests(auth -> auth
.requestMatchers("/graphql").permitAll() // Public endpoint
.anyRequest().authenticated()
)
.httpBasic();
return http.build();
}
}

For field-level security, use @PreAuthorize on resolver methods:

1
2
3
4
5
@QueryMapping
@PreAuthorize("hasRole('ADMIN')")
public List<Post> allPostsAdmin() {
return postService.getAllPosts();
}

9. Subscriptions for Real-Time Updates

GraphQL subscriptions allow clients to receive real-time updates. Spring for GraphQL supports WebSocket-based subscriptions.

Add the WebSocket dependency:

1
2
3
4
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-websocket</artifactId>
</dependency>

Define a subscription in your schema:

1
2
3
type Subscription {
postCreated: Post!
}

Implement the subscription resolver using @SubscriptionMapping:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
import org.reactivestreams.Publisher;
import org.springframework.graphql.data.method.annotation.SubscriptionMapping;
import reactor.core.publisher.Sinks;

@Controller
public class PostSubscriptionController {

private final Sinks.Many<Post> postSink = Sinks.many().multicast().onBackpressureBuffer();

@SubscriptionMapping
public Publisher<Post> postCreated() {
return postSink.asFlux();
}

// Call this method when a new post is created
public void publishPost(Post post) {
postSink.tryEmitNext(post);
}
}

Testing Your GraphQL API

Spring for GraphQL provides excellent testing support. Use @GraphQlTest for slice testing:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
@GraphQlTest(PostController.class)
public class PostControllerTest {

@Autowired
private GraphQlTester graphQlTester;

@Test
void shouldCreatePost() {
String query = """
mutation {
createPost(input: {title: "Hello", content: "World", authorId: "1"}) {
id
title
}
}
""";

graphQlTester.document(query)
.execute()
.path("createPost.id")
.entity(String.class)
.isNotEmpty();
}
}

Best Practices

Conclusion

Spring for GraphQL simplifies the development of GraphQL APIs in Java, providing a robust, scalable, and secure foundation. By following the patterns and best practices outlined in this guide, you can build efficient APIs that meet modern client requirements. Start with the basics, then explore advanced features like subscriptions and batching to take full advantage of GraphQL’s power.

Key Takeaways

Start building your GraphQL API today with Spring for GraphQL and unlock a new level of API flexibility and performance!