GraphQL with Spring Boot
Building a GraphQL API with Spring for GraphQL: schema-first mapping, solving N+1 with batch mapping, field-level authorisation, and the query limits every public endpoint needs.
On this page
GraphQL moves the shape of the response from the server to the client. That solves real problems — over-fetching, endpoint proliferation, mobile round trips — and creates new ones around performance and abuse that REST simply does not have.
Key Takeaways
- Schema-first keeps the contract reviewable and is the well-trodden path in Spring for GraphQL.
- The N+1 problem is structural, not incidental —
@BatchMappingis not optional. - Authorisation must be per field, because the client chooses which fields to request.
- GraphQL defeats HTTP caching; plan for application-level caching instead.
- Always cap depth and complexity on a public endpoint.
Schema and mapping
type Query {
order(id: ID!): Order
orders(status: OrderStatus, first: Int = 20, after: String): OrderConnection!
}
type Mutation {
placeOrder(input: PlaceOrderInput!): PlaceOrderPayload!
}
type Order {
id: ID!
reference: String!
status: OrderStatus!
total: Money!
customer: Customer!
lines: [OrderLine!]!
createdAt: DateTime!
}
type OrderConnection {
edges: [OrderEdge!]!
pageInfo: PageInfo!
}
type OrderEdge { node: Order! cursor: String! }
type PageInfo { hasNextPage: Boolean! endCursor: String }
enum OrderStatus { PENDING PAID SHIPPED CANCELLED }
scalar DateTime@Controller
public class OrderGraphQlController {
private final OrderService orders;
private final CustomerService customers;
@QueryMapping
public Order order(@Argument String id) {
return orders.find(id);
}
@QueryMapping
public OrderConnection orders(@Argument OrderStatus status,
@Argument int first,
@Argument String after) {
return orders.page(status, first, after);
}
@MutationMapping
public PlaceOrderPayload placeOrder(@Argument @Valid PlaceOrderInput input) {
return new PlaceOrderPayload(orders.place(input));
}
// WITHOUT batching this runs once per order in the result — the N+1 problem.
// @BatchMapping receives every parent at once and resolves them in one call.
@BatchMapping
public Map<Order, Customer> customer(List<Order> orders) {
Set<String> ids = orders.stream().map(Order::customerId).collect(toSet());
Map<String, Customer> byId = customers.findAllById(ids);
return orders.stream().collect(toMap(identity(), o -> byId.get(o.customerId())));
}
}The @BatchMapping method is the single most important thing in a Spring GraphQL controller. Without
it, a query for twenty orders with their customers issues twenty-one queries; with it, two.
Field-level authorisation
REST authorises an endpoint. GraphQL cannot, because one endpoint returns whatever the client asked for. Authorisation has to move to the field:
@SchemaMapping(typeName = "Customer", field = "email")
@PreAuthorize("hasRole('SUPPORT') or #customer.id == authentication.name")
public String email(Customer customer) {
return customer.email();
}
@QueryMapping
@PreAuthorize("hasRole('ADMIN')")
public List<AuditEntry> auditLog(@Argument String orderId) {
return auditService.forOrder(orderId);
}Enable method security and make sure denials become GraphQL errors rather than 500s:
@Component
public class SecurityExceptionResolver extends DataFetcherExceptionResolverAdapter {
@Override
protected GraphQLError resolveToSingleError(Throwable ex, DataFetchingEnvironment env) {
if (ex instanceof AccessDeniedException) {
return GraphqlErrorBuilder.newError(env)
.errorType(ErrorType.FORBIDDEN)
.message("Not permitted to read this field")
.build();
}
if (ex instanceof ResourceNotFoundException notFound) {
return GraphqlErrorBuilder.newError(env)
.errorType(ErrorType.NOT_FOUND)
.message(notFound.getMessage())
.build();
}
return null; // fall through to the default handler
}
}Remember that GraphQL returns 200 with an errors array for most failures. Monitoring that only
watches HTTP status codes will report a perfectly healthy service while every query is failing —
instrument the error array explicitly.
Limiting abuse
A cyclic schema plus an unbounded query is a denial-of-service primitive. Three controls close it:
@Bean
public GraphQlSourceBuilderCustomizer limits() {
return builder -> builder.instrumentation(List.of(
new MaxQueryDepthInstrumentation(10),
new MaxQueryComplexityInstrumentation(500)));
}spring:
graphql:
schema:
introspection:
enabled: false # off in production; keep it on in devDepth stops recursive nesting. Complexity assigns a cost per field — higher for list fields, which multiply — and rejects queries above a budget. Disabling introspection removes the free map of your entire schema; it is not a security boundary on its own, but it raises the cost of casual probing.
For a partner-facing API, add persisted queries: clients register their queries ahead of time and send a hash at runtime. The server then executes only known queries, which eliminates arbitrary query abuse completely and shrinks request payloads as a bonus.
Caching, and what you give up
REST gets HTTP caching for free: a GET with an ETag can be cached by any proxy on the path.
GraphQL sends everything as a POST with a body that varies per client, so none of that
infrastructure applies.
What replaces it is application-level caching at two layers. Per-request caching happens for free
via the DataLoader, which deduplicates identical loads within a single query. Cross-request
caching means caching at the data-access layer, keyed by entity id rather than by response, since the
response shape differs per client. Automatic persisted queries can restore a degree of CDN caching by
turning queries into cacheable GETs with a hash in the URL — worth setting up if you serve public
read-heavy traffic.
When GraphQL is the right choice
It fits well when several clients need different subsets of the same connected graph, when mobile round trips are the bottleneck, or when you are aggregating many back-end services behind one facade for front-end teams.
It fits badly for file upload and download, for simple resource CRUD where REST maps cleanly, for public APIs where HTTP caching is doing real work, and for teams without capacity to own the operational side — the schema registry, the complexity budgets, the per-field authorisation review. Those are not blockers, but they are ongoing work that REST does not require, and adopting GraphQL without budgeting for them is how teams end up with an endpoint nobody wants to own.
What to take away
Write the schema first, batch every nested resolver, and authorise per field. Cap depth and
complexity before the endpoint is public, monitor the errors array rather than HTTP status, and go
in knowing you have traded HTTP caching for client flexibility.
Frequently Asked Questions
Does GraphQL replace REST?
Why is my GraphQL API making hundreds of queries?
How do I stop a client sending an abusive query?
Related tutorials
- WebClient & HTTP Client IntegrationCalling other services without taking yourself down: WebClient configuration, the four timeouts that matter, connection pool sizing, retry with backoff, and testing against a real socket.
- WebSocket & Real-Time CommunicationReal-time push in Spring: STOMP over WebSocket, broadcasting and user-targeted messages, authenticating the handshake, scaling with an external broker, and when SSE is the better fit.
- Error Handling with Problem DetailsDesigning an error contract on RFC 7807: the standard fields, extension properties worth adding, an error catalogue, internationalised messages, and errors across service boundaries.
- Rate Limiting & ThrottlingThe five rate-limiting algorithms compared, distributed limiting with Redis and Bucket4j, per-tier quotas, and the response headers clients need to behave well.