REST gives the server the last word on the response shape: the menu grid and the order screen
both call GET /api/products and both get everything. GraphQL inverts that — one
endpoint, one schema, and the client writes the query.
This lesson adds GraphQL to the pizza API alongside the REST controllers, calling
the same ProductService and CartService. GraphQL is a transport, not an
architecture: caching, transactions and validation live under the service layer and carry over
untouched.
{
menu(type: PIZZA) {
name
priceFor(size: LARGE)
}
}{"data":{"menu":[
{"name":"Pepperoni Pizza","priceFor":16.99},
{"name":"Cheese Pizza","priceFor":15.49},
{"name":"Supreme Pizza","priceFor":19.99}
]}}Two fields asked for, two returned — and no endpoint had to be written for that combination.
Setup
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-graphql</artifactId>
</dependency>That is the whole wiring — no configuration class, no registration. Provided it finds a
schema. Boot looks in classpath:graphql/**/*.graphqls, and the entire
autoconfiguration is gated on that (@ConditionalOnGraphQlSchema). With no schema file
the app starts normally, logs nothing, and simply has no /graphql endpoint — a 404 with
no clue anywhere as to why. The line to look for, whose absence is the whole diagnosis:
GraphQlWebMvcAutoConfiguration : GraphQL endpoint HTTP POST /graphqlOnce a schema is there, Boot inspects it against your controllers at startup and logs a report of unmapped fields, registrations and arguments. Read it after every schema change — it is how you find a field you declared and forgot to back with anything.
The schema is not your DTOs
GraphQL is schema-first: you write the contract and Spring checks your code against it. The temptation is to transcribe the DTOs. Resist it — everything in the schema is reachable by anyone who can reach the endpoint, so the schema is a publishing decision:
scalar UUID
scalar BigDecimal
type Product {
id: UUID!
name: String!
description: String
type: ProductType!
sizes: [ProductSize!]!
"Price in one size, or null if this product is not sold in it."
priceFor(size: SizeName!): BigDecimal
}
type Query {
menu(type: ProductType): [Product!]!
# Nullable: a miss returns data.product = null AND a NOT_FOUND entry in errors.
product(id: UUID!): Product
}ProductDTO also carries createdAt, updatedAt and
displayOrder; none appears here, because every one would become a supported field the
moment it shipped. And ! means non-null, which carries real weight — a field left
nullable is a decision, not laziness. See below.
⚠️ There are five scalars, and none of them is a decimal
GraphQL ships Int, Float, String, Boolean and
ID. That is the entire list: no UUID, no decimal. And Float is an IEEE-754
double, so publishing a price through it reintroduces exactly the drift every
BigDecimal in the codebase exists to prevent — at the last hop, on the wire, where it is
hardest to notice.
<dependency>
<groupId>com.graphql-java</groupId>
<artifactId>graphql-java-extended-scalars</artifactId>
<version>22.0</version>
</dependency>Boot manages graphql-java but not the scalar set, so the version is pinned by hand —
same situation as springdoc in lesson 14.
// Every `scalar` line in the schema must be given an implementation here.
@Bean
public RuntimeWiringConfigurer scalarConfigurer() {
return wiring -> wiring.scalar(ExtendedScalars.UUID).scalar(ExtendedScalars.GraphQLBigDecimal);
}Unlike a missing schema, a declared-but-unimplemented scalar is loud:
SchemaProblem{errors=[There is no scalar implementation for the named 'UUID' scalar type,
There is no scalar implementation for the named 'BigDecimal' scalar type]}Note what is not registered: a date-time scalar. The extended set's
DateTime is built on OffsetDateTime and every timestamp in this app is a
zoneless LocalDateTime, so rather than publish one through a scalar that would have to
invent a zone, the schema has no timestamps in it at all.
Queries
// @Controller, not @RestController. These methods return domain objects that
// graphql-java shapes according to the query, so there is nothing for @ResponseBody to do.
@Controller
@RequiredArgsConstructor
public class MenuGraphQlController {
// The SAME service the REST controller calls. Its @Cacheable and @Transactional
// still apply, because they live under here rather than in the controller.
private final ProductService productService;
// The method name is the field name. That is the whole mapping.
@QueryMapping
public List<ProductDTO> menu(@Argument ProductType type) {
return type == null ? productService.getMenu() : productService.getByType(type);
}
// No try/catch: the service throws ApiException.notFound exactly as it does for REST.
@QueryMapping
public ProductDTO product(@Argument UUID id) {
return productService.getProductByPublicId(id);
}
}@Argument binds by name, and a nullable argument the caller omitted arrives as
null — the same "all or filtered" decision the REST controller makes with an optional
query parameter.
A field that exists only in the schema
priceFor(size:) has no accessor on ProductDTO. It is supplied by a data
fetcher, and it can take arguments of its own — something REST has no equivalent of short of another
endpoint.
// The first parameter is the parent object. The method name matches the field, so
// @SchemaMapping needs only the type.
@SchemaMapping(typeName = "Product")
public BigDecimal priceFor(ProductDTO product, @Argument SizeName size) {
return product.sizes().stream()
.filter(s -> s.size() == size)
.map(ProductSizeDTO::price)
.findFirst()
.orElse(null);
}And it costs nothing when unasked: a data fetcher only runs if the query selected its field.
⚠️ The N+1 that nested queries invite
A cart line stores a product id, not a product. Exposing CartItem.product as a
@SchemaMapping taking one CartItemDTO runs it once per
line: a six-item cart makes six lookups to answer one query, and nothing in the query text
hints at it.
Same N+1 as lesson 17 in different clothes, and
GraphQL invites it because the client decides the nesting. @BatchMapping inverts the
signature:
// graphql-java collects every CartItem at this level FIRST and calls this once with all
// of them. One lookup, whatever the cart's size.
@BatchMapping(typeName = "CartItem", field = "product")
public Map<CartItemDTO, ProductDTO> cartItemProduct(List<CartItemDTO> items) {
Map<UUID, ProductDTO> byId = productService.getMenu().stream()
.collect(Collectors.toMap(ProductDTO::id, Function.identity()));
Map<CartItemDTO, ProductDTO> resolved = new LinkedHashMap<>();
for (CartItemDTO item : items) {
ProductDTO product = byId.get(item.productId());
if (product != null) {
resolved.put(item, product);
}
}
return resolved;
}The return is a map from parent to value — which is how each result finds the right line,
so the parent type needs a usable equals/hashCode or it silently loses
rows. CartItemDTO is a record and each line carries its own UUID, so two identical lines
stay distinct keys.
Why product is nullable
A cart can outlive a product being taken off the menu. Under Product! that null is
illegal, and GraphQL propagates a non-null violation upwards — it would wipe out the
entire CartItem, turning "one item is no longer sold" into "the cart is missing a line".
The honest null is the better answer, and the schema has to say so.
Mutations
Anything that writes goes under Mutation — a convention rather than an enforcement,
but clients and tooling rely on it.
input CartItemInput {
productId: UUID!
size: SizeName!
crustId: UUID
toppingIds: [UUID!]
quantity: Int!
}
input CartInput {
orderType: OrderType!
items: [CartItemInput!]!
}
type Mutation {
saveCart(cartId: UUID!, cart: CartInput!): Cart!
"Admin only. Enforced by @PreAuthorize, because every operation shares one URL."
deactivateProduct(id: UUID!): Boolean!
}input is a distinct kind from type — inputs go in, types come out, and
one cannot substitute for the other. Spring binds an input onto a record by matching component names,
exactly as Jackson binds a JSON body.
@MutationMapping
public CartDTO saveCart(@Argument UUID cartId, @Argument @Valid CartWriteDTO cart) {
return cartService.replaceCart(cartId, cart, signedInEmail());
}@Valid carries over: a quantity of 0 is refused by the @Min(1) on the
record, not by controller code.
⚠️ Principal does not carry over
The REST controller declares Principal principal and gets null for an
anonymous caller — that is what makes guest checkout work. Spring GraphQL resolves the same parameter
type differently: with no authentication it throws
AuthenticationCredentialsNotFoundException. So a signature copied verbatim from the REST
controller breaks every anonymous write, and it surfaces as an opaque INTERNAL_ERROR
rather than a 401 — it reads as a server bug, not an auth one. Absent authentication is a legitimate
state here, so the method has to be able to see it:
/** The signed-in caller's email, or null for a guest. Anonymous counts as a guest. */
private String signedInEmail() {
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
return authentication == null || !authentication.isAuthenticated()
? null
: authentication.getName();
}Errors: there is no status code
A GraphQL request is a POST that succeeded. Every response is 200, and failures
live in an errors array beside whatever data did resolve. Partial data and errors
together is the single biggest departure from REST.
{"errors":[{
"message":"Product 00000000-0000-4000-8000-000000000000 was not found",
"path":["product"],
"extensions":{"classification":"NOT_FOUND"}
}],
"data":{"product":null}}@RestControllerAdvice does nothing here — there is no status to set. Unresolved, an
exception becomes INTERNAL_ERROR for <some uuid>. That opacity is deliberate, so an
accidental message never leaks, but it is useless to a caller who asked for a product that does not
exist:
@Component
public class ApiExceptionResolver extends DataFetcherExceptionResolverAdapter {
@Override
protected GraphQLError resolveToSingleError(Throwable ex, DataFetchingEnvironment env) {
// ⚠️ Bean Validation lands here too, and NOT as anything Spring recognises.
// @Valid on an @Argument throws a plain ConstraintViolationException, which has no
// mapping of its own - so without this branch a rejected quantity is reported as
// INTERNAL_ERROR, indistinguishable from a crash. RestExceptionHandler gets this
// for free from MethodArgumentNotValidException; GraphQL has to be told.
if (ex instanceof ConstraintViolationException violations) {
return GraphqlErrorBuilder.newError(env)
.errorType(ErrorType.BAD_REQUEST)
.message("Validation failed")
.extensions(Map.of("fieldErrors", fieldErrors(violations)))
.build();
}
// Returning null means "not mine" and lets the next resolver handle it.
// ANYTHING UNRECOGNISED MUST STAY an opaque INTERNAL_ERROR.
if (!(ex instanceof ApiException api)) {
return null;
}
return GraphqlErrorBuilder.newError(env)
.errorType(classify(api.getError().getStatus()))
.message(api.getMessage())
.build();
}
}ErrorType — NOT_FOUND, BAD_REQUEST,
UNAUTHORIZED, FORBIDDEN, INTERNAL_ERROR — is Spring's
stand-in for the status code, and it is what clients should branch on rather than the message
text.
⚠️ Security: one URL for everything
This is genuinely different from lesson
25. Reading the public menu and deactivating a product are both POST
/graphql. There is no requestMatchers("/graphql/deactivateProduct") to write,
because that path does not exist.
// SecurityConfig. permitAll() here is not "GraphQL is public" - it is an admission that
// this layer has nothing useful to say. The JWT filter still runs, so a caller who sends
// a token IS authenticated by the time a data fetcher executes; permitAll only means the
// request is not rejected for lacking one.
.requestMatchers("/graphql", "/graphiql", "/graphiql/**")
.permitAll()
// ...and the controller. Not defence in depth. The ONLY defence. A mutation that forgets
// this is open to anyone who can reach the endpoint, and no URL rule will catch it.
@MutationMapping
@PreAuthorize("hasRole('ADMIN')")
public boolean deactivateProduct(@Argument UUID id) { }So @EnableMethodSecurity is not optional here — without it every mutation is wide
open. Spring Security's GraphQL integration maps the outcome: no token gives
UNAUTHORIZED, a customer's token gives FORBIDDEN — 401 versus 403, carried
in the body because there is no status code to carry it.
Cost is the caller's to decide
A REST client asks for one resource and gets a bounded response; a GraphQL client writes the query. As soon as a schema contains a cycle — one type reachable from itself — a query can nest through it hundreds of levels deep, and one unauthenticated request does unbounded work.
// Boot picks up every Instrumentation bean and chains it, so declaring one is the whole
// wiring. The check runs during VALIDATION, before execution, so a rejected query costs
// no database work at all.
@Bean
public Instrumentation maxQueryDepthInstrumentation() {
return new MaxQueryDepthInstrumentation(10);
}The pizza schema has no cycle today — cart → items → product → sizes stops at five
levels. The bean is there because that is one field away from being untrue: adding
Product.relatedItems: [Product!] would open it, and nobody would call that a security
change. (MaxQueryComplexityInstrumentation bounds total fields touched, rather than
depth.)
Two switches to turn off in production: GraphiQL, which renders your whole schema
to anyone who loads the page, and introspection
(spring.graphql.schema.introspection.enabled=false), the API it uses to read it — real
clients ship with their queries already written and lose nothing.
Testing
@GraphQlTest is the GraphQL equivalent of the @WebMvcTest slice from
lesson 33 — schema, controller and data fetchers, with
no web server, no database and no security.
// ⚠️ GraphQlConfig must be imported explicitly. A slice registers @Controller beans and
// nothing else, so without it `scalar UUID` has no implementation and the context fails
// before a single test runs - naming a scalar, not the controller under test.
@GraphQlTest(MenuGraphQlController.class)
@Import({GraphQlConfig.class, ApiExceptionResolver.class})
class MenuGraphQlControllerTest {
@Autowired private GraphQlTester graphQlTester;
@MockitoBean private ProductService productService;
@Test
void missingProductIsClassified() {
when(productService.getProductByPublicId(MISSING))
.thenThrow(ApiException.notFound("Product", MISSING));
graphQlTester.document("query($id: UUID!) { product(id: $id) { name } }")
.variable("id", MISSING.toString())
.execute()
.errors().expect(e -> e.getErrorType() == ErrorType.NOT_FOUND).verify()
.path("product").valueIsNull();
}
}Two assertions worth calling out. The batch loader's purpose is a property invisible in the
response, so assert it directly. And a security test must use a seeded id — refused
by @PreAuthorize and 404 look identical from outside, so a nonexistent id would let the
test pass with the annotation deleted:
verify(productService, times(1)).getMenu(); // as a @SchemaMapping this would be 3
// 200, not 403 - the HTTP request succeeded and the failure is inside the body.
// Asserting on the status code here would pass while the endpoint was wide open.
.andExpect(status().isOk())
.andExpect(jsonPath("$.errors[0].extensions.classification").value("UNAUTHORIZED"))
.andExpect(jsonPath("$.data.deactivateProduct").doesNotExist());GraphQL or REST?
| REST | GraphQL | |
|---|---|---|
| Response shape | server decides | client decides |
| Errors | status code | 200 + errors array |
| Authorization | URL rules and annotations | annotations only |
| Caching | HTTP, for free | hand-rolled — one POST URL is uncacheable |
| Request cost | bounded by the endpoint | bounded by whatever you enforce |
| Contract | OpenAPI, generated after | the schema, written first |
GraphQL earns its keep when several clients need different slices of the same graph — a web app, a mobile app and a partner integration, each over-fetching or making three round trips against a REST API tuned for one of them. The pizza app has exactly that shape. REST stays the better default for a single client or a public API: HTTP caching alone is worth a great deal, and "this URL does this thing" is not worth giving up for a shape problem you do not have. They are not exclusive either, which is the point of this lesson — the pizza API serves both, over one service layer, in under 200 lines of Java plus the schema.
What to take from this
- Call the same services. If GraphQL needs its own service layer, the seam is in the wrong place.
- The schema is a publishing decision, not a transcription of your DTOs.
- Register a decimal scalar.
Floatis a double, and money in a double drifts. @BatchMapping, not@SchemaMapping, the moment a field resolves per-parent.- Map your exceptions, including
ConstraintViolationException, or every failure is an opaqueINTERNAL_ERROR. - One URL means
@PreAuthorizeis the only defence. A forgotten annotation is an open door. - Bound the query depth, and turn off GraphiQL and introspection in production.
Next: server-rendered pages with Thymeleaf — when a document beats any kind of API.