Skip to content
JavaAgentic

Type at least two characters. Try “RAG”, “pgvector” or “tool calling”.

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.

Advanced5 min readUpdated
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 — @BatchMapping is 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

src/main/resources/graphql/schema.graphqls
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
OrderGraphQlController.java
@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.

Batch mapping collects every parent key from one level of the query and resolves them in a single round trip.

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:

FieldSecurity.java
@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:

GraphQlExceptionResolver.java
@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:

GraphQlLimits.java
@Bean
public GraphQlSourceBuilderCustomizer limits() {
    return builder -> builder.instrumentation(List.of(
            new MaxQueryDepthInstrumentation(10),
            new MaxQueryComplexityInstrumentation(500)));
}
application.yml
spring:
  graphql:
    schema:
      introspection:
        enabled: false        # off in production; keep it on in dev

Depth 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?
Rarely. GraphQL is strongest where clients need widely varying shapes of the same graph — a mobile app and a desktop app with different data needs. It is weaker for file transfer, HTTP caching, and simple CRUD where REST resources map cleanly. Many systems run both, with GraphQL as an aggregation layer over REST services.
Why is my GraphQL API making hundreds of queries?
A nested field resolver runs once per parent object, so a list of 100 orders each resolving a customer produces 100 queries. @BatchMapping collects the parent keys and resolves them in one call, which is what DataLoader does under the covers.
How do I stop a client sending an abusive query?
Bound depth and complexity with MaxQueryDepthInstrumentation and MaxQueryComplexityInstrumentation, and disable introspection in production. Without limits, a recursive query over a cyclic schema can ask for effectively unbounded work in a few hundred characters.

Related tutorials