Skip to content
Spring Boot sb graphql 4 min read

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" }
  }
}
OperationRoot typeAnnotationPurpose
QueryQuery@QueryMappingRead data
MutationMutation@MutationMappingCreate/update/delete
SubscriptionSubscription@SubscriptionMappingStream 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 @Valid failures map to HTTP 400, GraphQL keeps a 200 status and reports problems in the errors array. Clients inspect errors, 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 a Sinks.Many). Plain Spring MVC over HTTP supports queries and mutations but not subscriptions. See Mono & Flux for the reactive types involved.

Last updated June 13, 2026
Was this helpful?