Spring - GraphQL
Overview
GraphQL is a query language that offers an alternative model to developing APIs instead of REST, SOAP or gRPC. It allows partial fetch of data, you can use a single endpoint to fetch different formats of data.
With REST, the shape of the response is decided by the server, every endpoint returns a fixed structure and the client has to work with whatever comes back, over-fetching fields it doesn't need or under-fetching and having to call a second endpoint to get the rest. GraphQL flips that around, the server exposes a strongly typed schema describing every type, field and relationship in the system, and the client sends a query describing exactly the shape of the response it wants. The server walks the schema and resolves only the fields that were asked for.
A few things fall out of that model:
- Single endpoint - Unlike REST where you keep adding endpoints as requirements grow, GraphQL exposes one endpoint (
/graphql) for every query and mutation. What changes between requests is the query document, not the URL. - Strongly typed schema - Every field has a type (
String,Int,ID, a custom object type, etc.), so the contract between client and server is explicit and can be validated before a query ever hits a resolver. - Introspection - Because the schema is typed and published, tools like GraphiQL can query the schema itself to auto-generate docs, auto-complete queries and validate them client side.
- No API versioning - Fields can be added to a type without breaking older clients since a client only receives the fields it asked for. Deprecated fields are marked
@deprecatedinstead of bumping a version number. - Aggregation over multiple sources - A single query can pull together data that would otherwise need multiple REST calls, since the resolvers for different fields can independently reach into different services, databases or repositories.
The trade-off is that the server now has to do more work per request, resolving a graph of fields instead of returning a canned response, and it opens up problems that don't really exist in REST, like the N+1 problem covered further down.
Github: https://github.com/gitorko/project96
Spring Boot GraphQL
Lets say you have a rest api that returns customer profile, the customer profile has 200+ fields, so a mobile device may not need all the fields, it may need may be 5 fields like name, address etc. Requesting a big payload over wire is costly. So now you end up writing a rest endpoint that returns just the 5 fields. This can become overwhelming when the requirements increase and you end up creating different endpoint for such requirement. In GraphQL you define a schema and let the user/consumer decide which fields they want to fetch.
Before GraphQL 1.0 was Released spring had to extend the classes GraphQLMutationResolver, GraphQLQueryResolver. Its no longer required.
GraphQLMutationResolver -> @MutationMapping
GraphQLQueryResolver -> @QueryMapping
The code uses Extended Scalars for graphql-java to support Date and other type objects in GraphQL The code shows how pagination can be done in GraphQL
Code
1package com.demo.project96.controller;
2
3import java.util.Optional;
4
5import com.demo.project96.domain.Comment;
6import com.demo.project96.domain.Post;
7import com.demo.project96.domain.PostPage;
8import com.demo.project96.repo.CommentRepository;
9import com.demo.project96.repo.PostRepository;
10import lombok.RequiredArgsConstructor;
11import lombok.extern.slf4j.Slf4j;
12import org.springframework.data.domain.Page;
13import org.springframework.data.domain.PageRequest;
14import org.springframework.graphql.data.method.annotation.Argument;
15import org.springframework.graphql.data.method.annotation.QueryMapping;
16import org.springframework.graphql.data.method.annotation.SchemaMapping;
17import org.springframework.stereotype.Controller;
18
19@Controller
20@Slf4j
21@RequiredArgsConstructor
22public class QueryController {
23
24 private final PostRepository postRepository;
25 private final CommentRepository commentRepository;
26
27 @QueryMapping
28 public Iterable<Post> findAllPosts() {
29 return postRepository.findAll();
30 }
31
32 @QueryMapping
33 public PostPage findAllPostsPage(@Argument Integer page, @Argument Integer size) {
34 PageRequest pageOf = PageRequest.of(page, size);
35 Page<Post> all = postRepository.findAll(pageOf);
36 return PostPage.builder()
37 .posts(all.getContent())
38 .totalElements(all.getTotalElements())
39 .totalPages(all.getTotalPages())
40 .currentPage(all.getNumber())
41 .size(all.getNumberOfElements())
42 .build();
43 }
44
45 @QueryMapping
46 public Optional<Post> findPostById(@Argument("id") Long id) {
47 return postRepository.findById(id);
48 }
49
50 @QueryMapping
51 public Iterable<Comment> findAllComments() {
52 //Will cause N+1 problem
53 //return commentRepository.findAll();
54 return commentRepository.findAllComments();
55 }
56
57 @QueryMapping
58 public Optional<Comment> findCommentById(@Argument("id") Long id) {
59 return commentRepository.findById(id);
60 }
61
62 @QueryMapping
63 public long countPosts() {
64 return postRepository.count();
65 }
66
67 @QueryMapping
68 public Iterable<Comment> findCommentsByPostId(@Argument("postId") Long postId) {
69 Optional<Post> byId = postRepository.findById(postId);
70 if (byId.isPresent()) {
71 return commentRepository.findByPost(byId.get());
72 } else {
73 throw new RuntimeException("Post not found!");
74 }
75 }
76
77 /**
78 * Functionality will work same without this method as well.
79 * Hibernate Lazy fetch prevents the post entity from being fetched even without this method.
80 * So no unnecessary db call is made if post entity is not needed in the response even without this method.
81 * However if there is any reason why we want to control a single field explicitly we can use this approach and define how that field gets data.
82 * eg: You want to sort the comments
83 */
84 @SchemaMapping(typeName = "Comment", field = "post")
85 public Post getPost(Comment comment) {
86 return postRepository.findById(comment.getPost().getId())
87 .orElseThrow(() -> new RuntimeException("Post not found!"));
88 }
89
90}
1package com.demo.project96.controller;
2
3import java.time.ZonedDateTime;
4import java.util.Optional;
5
6import com.demo.project96.domain.Comment;
7import com.demo.project96.domain.Post;
8import com.demo.project96.repo.CommentRepository;
9import com.demo.project96.repo.PostRepository;
10import lombok.RequiredArgsConstructor;
11import lombok.extern.slf4j.Slf4j;
12import org.springframework.graphql.data.method.annotation.Argument;
13import org.springframework.graphql.data.method.annotation.MutationMapping;
14import org.springframework.stereotype.Controller;
15
16@Controller
17@Slf4j
18@RequiredArgsConstructor
19public class MutationController {
20
21 private final PostRepository postRepository;
22 private final CommentRepository commentRepository;
23
24 @MutationMapping
25 public Post createPost(@Argument("header") String header, @Argument("createdBy") String createdBy) {
26 Post post = new Post();
27 post.setHeader(header);
28 post.setCreatedBy(createdBy);
29 post.setCreatedDt(ZonedDateTime.now());
30 post = postRepository.save(post);
31 return post;
32 }
33
34 @MutationMapping
35 public Comment createComment(@Argument("message") String message, @Argument("createdBy") String createdBy, @Argument("postId") Long postId) {
36 Comment comment = new Comment();
37 Optional<Post> byId = postRepository.findById(postId);
38 if (byId.isPresent()) {
39 Post post = byId.get();
40 comment.setPost(post);
41 comment.setMessage(message);
42 comment.setCreatedBy(createdBy);
43 comment.setCreatedDt(ZonedDateTime.now());
44 comment = commentRepository.save(comment);
45 return comment;
46 } else {
47 throw new RuntimeException("Post not found!");
48 }
49
50 }
51
52 @MutationMapping
53 public boolean deleteComment(@Argument("id") Long id) {
54 commentRepository.deleteById(id);
55 return true;
56 }
57
58 @MutationMapping
59 public Comment updateComment(@Argument("id") Long id, @Argument("message") String message) {
60 Optional<Comment> byId = commentRepository.findById(id);
61 if (byId.isPresent()) {
62 Comment comment = byId.get();
63 comment.setMessage(message);
64 commentRepository.save(comment);
65 return comment;
66 }
67 throw new RuntimeException("Post not found!");
68 }
69}
The schema for GraphQL. The ! simply tells us that you can always expect a value back and will never need to check for null.
1scalar DateTime
2scalar Long
3
4type Post {
5 id: ID!
6 header: String!
7 createdDt: DateTime!
8 createdBy: String!
9}
10
11type PostPage {
12 posts: [Post]
13 totalElements: Int
14 totalPages: Int
15 currentPage: Int
16 size: Int
17}
18
19type Query {
20 findAllPosts: [Post]
21 findPostById(id: ID!): Post
22 countPosts: Long!
23 findAllPostsPage(page: Int = 0, size: Int = 20): PostPage
24}
25
26type Mutation {
27 createPost(header: String!, createdBy: String!): Post
28}
GraphQL accepts only one root Query and one root Mutation types, To keep the logic in different files we extend the Query and Mutation types.
1type Comment {
2 id: ID!
3 message: String!
4 createdBy: String!
5 createdDt: DateTime!
6 post: Post
7}
8
9extend type Query {
10 findAllComments: [Comment]!
11 findCommentById(id: ID!): Comment!
12 findCommentsByPostId(postId: ID!): [Comment]
13}
14
15extend type Mutation {
16 createComment(message: String!, createdBy: String!, postId: ID!): Comment!
17 updateComment(id: ID!, message: String!): Comment!
18 deleteComment(id: ID!): Boolean
19}
The key terminologies in GraphQL are
- Query: Used to read data
- Mutation: Used to create, update and delete data
- Subscription: Similar to a query allowing you to fetch data from the server. Subscriptions offer a long-lasting operation that can change their result over time.
The N+1 Problem
QueryController.findAllComments() calls out that returning commentRepository.findAll() directly would cause an N+1 problem, one query to fetch all comments, followed by one additional query per comment to lazily load its post. With a handful of comments that's not noticeable, but with a thousand comments that's a thousand and one round trips to the database for a single GraphQL request.
The project works around it with a hand-written JOIN FETCH query in CommentRepository:
1package com.demo.project96.repo;
2
3import java.util.List;
4
5import com.demo.project96.domain.Comment;
6import com.demo.project96.domain.Post;
7import org.springframework.data.jpa.repository.JpaRepository;
8import org.springframework.data.jpa.repository.Query;
9
10public interface CommentRepository extends JpaRepository<Comment, Long> {
11
12 Iterable<Comment> findByPost(Post post);
13
14 //To avoid N+1 problem
15 @Query("SELECT c FROM Comment c LEFT JOIN FETCH c.post")
16 List<Comment> findAllComments();
17}
That works well when you know upfront which relations a query needs, but it doesn't scale to arbitrary nested queries, if Post itself had a lazy author relation, or if comments were fetched through several different queries, you'd need a hand-written fetch join for every path.
The idiomatic GraphQL fix is a DataLoader. Instead of resolving Comment.post field-by-field per comment, a batch loader collects all the post IDs requested across a single GraphQL execution and fetches them in one query, then hands each comment its post from the batch. Spring for GraphQL wraps this behind @BatchMapping, batching every Comment.post field resolution requested in an execution into a single call, regardless of which query triggered it, so it composes across findAllComments, findCommentById, findCommentsByPostId etc. without a separate hand-written query for each one. See the Spring for GraphQL batch mapping docs for the exact API.
Error Handling
By default, an exception thrown from a resolver (like the RuntimeException("Post not found!") thrown in MutationController.createComment()) is masked and returned to the client as a generic error:
1{
2 "errors": [
3 { "message": "INTERNAL_ERROR for 9cf1eed9-c977-9be7-4767-461b7c45622c", "extensions": { "classification": "INTERNAL_ERROR" } }
4 ]
5}
That's a safe default, it avoids leaking stack traces or internal messages to a client, but it also means every failure looks the same, whether it's a missing post, a validation failure or an unhandled bug. Spring for GraphQL lets you register a DataFetcherExceptionResolver bean that inspects the exception and returns a classified GraphQLError (e.g. NOT_FOUND for a missing entity) instead of the generic INTERNAL_ERROR, falling back to the default masking for anything it doesn't recognise. See the Spring for GraphQL exception handling docs for the exact API.
Bruno
Import the Bruno collection to try out the requests.
A few sample requests to hit the endpoint at http://localhost:8080/api/graphql:
Query all posts:
1query {
2 findAllPosts {
3 id
4 header
5 createdBy
6 createdDt
7 }
8}
Query posts with pagination:
1query {
2 findAllPostsPage(page: 0, size: 10) {
3 posts {
4 id
5 header
6 createdBy
7 }
8 totalElements
9 totalPages
10 currentPage
11 size
12 }
13}
Create a post:
1mutation {
2 createPost(header: "Hello world", createdBy: "John") {
3 id
4 }
5}
Create a comment on a post:
1mutation {
2 createComment(message: "comment1", createdBy: "John", postId: 1) {
3 id
4 message
5 createdBy
6 }
7}
Or hit it directly with curl:
1curl -X POST http://localhost:8080/api/graphql \
2 -H "Content-Type: application/json" \
3 -d '{"query": "query { findAllPosts { id header createdBy createdDt } }"}'
Testing
Spring for GraphQL ships GraphQlTester, a fluent client for firing GraphQL documents at your app and asserting on the JSON response by path, without hand-rolling JSON parsing. Bound to a running server via HttpGraphQlTester, the queries and mutations from this project are tested like this:
1package com.demo.project96.controller;
2
3import java.time.ZonedDateTime;
4
5import com.demo.project96.domain.Comment;
6import com.demo.project96.domain.Post;
7import com.demo.project96.repo.CommentRepository;
8import com.demo.project96.repo.PostRepository;
9import org.junit.jupiter.api.AfterEach;
10import org.junit.jupiter.api.BeforeEach;
11import org.junit.jupiter.api.Test;
12import org.springframework.beans.factory.annotation.Autowired;
13import org.springframework.boot.test.context.SpringBootTest;
14import org.springframework.boot.test.web.server.LocalServerPort;
15import org.springframework.graphql.test.tester.HttpGraphQlTester;
16import org.springframework.test.context.ActiveProfiles;
17import org.springframework.test.web.reactive.server.WebTestClient;
18
19import static org.assertj.core.api.Assertions.assertThat;
20
21@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
22@ActiveProfiles("test")
23class QueryControllerGraphQlTest {
24
25 @LocalServerPort
26 private int port;
27
28 @Autowired
29 private PostRepository postRepository;
30
31 @Autowired
32 private CommentRepository commentRepository;
33
34 private HttpGraphQlTester graphQlTester;
35
36 private Post post1;
37 private Post post2;
38 private Comment comment1;
39
40 @BeforeEach
41 void setUp() {
42 commentRepository.deleteAll();
43 postRepository.deleteAll();
44
45 WebTestClient client = WebTestClient.bindToServer()
46 .baseUrl("http://localhost:" + port + "/api/graphql")
47 .build();
48 graphQlTester = HttpGraphQlTester.create(client);
49
50 post1 = postRepository.save(Post.builder()
51 .header("header_1")
52 .createdBy("Jack")
53 .createdDt(ZonedDateTime.now())
54 .build());
55 post2 = postRepository.save(Post.builder()
56 .header("header_2")
57 .createdBy("Adam")
58 .createdDt(ZonedDateTime.now())
59 .build());
60 comment1 = commentRepository.save(Comment.builder()
61 .message("comment_1")
62 .createdBy("Jack")
63 .createdDt(ZonedDateTime.now())
64 .post(post1)
65 .build());
66 }
67
68 @AfterEach
69 void cleanUp() {
70 commentRepository.deleteAll();
71 postRepository.deleteAll();
72 }
73
74 @Test
75 void findAllPostsReturnsEveryPost() {
76 graphQlTester.document("query { findAllPosts { id header createdBy } }")
77 .execute()
78 .path("findAllPosts")
79 .entityList(Object.class)
80 .hasSize(2);
81 }
82
83 @Test
84 void findAllPostsPagePaginatesResults() {
85 graphQlTester.document("query { findAllPostsPage(page: 0, size: 1) { totalElements totalPages currentPage size posts { id } } }")
86 .execute()
87 .path("findAllPostsPage.totalElements").entity(Long.class).isEqualTo(2L)
88 .path("findAllPostsPage.totalPages").entity(Integer.class).isEqualTo(2)
89 .path("findAllPostsPage.posts").entityList(Object.class).hasSize(1);
90 }
91
92 @Test
93 void findPostByIdReturnsMatchingPost() {
94 graphQlTester.document("query { findPostById(id: " + post1.getId() + ") { id header createdBy } }")
95 .execute()
96 .path("findPostById.header").entity(String.class).isEqualTo("header_1")
97 .path("findPostById.createdBy").entity(String.class).isEqualTo("Jack");
98 }
99
100 @Test
101 void findPostByIdReturnsNullWhenMissing() {
102 graphQlTester.document("query { findPostById(id: 999999) { id } }")
103 .execute()
104 .path("findPostById").valueIsNull();
105 }
106
107 @Test
108 void countPostsReturnsTotalCount() {
109 graphQlTester.document("query { countPosts }")
110 .execute()
111 .path("countPosts").entity(Long.class).isEqualTo(2L);
112 }
113
114 @Test
115 void findAllCommentsIncludesNestedPost() {
116 graphQlTester.document("query { findAllComments { id message post { id header } } }")
117 .execute()
118 .path("findAllComments[0].message").entity(String.class).isEqualTo("comment_1")
119 .path("findAllComments[0].post.id").entity(String.class).isEqualTo(String.valueOf(post1.getId()));
120 }
121
122 @Test
123 void findCommentByIdIncludesNestedPost() {
124 graphQlTester.document("query { findCommentById(id: " + comment1.getId() + ") { id message post { header } } }")
125 .execute()
126 .path("findCommentById.message").entity(String.class).isEqualTo("comment_1")
127 .path("findCommentById.post.header").entity(String.class).isEqualTo("header_1");
128 }
129
130 @Test
131 void findCommentByIdErrorsWhenMissing() {
132 // The schema declares findCommentById as non-nullable (Comment!), so a missing
133 // comment surfaces as a GraphQL execution error rather than a null result.
134 graphQlTester.document("query { findCommentById(id: 999999) { id } }")
135 .execute()
136 .errors()
137 .satisfy(errors -> assertThat(errors).isNotEmpty());
138 }
139
140 @Test
141 void findCommentsByPostIdReturnsOnlyThatPostsComments() {
142 graphQlTester.document("query { findCommentsByPostId(postId: " + post1.getId() + ") { id message } }")
143 .execute()
144 .path("findCommentsByPostId").entityList(Object.class).hasSize(1);
145 }
146
147 @Test
148 void findCommentsByPostIdErrorsWhenPostMissing() {
149 graphQlTester.document("query { findCommentsByPostId(postId: 999999) { id } }")
150 .execute()
151 .errors()
152 .satisfy(errors -> assertThat(errors).isNotEmpty());
153 }
154}
1package com.demo.project96.controller;
2
3import java.time.ZonedDateTime;
4
5import com.demo.project96.domain.Comment;
6import com.demo.project96.domain.Post;
7import com.demo.project96.repo.CommentRepository;
8import com.demo.project96.repo.PostRepository;
9import org.junit.jupiter.api.AfterEach;
10import org.junit.jupiter.api.BeforeEach;
11import org.junit.jupiter.api.Test;
12import org.springframework.beans.factory.annotation.Autowired;
13import org.springframework.boot.test.context.SpringBootTest;
14import org.springframework.boot.test.web.server.LocalServerPort;
15import org.springframework.graphql.test.tester.HttpGraphQlTester;
16import org.springframework.test.context.ActiveProfiles;
17import org.springframework.test.web.reactive.server.WebTestClient;
18
19import static org.assertj.core.api.Assertions.assertThat;
20
21@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
22@ActiveProfiles("test")
23class MutationControllerGraphQlTest {
24
25 @LocalServerPort
26 private int port;
27
28 @Autowired
29 private PostRepository postRepository;
30
31 @Autowired
32 private CommentRepository commentRepository;
33
34 private HttpGraphQlTester graphQlTester;
35
36 private Post post;
37
38 @BeforeEach
39 void setUp() {
40 commentRepository.deleteAll();
41 postRepository.deleteAll();
42
43 WebTestClient client = WebTestClient.bindToServer()
44 .baseUrl("http://localhost:" + port + "/api/graphql")
45 .build();
46 graphQlTester = HttpGraphQlTester.create(client);
47
48 post = postRepository.save(Post.builder()
49 .header("header_1")
50 .createdBy("Jack")
51 .createdDt(ZonedDateTime.now())
52 .build());
53 }
54
55 @AfterEach
56 void cleanUp() {
57 commentRepository.deleteAll();
58 postRepository.deleteAll();
59 }
60
61 @Test
62 void createPostPersistsNewPost() {
63 String postId = graphQlTester.document(
64 "mutation { createPost(header: \"Hello world\", createdBy: \"John\") { id header createdBy } }")
65 .execute()
66 .path("createPost.header").entity(String.class).isEqualTo("Hello world")
67 .path("createPost.createdBy").entity(String.class).isEqualTo("John")
68 .path("createPost.id").entity(String.class).get();
69
70 assertThat(postRepository.findById(Long.valueOf(postId))).isPresent();
71 }
72
73 @Test
74 void createCommentLinksToExistingPost() {
75 String commentId = graphQlTester.document(
76 "mutation { createComment(message: \"comment1\", createdBy: \"John\", postId: " + post.getId() + ") { id message post { id } } }")
77 .execute()
78 .path("createComment.message").entity(String.class).isEqualTo("comment1")
79 .path("createComment.post.id").entity(String.class).isEqualTo(String.valueOf(post.getId()))
80 .path("createComment.id").entity(String.class).get();
81
82 assertThat(commentRepository.findById(Long.valueOf(commentId))).isPresent();
83 }
84
85 @Test
86 void createCommentErrorsWhenPostMissing() {
87 graphQlTester.document(
88 "mutation { createComment(message: \"comment1\", createdBy: \"John\", postId: 999999) { id } }")
89 .execute()
90 .errors()
91 .satisfy(errors -> assertThat(errors).isNotEmpty());
92 }
93
94 @Test
95 void updateCommentChangesMessage() {
96 Comment comment = commentRepository.save(Comment.builder()
97 .message("original")
98 .createdBy("Jack")
99 .createdDt(ZonedDateTime.now())
100 .post(post)
101 .build());
102
103 graphQlTester.document("mutation { updateComment(id: " + comment.getId() + ", message: \"updated\") { id message } }")
104 .execute()
105 .path("updateComment.message").entity(String.class).isEqualTo("updated");
106
107 assertThat(commentRepository.findById(comment.getId()).get().getMessage()).isEqualTo("updated");
108 }
109
110 @Test
111 void updateCommentErrorsWhenMissing() {
112 graphQlTester.document("mutation { updateComment(id: 999999, message: \"updated\") { id } }")
113 .execute()
114 .errors()
115 .satisfy(errors -> assertThat(errors).isNotEmpty());
116 }
117
118 @Test
119 void deleteCommentRemovesIt() {
120 Comment comment = commentRepository.save(Comment.builder()
121 .message("to-delete")
122 .createdBy("Jack")
123 .createdDt(ZonedDateTime.now())
124 .post(post)
125 .build());
126
127 graphQlTester.document("mutation { deleteComment(id: " + comment.getId() + ") }")
128 .execute()
129 .path("deleteComment").entity(Boolean.class).isEqualTo(true);
130
131 assertThat(commentRepository.findById(comment.getId())).isEmpty();
132 }
133}
The same .path(...) assertions work for mutations, and .errors() lets you assert on the error path without the test caring what shape the successful response would have been. Repository-level logic (custom @Query methods, pagination, cascades) is better covered with a focused @DataJpaTest against a real database instead, since GraphQL tests should be asserting on the API contract, not on JPA/Hibernate behaviour:
1package com.demo.project96.repo;
2
3import java.time.ZonedDateTime;
4
5import com.demo.project96.domain.Comment;
6import com.demo.project96.domain.Post;
7import org.junit.jupiter.api.AfterEach;
8import org.junit.jupiter.api.Test;
9import org.springframework.beans.factory.annotation.Autowired;
10import org.springframework.boot.data.jpa.test.autoconfigure.DataJpaTest;
11import org.springframework.boot.jdbc.test.autoconfigure.AutoConfigureTestDatabase;
12import org.springframework.test.context.ActiveProfiles;
13
14import static org.assertj.core.api.Assertions.assertThat;
15
16@DataJpaTest
17@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
18@ActiveProfiles("test")
19class CommentRepositoryTest {
20
21 @Autowired
22 private PostRepository postRepository;
23
24 @Autowired
25 private CommentRepository commentRepository;
26
27 @AfterEach
28 void cleanUp() {
29 commentRepository.deleteAll();
30 postRepository.deleteAll();
31 }
32
33 private Post newPost() {
34 return postRepository.save(Post.builder()
35 .header("header_1")
36 .createdBy("Jack")
37 .createdDt(ZonedDateTime.now())
38 .build());
39 }
40
41 private Comment newComment(Post post, String message) {
42 return commentRepository.save(Comment.builder()
43 .message(message)
44 .createdBy("Jack")
45 .createdDt(ZonedDateTime.now())
46 .post(post)
47 .build());
48 }
49
50 @Test
51 void findsCommentsByPost() {
52 Post post = newPost();
53 newComment(post, "comment_1");
54 newComment(post, "comment_2");
55
56 Iterable<Comment> comments = commentRepository.findByPost(post);
57
58 assertThat(comments).hasSize(2)
59 .extracting(Comment::getMessage)
60 .containsExactlyInAnyOrder("comment_1", "comment_2");
61 }
62
63 @Test
64 void findAllCommentsEagerlyFetchesPost() {
65 Post post = newPost();
66 newComment(post, "comment_1");
67
68 var comments = commentRepository.findAllComments();
69
70 assertThat(comments).hasSize(1);
71 assertThat(comments.get(0).getPost().getId()).isEqualTo(post.getId());
72 }
73}
Setup
1# Project 96
2
3Spring Boot & GraphQL
4
5[https://gitorko.github.io/spring-graphql/](https://gitorko.github.io/spring-graphql/)
6
7## Version
8
9Check version
10
11```bash
12$java --version
13openjdk version "21.0.3" 2024-04-16 LTS
14```
15
16## Postgres DB
17
18```
19docker run -p 5432:5432 --name pg-container -e POSTGRES_PASSWORD=password -d postgres:18.4
20docker ps
21docker exec -it pg-container psql -U postgres -W postgres
22CREATE USER test WITH PASSWORD 'test@123';
23CREATE DATABASE "test-db" WITH OWNER "test" ENCODING UTF8 TEMPLATE template0;
24grant all PRIVILEGES ON DATABASE "test-db" to test;
25
26docker stop pg-container
27docker start pg-container
28```
29
30## Dev
31
32To run the backend in dev mode.
33Postgres DB is needed to run the integration tests during build.
34
35```bash
36./gradlew clean build
37./gradlew bootRun
38```
39
40## Prod
41
42To run as a single jar.
43
44```bash
45./gradlew bootJar
46cd project96/build/libs
47java -jar project96-1.0.0.jar
48```
49
50## Graph IQL
51
52GraphQL comes with a browser client to test the Query. This can be enabled in properties
53
54```yaml
55graphql.graphiql.enabled: true
56```
57
58Open [http://localhost:8080/graphiql](http://localhost:8080/graphiql)
59
60## Bruno
61
62Import the Bruno collection to [Bruno](https://www.usebruno.com/)
63
64[Bruno Collection](https://github.com/gitorko/project96/blob/main/bruno/Project96)
Choosing Between REST, gRPC and GraphQL
All three are just different answers to "how does a client talk to a server", and the right one depends on who the client is and what shape the problem has, not which is objectively "better".
REST is the default choice, and stays the default unless something specific pushes you away from it.
- The API is public, or consumed by parties you don't control. REST's use of plain HTTP/JSON means every language, tool and human can read it without special tooling.
- The resource model is simple and maps cleanly to CRUD (
GET /posts/{id},POST /posts). You don't need clients able to shape their own responses. - You want to lean on standard HTTP infrastructure as-is: caching (
ETag,Cache-Control), CDNs, browser support, load balancers, API gateways, all of it already understands REST. - The team is small or the org is polyglot and you'd rather not ask every consumer to learn a schema language or codegen step.
gRPC earns its complexity when performance and strict contracts between services you control matter more than human-readability.
- Service-to-service calls inside your own infrastructure, not public-facing APIs, since gRPC needs HTTP/2 and Protobuf tooling on both ends.
- Latency and payload size actually matter: Protobuf's binary format and HTTP/2 multiplexing beat JSON/REST on the wire, which adds up at high request volumes.
- You need streaming, gRPC has first-class support for client, server and bidirectional streaming, which REST has to bolt on with things like SSE or long polling.
- You want the contract enforced at compile time, a
.protofile generates strongly typed client and server stubs, so a field type or method signature mismatch fails the build, not production.
GraphQL is worth the extra server-side complexity when the client's needs are the variable, not the server's.
- Multiple, different clients (web, mobile, third-party integrations) each want a different shape or subset of the same underlying data, and you'd otherwise end up building bespoke REST endpoints per client, or heavily over-fetching.
- A single logical request would otherwise mean multiple REST round trips (get a post, then its comments, then each comment's author), and the client would rather ask for that whole tree in one request.
- The data comes from several disparate sources (services, databases) and you want to expose it behind one coherent, browsable schema rather than making the client stitch together several APIs.
- You value the schema itself as living documentation, introspection means the API is self-describing and tools like GraphiQL give you a working playground for free.
Falcor and OData solve the same over/under-fetching problem as GraphQL, without introducing a new query language.
- Falcor (built at Netflix) models the entire backend as one virtual JSON object graph the client can path into, so the client's query is just a JSON path expression instead of a GraphQL document. It's largely dormant today, most teams that would have reached for it now reach for GraphQL instead, but it's worth knowing it exists if you ever see it in an older codebase.
- OData is a REST-based (mostly Microsoft/enterprise-ecosystem) standard that adds filtering, sorting, pagination and field-selection conventions on top of plain REST URLs (
GET /Posts?$filter=...&$select=header,createdBy), rather than a separate query language and endpoint. It's worth considering over GraphQL when the team wants REST's caching and tooling story but with more flexible querying than hand-rolled query parameters.
The projects don't have to be mutually exclusive within the same system. It's common to expose GraphQL or REST at the edge for clients, while services behind that edge talk to each other over gRPC, using each protocol where it's actually the better fit rather than picking one for the entire stack.
References
https://spring.io/projects/spring-graphql
https://github.com/graphql-java/graphql-java-extended-scalars
https://www.graphql-java.com/tutorials/getting-started-with-spring-boot/
https://spring.io/blog/2022/05/19/spring-for-graphql-1-0-release