GraphQL for Java backends
Schema-first with Spring for GraphQL, resolvers, DataLoader against N+1, and depth and complexity limits.
REST gives every client the same response and lets the server decide its shape. GraphQL turns that round: the server publishes a schema of types and fields, and each client asks for exactly the fields it wants, across relationships, in one request. It solves the mobile team's "three round trips and 80% of the payload unused" problem precisely, and it creates a set of server-side problems — the N+1 in every resolver, a query that can ask for the whole graph, errors that arrive as 200 — that a Java backend has to solve before the first client ships.
The schema, and schema-first
type Query {
order(id: ID!): Order
orders(customerId: ID!, first: Int = 20, after: String): OrderConnection!
}
type Order {
id: ID!
status: OrderStatus!
total: Money!
lines: [Line!]!
customer: Customer!
}
type Customer { id: ID! name: String! orders(first: Int = 10): [Order!]! }
type Line { sku: String! qty: Int! product: Product! }
enum OrderStatus { PAID SHIPPED DELIVERED CANCELLED }
scalar Money
type Mutation { cancelOrder(id: ID!, reason: String!): CancelOrderPayload! }The .graphqls file is the contract, reviewed before the code exists — the design-first argument from the OpenAPI lesson, with the difference that here it is the only way: Spring for GraphQL loads the schema and maps fields to methods, so the schema cannot drift from the implementation. ! is non-null, and it is a promise the server keeps or the whole parent field becomes null; make a field nullable when its resolver can fail independently of its parent. Custom scalars (Money, DateTime) need a coercing implementation registered with the runtime wiring; the graphql-java-extended-scalars library has most. Mutations return a payload type rather than the bare object, so a business failure (CancelOrderPayload { order, error }) has somewhere to go that is not the errors array.
Resolvers: a method per field, and where the data comes from
@Controller
class OrderGraph {
@QueryMapping
Order order(@Argument String id) { return orders.findById(id).orElse(null); }
@SchemaMapping(typeName = "Order", field = "customer")
Customer customer(Order order) { return customers.findById(order.customerId()); } // called ONCE PER ORDER
}@QueryMapping binds a root field; @SchemaMapping binds a field of a type, and its first parameter is the parent object. Anything not mapped is resolved by the default property fetcher — a getter or a record component of the same name — which is why a DTO shaped like the type needs no resolvers at all. A @MutationMapping is the same for Mutation, and @Argument binds inputs, with Bean Validation on an @Valid input type as in the REST course.
That comment on customer is the whole difficulty of GraphQL on a backend. The client asks for twenty orders and each one's customer; the runtime resolves the orders field once, gets twenty orders, then calls the customer resolver twenty times, one query each. It is the JPA course's N+1 problem, produced by the engine on purpose, for every relationship in every query, and no amount of JOIN FETCH in the first resolver helps, because the engine does not know what the client will ask for next.
DataLoader: batch the second level
The fix is to make the per-parent resolver collect keys instead of loading, and load them all at once when the engine has finished the level:
@Controller
class OrderGraph {
@BatchMapping(typeName = "Order", field = "customer")
Map<Order, Customer> customers(List<Order> orders) { // called ONCE for all twenty
Set<String> ids = orders.stream().map(Order::customerId).collect(toSet());
Map<String, Customer> byId = customers.findAllById(ids).stream().collect(toMap(Customer::id, c -> c));
return orders.stream().collect(toMap(o -> o, o -> byId.get(o.customerId())));
}
}@BatchMapping is Spring's front for DataLoader: the engine defers every customer fetch on a level, hands the batch to this method, and one WHERE id IN (...) replaces twenty queries. The rules that make it correct: return a Map (or a List in the same order as the input — the DataLoader contract), return an entry for every key even when it is null, and register a loader per request, never as a singleton, because it also caches by key for the request's lifetime and a shared cache would leak one user's customer into another's query. For loaders with more logic, BatchLoaderRegistry and DataLoader<K, V> as a method parameter are the explicit form.
Do the same for lines → product and customer → orders, and the graph resolves in one query per level rather than one per node. Which is still one query per level per relationship, and a client that asks for orders { lines { product { supplier { ... } } } } is four levels deep; the JPA course's projections apply — a resolver that loads a Product for a sku and name should select those two columns.
Errors: the 200 that is not OK
A GraphQL response is { "data": ..., "errors": [...] }, and the HTTP status is 200 whenever the query was parseable — a resolver that threw puts its field to null and an entry in errors with a path and extensions. Three consequences for a Java backend. Classify exceptions with a DataFetcherExceptionResolver: a NotFoundException becomes extensions.classification: NOT_FOUND with a clean message, and everything else becomes INTERNAL_ERROR with the message replaced, because the default is to leak the exception's message and the REST course's "what never leaves the server" applies here with more force (the error path names your schema). Business outcomes are not errors: "cannot cancel a shipped order" is a field on the mutation's payload type, so the client handles it as data, not as an exception it has to parse out of a list. And monitor the errors array, because an HTTP dashboard sees 200s while half the queries are failing; the observability course's error metric here counts responses with a non-empty errors.
Limits: a query is a program the client wrote
The client chooses the shape, so the client can choose a shape that reads the whole database: orders { customer { orders { customer { orders ... } } } }, or a list field with first: 1000000. Every public GraphQL API needs the same four guards, and Spring for GraphQL exposes the graphql-java instrumentation for them:
- Depth limit (
MaxQueryDepthInstrumentation): reject a query nested past, say, ten levels, before it runs. - Complexity limit (
MaxQueryComplexityInstrumentation): each field costs one, a list field costs itsfirstargument times the child cost; reject past a budget. This is the one that stopsfirst: 1000000. - Pagination on every list, with a maximum page size enforced in the resolver — the REST course's "bound everything" — and the Relay connection shape (
edges,pageInfo, cursors) when clients page. - Persisted queries for a first-party client: the client sends a hash of a query the server already knows, so an attacker with the endpoint cannot run arbitrary shapes at all, and the queries can be reviewed like an API.
Plus the ones REST already has: authentication before the schema (the security course's filter chain sits in front of /graphql like any endpoint), authorisation per field where the schema exposes something not everyone may read (@PreAuthorize on the resolver method works), introspection disabled in production so the schema is not a free map for an attacker, and a request timeout, because a slow resolver holds a thread exactly as a slow endpoint does. Test the whole thing with @GraphQlTest and GraphQlTester, the slice from the testing course for this layer.