Queries, Mutations & Subscriptions
GraphQL defines three operation types: queries read data, mutations write it, and subscriptions stream real-time updates. Spring for GraphQL maps each to a dedicated annotation on a @Controller. This page covers all three plus validation and error handling.
Queries — reading data
A query operation requests data without side effects. As covered in Schema & Resolvers, each root query field maps to a @QueryMapping method.
query GetBook {
book(id: "1") {
title
pages
}
}
{ "data": { "book": { "title": "Clean Code", "pages": 464 } } }
By convention, multiple operations can share one document and you select which to run by name (GetBook above). Read operations should never mutate state — that is the contract clients rely on for caching and retries.
Mutations — writing data
Writes go under the root Mutation type. Define an input type for the payload (inputs are a distinct kind in SDL and cannot be reused as output types).
type Mutation {
addBook(input: AddBookInput!): Book!
deleteBook(id: ID!): Boolean!
}
input AddBookInput {
title: String!
pages: Int
authorId: ID!
}
Map each with @MutationMapping. Bind the input object to a record using @Argument; Spring maps fields by name.
@Controller
@RequiredArgsConstructor
public class BookMutationController {
private final BookService bookService;
@MutationMapping
public Book addBook(@Argument AddBookInput input) {
return bookService.create(input);
}
@MutationMapping
public boolean deleteBook(@Argument String id) {
return bookService.delete(id);
}
}
public record AddBookInput(String title, Integer pages, String authorId) { }
A mutation request looks like a query but uses the mutation keyword:
mutation {
addBook(input: { title: "Effective Java", pages: 416, authorId: "7" }) {
id
title
}
}
Output:
{
"data": {
"addBook": { "id": "12", "title": "Effective Java" }
}
}
| Operation | Root type | Annotation | Purpose |
|---|---|---|---|
| Query | Query | @QueryMapping | Read data |
| Mutation | Mutation | @MutationMapping | Create/update/delete |
| Subscription | Subscription | @SubscriptionMapping | Stream updates |
Validation
GraphQL’s schema enforces types and non-null (!) automatically — a missing required argument is rejected before your resolver runs. For business rules, add Jakarta Bean Validation with spring-boot-starter-validation, then annotate input fields and the parameter with @Valid.
public record AddBookInput(
@NotBlank String title,
@Positive @Max(5000) Integer pages,
@NotBlank String authorId) { }
@MutationMapping
public Book addBook(@Argument @Valid AddBookInput input) {
return bookService.create(input);
}
A constraint violation surfaces as a GraphQL error rather than an HTTP 400, because GraphQL responses are always 200 OK at the transport level (errors live in the errors array).
Note: Unlike a REST controller where
@Validfailures map to HTTP400, GraphQL keeps a200status and reports problems in theerrorsarray. Clients inspecterrors, not the HTTP code.
Error handling with GraphQlExceptionResolver
Uncaught exceptions become a generic INTERNAL_ERROR with the message hidden — good for security, unhelpful for clients. Register a @ControllerAdvice whose methods are annotated with @GraphQlExceptionHandler (or implement DataFetcherExceptionResolver) to map exceptions to meaningful errors with proper ErrorType classifications.
@ControllerAdvice
public class GraphQlExceptionHandler {
@GraphQlExceptionHandler
public GraphQLError handleNotFound(BookNotFoundException ex) {
return GraphQLError.newError()
.errorType(ErrorType.NOT_FOUND)
.message(ex.getMessage())
.build();
}
@GraphQlExceptionHandler
public GraphQLError handleIllegalArg(IllegalArgumentException ex) {
return GraphQLError.newError()
.errorType(ErrorType.BAD_REQUEST)
.message(ex.getMessage())
.build();
}
}
Output for a missing book:
{
"data": { "book": null },
"errors": [
{
"message": "Book 99 not found",
"extensions": { "classification": "NOT_FOUND" }
}
]
}
The ErrorType (NOT_FOUND, BAD_REQUEST, FORBIDDEN, UNAUTHORIZED, INTERNAL_ERROR) appears as extensions.classification, giving clients a stable, typed signal. See Custom exceptions for designing the domain exceptions themselves.
Subscriptions — real-time streams
A subscription delivers a stream of results over a persistent connection (usually WebSocket). The resolver returns a Project Reactor Flux, so this requires reactive support — add spring-boot-starter-webflux (or enable the WebSocket transport) and configure the endpoint.
type Subscription {
bookAdded: Book!
}
@Controller
@RequiredArgsConstructor
public class BookSubscriptionController {
private final BookEventPublisher publisher;
@SubscriptionMapping
public Flux<Book> bookAdded() {
return publisher.bookAddedStream(); // Flux<Book>
}
}
Enable the WebSocket transport so subscriptions have a connection to ride on:
spring.graphql.websocket.path=/graphql
subscription {
bookAdded {
id
title
}
}
Each time the server publishes a book, the client receives an incremental data payload on the open socket:
{ "data": { "bookAdded": { "id": "13", "title": "Refactoring" } } }
Warning: Subscriptions require WebFlux/WebSocket and a reactive publisher (a hot
Flux, e.g. backed by aSinks.Many). Plain Spring MVC over HTTP supports queries and mutations but not subscriptions. See Mono & Flux for the reactive types involved.