View source on GitHub

Module 51 - GraphQL with Spring

What this module covers

Schema-first GraphQL with Spring for GraphQL: queries, mutations, and subscriptions via @QueryMapping/@MutationMapping/@SubscriptionMapping, solving the N+1 problem with @BatchMapping, and testing all three operation types with @GraphQlTest and GraphQlTester.


Project structure

src/main/java/com/javatraining/graphql/
├── GraphQlApplication.java
├── author/
│   ├── Author.java                  # record: id, name
│   └── AuthorRepository.java        # in-memory store + findAllByIds()
└── book/
    ├── Book.java                    # record: id, title, genre, authorId
    ├── BookRepository.java          # in-memory store
    ├── BookEventPublisher.java      # Sinks.Many<Book> wrapper for subscriptions
    └── BookController.java          # all GraphQL resolvers

src/main/resources/graphql/
└── schema.graphqls                  # schema-first definition

Schema

The .graphqls file is the single source of truth for the API shape. Spring for GraphQL loads all files matching classpath:graphql/**/*.graphqls.

type Query {
  books: [Book!]!
  book(id: ID!): Book
  authors: [Author!]!
}

type Mutation {
  addBook(title: String!, genre: String!, authorId: ID!): Book!
  deleteBook(id: ID!): Boolean!
}

type Subscription {
  bookAdded: Book!
}

type Book {
  id: ID!
  title: String!
  genre: String!
  author: Author! # resolved by @BatchMapping to prevent N+1
}

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

Resolver annotations

Annotation Maps to
@QueryMapping type Query { ... }
@MutationMapping type Mutation { ... }
@SubscriptionMapping type Subscription { ... }
@BatchMapping sub-field of a parent type
@Argument inline argument conversion

Method names are matched to field names by default; override with value.


Queries and mutations

@Controller
@RequiredArgsConstructor
public class BookController {

    @QueryMapping
    public List<Book> books() {
        return bookRepository.findAll();
    }

    @QueryMapping
    public Book book(@Argument Long id) {
        return bookRepository.findById(id).orElse(null);
    }

    @MutationMapping
    public Book addBook(@Argument String title, @Argument String genre,
                        @Argument Long authorId) {
        Book book = bookRepository.save(new Book(null, title, genre, authorId));
        bookEventPublisher.publish(book);
        return book;
    }

    @MutationMapping
    public boolean deleteBook(@Argument Long id) {
        return bookRepository.deleteById(id);
    }
}

@Argument applies Spring’s ConversionService - the GraphQL ID scalar arrives as a String; Long id triggers automatic String → Long conversion.


Subscriptions

@SubscriptionMapping returns a reactive Flux. Spring for GraphQL streams each emitted element to the client as a separate GraphQL response.

BookEventPublisher wraps a Sinks.Many to decouple the emission point (mutation) from the subscription source, which also makes each component independently mockable.

@Component
public class BookEventPublisher {
    private final Sinks.Many<Book> sink = Sinks.many().multicast().onBackpressureBuffer();

    public void publish(Book book)    { sink.tryEmitNext(book); }
    public Flux<Book> getStream()     { return sink.asFlux(); }
}

@SubscriptionMapping
public Flux<Book> bookAdded() {
    return bookEventPublisher.getStream();
}

N+1 problem and @BatchMapping

The problem

Fetching books { author { name } } with a naive @SchemaMapping triggers one findById(authorId) per book - N books = N+1 database calls.

The fix

@BatchMapping receives ALL parent objects from a single GraphQL execution at once and returns a map from parent → child. Spring for GraphQL invokes it exactly once, regardless of how many books were fetched.

@BatchMapping(typeName = "Book")
public Map<Book, Author> author(List<Book> books) {
    List<Long> authorIds = books.stream()
            .map(Book::authorId)
            .distinct()
            .toList();
    Map<Long, Author> authorMap = authorRepository.findAllByIds(authorIds)
            .stream()
            .collect(Collectors.toMap(Author::id, Function.identity()));
    return books.stream()
            .collect(Collectors.toMap(Function.identity(), b -> authorMap.get(b.authorId())));
}

The method name author matches the Book.author field in the schema. typeName = "Book" targets the correct parent type.


Testing with @GraphQlTest

@GraphQlTest loads only the GraphQL controller layer - no Tomcat, no HTTP - backed by ExecutionGraphQlService. Queries, mutations, and subscriptions all run through the same tester.

@GraphQlTest(BookController.class)
class BookControllerTest {

    @Autowired GraphQlTester graphQlTester;
    @MockBean  BookRepository bookRepository;
    @MockBean  AuthorRepository authorRepository;
    @MockBean  BookEventPublisher bookEventPublisher;

Query

graphQlTester.document("{ book(id: \"1\") { title author { name } } }")
    .execute()
    .path("book.title").entity(String.class).isEqualTo("Effective Java")
    .path("book.author.name").entity(String.class).isEqualTo("Joshua Bloch");

Mutation with side-effect verification

graphQlTester.document("""
    mutation { addBook(title: "New Book", genre: "Fiction", authorId: "1") { id title } }
    """)
    .execute()
    .path("addBook.title").entity(String.class).isEqualTo("New Book");

verify(bookEventPublisher).publish(saved);

Subscription with StepVerifier

executeSubscription().toFlux() returns the subscription Flux directly. Mock bookEventPublisher.getStream() with a finite Flux to control what is emitted.

when(bookEventPublisher.getStream()).thenReturn(Flux.just(newBook));

graphQlTester.document("subscription { bookAdded { id title } }")
    .executeSubscription()
    .toFlux("bookAdded", Map.class)
    .as(StepVerifier::create)
    .assertNext(book -> assertThat(book.get("title")).isEqualTo("GraphQL in Action"))
    .verifyComplete();

Batch mapping verification

when(bookRepository.findAll()).thenReturn(List.of(book1, book2));
when(authorRepository.findAllByIds(any())).thenReturn(List.of(author1, author2));

graphQlTester.document("{ books { title author { name } } }")
    .execute()
    .path("books").entityList(Map.class).hasSize(2);

verify(authorRepository, times(1)).findAllByIds(any());

times(1) proves the batch mapping called findAllByIds once for 2 books, not twice (which would indicate N+1 regression).


Tests

Class Type Count
BookControllerTest @GraphQlTest 7

Run: JAVA_HOME=/opt/homebrew/opt/openjdk@21 mvn test Result: 7/7 pass


Key decisions

Decision Reason
@BatchMapping over @SchemaMapping with DataLoader @BatchMapping is Spring for GraphQL’s idiomatic N+1 fix - no manual DataLoader registration needed
BookEventPublisher as a separate component Decouples sink from controller, making mutation and subscription independently mockable in @GraphQlTest
Sinks.many().multicast().onBackpressureBuffer() Hot source that buffers for each slow subscriber; replaces the old EmitterProcessor (deprecated in Reactor 3.5)
Subscription mock returns Flux.just(...) Finite flux that completes immediately keeps StepVerifier tests synchronous and deterministic
Schema-first over annotation-first Schema file is language-agnostic documentation; implementation auto-validated against it at startup