Skip to main content

Class 5: Error Handling

Duration: 85 minutes | Difficulty: Intermediate | Prerequisites: Class 4 completed


What You'll Learn

By the end of this class, you will:

  • Understand how GraphQL errors differ fundamentally from REST errors
  • Tell the difference between validation errors (before execution) and execution errors (during resolver calls)
  • Create custom exception classes for different failure scenarios
  • Map exceptions to structured GraphQL errors with @GraphQlExceptionHandler
  • Add error classifications and extensions for client-side handling
  • Understand GraphQL's partial response model
  • Know when to model an error as typed schema data instead of throwing an exception

How GraphQL Errors Differ from REST

In Class 4 (Github repo here), we added create, update, and delete mutations, and we already met both ways a write can fail: returning a structured response and throwing an exception. This class makes that error handling deliberate and consistent.

In a REST API, errors are communicated through HTTP status codes. A 404 means "not found," a 400 means "bad request," a 500 means "server error." The entire response is either a success or a failure.

GraphQL takes a fundamentally different approach. Because a single GraphQL request can touch many resolvers, some fields might succeed while others fail. Rather than failing the entire request, GraphQL returns both a data field and an errors array. Here a movies query returned its list, but the server hit an error while processing one of the movies:

{
"data": {
"movies": [
...
{
"id": "998",
"title": "The Godfather Part III"
}
]
},
"errors": [
{
"message": "Server error while processing movie with ID 999",
"extensions": {
"classification": "INTERNAL_ERROR"
}
}
]
}

In most cases the HTTP status code is 200 even when there are errors. This surprises many developers coming from REST, but it makes sense when you consider that a query might successfully resolve ten fields and fail on one. A 500 status code would incorrectly suggest that the entire request failed.

The full picture is slightly more nuanced. Under the older application/json response media type (what Spring GraphQL serves by default, and what most clients still expect), execution-phase errors are returned as 200 with an errors array in the body. The newer application/graphql-response+json media type, introduced in the GraphQL-over-HTTP spec, is allowed to use 4xx codes for request errors (malformed JSON, validation failures before execution starts) while still using 200 for execution errors (thrown by a resolver). The practical rule is the same: never trust the HTTP status alone, always check the errors array, and provide rich structured error information so clients can react appropriately.

Validation Errors vs. Execution Errors

Before we start writing exception handlers, it's worth understanding that GraphQL errors come in two fundamentally different flavors. They look similar on the wire, but they happen at different phases of request handling, and clients typically treat them differently.

Validation errors happen before any resolver runs. The server parses the query, validates it against the schema, and if anything is wrong (unknown fields, wrong argument types, invalid variable values, malformed syntax), the request is rejected outright. Crucially, the response has no data field at all, just an errors array.

Try sending this query (with a typo on titl):

query {
movie(id: 1) {
titl
rating
}
}

Response:

{
"errors": [
{
"message": "Validation error (FieldUndefined@[movie/titl]) : Field 'titl' in type 'Movie' is undefined",
"locations": [{ "line": 3, "column": 5 }],
"extensions": {
"classification": "ValidationError"
}
}
]
}

Notice there is no data key in the response, not even "data": null. That's the tell: validation failed, nothing was executed. You'll also see a ValidationError classification rather than the execution-phase classifications like NOT_FOUND or BAD_REQUEST.

Execution errors happen during resolver execution. The query was valid, resolvers started running, and something went wrong inside one of them: a database call failed, a service returned null for a non-null field, or you threw an EntityNotFoundException. The response always has both a data field (possibly with null branches, see null propagation in Class 3) and an errors array.

PhaseWhen It RunsExample CauseResponse Shape
ValidationBefore executionUnknown field, wrong type{ "errors": [...] } (no data key)
ExecutionDuring resolver callsException from a resolver{ "data": {...}, "errors": [...] }
Why Clients Care About The Difference

A validation error means your code is broken: the query the client sent doesn't match the schema anymore. The correct response is "rebuild and redeploy the client," not "retry the request."

An execution error often means the server had a transient problem: the database was slow, the upstream service timed out. Retrying might work.

Sophisticated clients (like Apollo Client with onError links) branch on this distinction: validation errors become hard failures reported to Sentry, while execution errors with a specific classification can trigger automatic retries.

In this class we focus almost entirely on execution errors, because that's where custom exception handling matters. Validation errors are handled automatically by graphql-java and don't need per-exception wiring; they're produced directly from the schema contract.

The Problem with Unhandled Exceptions

Right now, if someone queries a movie that doesn't exist, our controller returns null:

@QueryMapping
public Movie movie(@Argument Long id) {
return movieService.findById(id).orElse(null);
}

Returning null is technically valid - the schema says movie(id: ID!): Movie (nullable return). But it's unhelpful. The client gets null with no explanation of why. Was the ID invalid? Did the database fail? Is the movie temporarily unavailable? The client has no way to distinguish between these scenarios.

What if we throw a raw exception instead? Spring GraphQL catches unhandled exceptions and returns a generic error message: "INTERNAL_ERROR for ...". This is intentional - Spring doesn't expose exception details to clients because they might contain sensitive information like stack traces or SQL queries. So we need a middle ground: custom exceptions that are safe to expose, mapped to structured GraphQL errors.

Step 1: Create Custom Exceptions

Whether a client asks for a missing movie, person, or (later) TV show, the failure is the same shape: a NOT_FOUND error that names which kind of entity was missing. The kind of entity is data, not a reason to write a new class each time, so we model every one of these with a single exception that carries the entity type as a field:

📁 src/main/java/com/graphqlguy/moviedb/exception/EntityNotFoundException.java

package com.graphqlguy.moviedb.exception;

import lombok.Getter;

@Getter
public class EntityNotFoundException extends RuntimeException {

private final String entityType;

public EntityNotFoundException(String entityType, Long id) {
super(entityType + " not found: " + id);
this.entityType = entityType;
}
}

This is the pattern Netflix DGS ships out of the box: a single DgsEntityNotFoundException that its default handler maps to NOT_FOUND, used for every entity rather than one class per type. Ours adds one thing DGS leaves in the message - an entityType field - so the handler in Step 2 can surface it in the error's extensions. The id goes straight into the human-readable message.

Why one exception instead of a MovieNotFoundException, a PersonNotFoundException, and so on? Because the entity name is just data. Nothing in the codebase ever catches a specific not-found type to react differently; the only thing that varies between them is a label we read back out for the client. A class per entity would multiply files and near-identical handlers for zero behavioral gain, and it is not what production GraphQL APIs do - GitHub, for instance, returns one NOT_FOUND type for every entity and names the entity in the error, not in the type. If some entity ever genuinely needs different handling, that is the day to introduce a subtype, not before.

Notice the id is a Long: every entity here is looked up by its numeric database id. A failure like "no user matches this username or token" is deliberately not an EntityNotFoundException. That is a question of identity and access, and it belongs with the authentication and authorization errors from the security chapters (a 401 or 403), not a 404. Keeping this exception strictly about "a row with this id does not exist" is exactly what lets the id stay a clean Long.

In Class 4, we introduced InvalidInputException for service-layer rules (for example, rejecting an updatePerson call that sends an out-of-range birthYear or tries to clear the required name). We still need to map it to a structured GraphQL error here, which we do in Step 2.

We extend RuntimeException rather than Exception because GraphQL resolvers shouldn't declare checked exceptions. The resolver signature is determined by Spring GraphQL's annotations, and checked exceptions would force every resolver to declare throws, cluttering the code without adding value. Runtime exceptions propagate naturally through the resolver chain and are caught by the exception handler.

InvalidInputException includes a field property so the client knows exactly which input field caused the problem. This is much more useful than a generic "validation failed" message - a form UI can highlight the specific field that needs correction.

Step 2: Create the Global Exception Handler

Now we need to tell Spring GraphQL how to convert our exceptions into structured GraphQL errors. This is done with a @ControllerAdvice class containing @GraphQlExceptionHandler methods:

📁 src/main/java/com/graphqlguy/moviedb/exception/GlobalExceptionHandler.java

package com.graphqlguy.moviedb.exception;

import graphql.GraphQLError;
import graphql.GraphqlErrorBuilder;
import graphql.schema.DataFetchingEnvironment;
import lombok.extern.slf4j.Slf4j;
import org.springframework.graphql.data.method.annotation.GraphQlExceptionHandler;
import org.springframework.graphql.execution.ErrorType;
import org.springframework.web.bind.annotation.ControllerAdvice;

import java.util.Map;
import java.util.UUID;

@ControllerAdvice
@Slf4j
public class GlobalExceptionHandler {

@GraphQlExceptionHandler
public GraphQLError handleEntityNotFound(EntityNotFoundException ex, DataFetchingEnvironment env) {
return GraphqlErrorBuilder.newError(env)
.message(ex.getMessage())
.errorType(ErrorType.NOT_FOUND)
.extensions(Map.of("entityType", ex.getEntityType()))
.build();
}

@GraphQlExceptionHandler
public GraphQLError handleInvalidInput(InvalidInputException ex, DataFetchingEnvironment env) {
return GraphqlErrorBuilder.newError(env)
.message(ex.getMessage())
.errorType(ErrorType.BAD_REQUEST)
.extensions(Map.of("field", ex.getField()))
.build();
}

@GraphQlExceptionHandler
public GraphQLError handleUnexpected(Exception ex, DataFetchingEnvironment env) {
String reference = UUID.randomUUID().toString();
log.error("Unhandled exception [ref={}] at path {}",
reference, env.getExecutionStepInfo().getPath(), ex);
return GraphqlErrorBuilder.newError(env)
.message("An unexpected error occurred. Reference: " + reference)
.errorType(ErrorType.INTERNAL_ERROR)
.extensions(Map.of("reference", reference))
.build();
}
}

Let's break down what each piece does:

@ControllerAdvice makes this class a global handler - its methods apply to exceptions thrown from any controller, not just one specific controller. This is important because our exceptions might come from MovieController, PersonController, or any future controller.

@GraphQlExceptionHandler is the GraphQL equivalent of Spring MVC's @ExceptionHandler. It catches exceptions of the specified type and converts them into GraphQLError objects. Spring GraphQL matches exceptions by type - an EntityNotFoundException is caught by handleEntityNotFound, while an InvalidInputException is caught by handleInvalidInput.

GraphqlErrorBuilder.newError(env) creates an error builder pre-configured with the execution path and source location from the DataFetchingEnvironment. This means the error automatically includes which field caused it (e.g., "path": ["movie"]) and where in the query it was defined. Without passing env, the error would lack this context.

ErrorType.NOT_FOUND is one of Spring GraphQL's built-in error classifications. Clients can use extensions.classification to programmatically determine the error type without parsing the message string. Spring provides NOT_FOUND, BAD_REQUEST, UNAUTHORIZED, FORBIDDEN, and INTERNAL_ERROR. These five cover almost everything you will reach for. If you ever want a finer-grained category - say, splitting authorization failures into distinct kinds so you can chart them separately in metrics - you can implement graphql-java's ErrorClassification interface and pass your own value to .errorType(...); whatever string it returns lands in extensions.classification exactly like the built-ins do.

Because clients branch on that string, it is part of your API contract, as much as a field name is. Keep it stable, document the values you emit, and never quietly reword one, or you break every client that switched on the old value. The SCREAMING_SNAKE_CASE you see in NOT_FOUND and BAD_REQUEST is the community convention for exactly this reason: a code is a symbol to match on, not a sentence to read.

One cross-ecosystem gotcha, and the reason it exists: the GraphQL spec never standardized a key for error codes. It reserves extensions "for implementors to add additional information to the error however they see fit," with "no additional restrictions on its contents," so each server chose its own. graphql-java, and therefore Spring, writes the classification under extensions.classification; Apollo Server writes its code under extensions.code, with values like BAD_USER_INPUT and UNAUTHENTICATED. Neither is more official than the other. A polyglot client that reads only one key silently misses the other server's codes, so if your graph spans both, pick one key and normalize to it.

extensions(Map.of(...)) adds custom metadata to the error. The extensions object in a GraphQL error is a free-form map where you can put anything useful for the client. We read entityType off the exception and add it so the client knows what kind of entity wasn't found - that one field is what lets a single handler correctly label a missing Movie, Person, or any entity we add later. We also add field on validation errors so the client can highlight the problematic input field.

@GraphQlExceptionHandler is the high-level door

These annotated methods are convenient sugar over a lower-level mechanism. Under the hood, Spring for GraphQL runs a chain of DataFetcherExceptionResolver beans, and any exception no resolver claims falls through to the framework's default INTERNAL_ERROR. If you ever need to map exceptions thrown from code that isn't a controller, or you prefer to work at that level, you can extend DataFetcherExceptionResolverAdapter and override resolveToSingleError (or resolveToMultipleErrors), which handles the reactive plumbing for you. For everything in this tutorial the @ControllerAdvice approach is the idiomatic choice, and it is what we use throughout.

The Catch-All Handler

The first two handlers each target a specific exception we control. The third, handleUnexpected, is the safety net: because Exception is the supertype of everything, it matches any failure the specific handlers miss - a dropped database connection, a null pointer deep in a service, a third-party client blowing up.

You might reasonably ask why this is needed at all. Doesn't Spring GraphQL already turn unhandled exceptions into INTERNAL_ERROR? It does, and that default is deliberately safe: it returns an opaque message so implementation details never reach the client. We are emphatically not catching Exception to expose more. Notice that handleUnexpected pointedly does not call ex.getMessage(). Echoing the raw message is exactly the leak this whole chapter warns against - that text might carry a SQL fragment or a file path - so we return a fixed, generic sentence instead.

What the catch-all adds is the one thing the framework default can't: a thread to pull. We generate a random reference, log the full exception against it on the server, and hand that same reference back to the client in extensions. When a user reports "I got an error, reference a1b2c3", support can grep the logs and land on the exact stack trace. The client sees nothing sensitive; we see everything.

There is a subtler reason to log explicitly. Spring for GraphQL logs unresolved exceptions at ERROR level but resolved ones at DEBUG, on the theory that once you write a handler you have taken responsibility for the outcome. The moment this catch-all exists, every exception counts as "resolved", and that automatic ERROR-level logging goes quiet. The log.error inside the handler is not redundant with the framework - it is what keeps unexpected failures visible in the logs at all.

One rule governs the catch-all: it must stay the least specific handler and never shadow the others. Spring matches exceptions the way Spring MVC does, picking the closest type in the hierarchy, so an EntityNotFoundException still routes to handleEntityNotFound even though it is also an Exception. Keep this in mind for Class 6, where we add authentication: Spring Security signals "not allowed" by throwing AccessDeniedException, which is also an Exception. We will give it a dedicated handler there precisely so an authorization failure comes back as a clean 403, instead of being lumped in with the generic 500s this catch-all produces.

Step 3: Update the Controller

Now let's use our custom exceptions instead of returning null:

📁 src/main/java/com/graphqlguy/moviedb/movie/MovieController.java - update the movie query:

@QueryMapping
public Movie movie(@Argument Long id) {
return movieService.findById(id)
.orElseThrow(() -> new EntityNotFoundException("Movie", id));
}

This is a significant improvement. Before, querying a non-existent movie returned null with no explanation. Now it throws an EntityNotFoundException tagged "Movie", which our handler converts to a structured error with a clear message, classification, and path.

Also update the MovieService.deleteMovie method to throw an exception instead of returning a failure response:

@Transactional
public DeleteMovieResponse deleteMovie(Long id) {
Movie movie = movieRepository.findById(id)
.orElseThrow(() -> new EntityNotFoundException("Movie", id));
movieRepository.delete(movie);
return new DeleteMovieResponse(true, "Movie deleted successfully", id);
}
Pattern Evolution: DeleteMovieResponse vs. Exceptions

In Class 4, deleteMovie returned DeleteMovieResponse(false, "Movie not found", null) for non-existent movies. Here we've switched to throwing EntityNotFoundException instead, because the two situations call for different tools.

Use exceptions when the situation is truly unexpected and the client likely has a bug. If a client tries to delete a movie they just fetched, and it's gone, something went wrong. An error with NOT_FOUND classification tells the client exactly what happened.

Use response objects when both outcomes are normal. For example, a "does this username exist?" check where both "yes" and "no" are valid, expected answers.

As a rule of thumb: if the client should handle both cases as part of normal flow, use a response object. If one case means something went wrong, throw an exception.

Finally, put the same exception to work for people. In Class 4, PersonService.updatePerson used a placeholder RuntimeException for the missing-person case; swap it for an EntityNotFoundException tagged "Person":

📁 src/main/java/com/graphqlguy/moviedb/person/PersonService.java - update updatePerson:

@Transactional
public Person updatePerson(UpdatePersonInput input) {
Person person = personRepository.findById(input.id())
.orElseThrow(() -> new EntityNotFoundException("Person", input.id()));

validate(input);

applyIfPresent(input.name(), person::setName);
applyIfPresent(input.birthYear(), person::setBirthYear);
applyIfPresent(input.nationality(), person::setNationality);
return personRepository.save(person);
}

The only change from Class 4 is the exception. Now an updatePerson against a missing id produces the same clean NOT_FOUND error as a missing movie, caught by the same handleEntityNotFound method from Step 2 - no per-entity handler required. Add the import: import com.graphqlguy.moviedb.exception.EntityNotFoundException;.

Step 4: Run and Test

Restart your application and open GraphiQL at http://localhost:8080/graphiql.

Query a Non-Existent Movie

query {
movie(id: 999) {
title
}
}

Response:

{
"data": {
"movie": null
},
"errors": [
{
"message": "Movie not found: 999",
"locations": [{ "line": 2, "column": 3 }],
"path": ["movie"],
"extensions": {
"entityType": "Movie",
"classification": "NOT_FOUND"
}
}
]
}

Notice the structure: data.movie is null (because the schema allows a nullable return), and the errors array contains a single error with all the context a client needs. The path tells you which field failed. The classification tells you the category. The entityType tells you what was missing.

Delete a Non-Existent Movie

mutation {
deleteMovie(id: "999") {
success
message
deletedId
}
}

Response:

{
"data": null,
"errors": [
{
"message": "Movie not found: 999",
"path": ["deleteMovie"],
"extensions": {
"entityType": "Movie",
"classification": "NOT_FOUND"
}
}
]
}

Notice that data is null here, not { "deleteMovie": null }. The schema declares deleteMovie: DeleteMovieResponse! (non-null), so when the resolver throws an exception, GraphQL cannot null the field itself; the null propagates up to the nearest nullable ancestor. Since deleteMovie is a root mutation field and the root data object is the only thing above it, the whole data becomes null. This is GraphQL's null propagation in action: the strictness you opt into with ! is exactly what causes the cascade when something fails. Note that deletedId would be null in any failure response because there is no ID to report when no deletion occurred.

Understanding Partial Responses

GraphQL's error model really shines when you query multiple things at once. Imagine a client that queries two movies:

query {
movie1: movie(id: 1) {
title
}
movie2: movie(id: 999) {
title
}
}

Response:

{
"data": {
"movie1": {
"title": "The Shawshank Redemption"
},
"movie2": null
},
"errors": [
{
"message": "Movie not found: 999",
"path": ["movie2"],
"extensions": {
"classification": "NOT_FOUND"
}
}
]
}

The movie1: and movie2: labels are aliases. A query can't ask for the same field twice under the same name, so an alias lets you call movie more than once and give each result its own key in the response. (More generally, an alias renames any field's response key, which is handy when a client wants a friendlier name than the schema field.)

movie1 succeeded and returned data. movie2 failed and is null with an error. A REST API would either fail the entire request or need complex partial-response logic. GraphQL handles this naturally - each field resolves independently.

Project Structure So Far

moviedb/
├── src/main/java/com/graphqlguy/moviedb/
│ ├── MoviedbApplication.java
│ ├── config/
│ │ └── DataInitializer.java
│ ├── exception/ ← NEW
│ │ ├── GlobalExceptionHandler.java ← NEW
│ │ ├── EntityNotFoundException.java ← NEW
│ │ └── InvalidInputException.java
│ ├── movie/
│ │ ├── Movie.java
│ │ ├── MovieCast.java
│ │ ├── MovieCastRepository.java
│ │ ├── MovieController.java
│ │ ├── MovieRepository.java
│ │ └── MovieService.java
│ ├── person/
│ │ ├── Person.java
│ │ ├── PersonController.java
│ │ ├── PersonRepository.java
│ │ ├── PersonService.java
│ │ ├── CreatePersonInput.java
│ │ └── UpdatePersonInput.java
│ └── shared/
│ ├── DeleteMovieResponse.java
│ ├── DeletePersonResponse.java
│ └── Genre.java
└── ...

Beyond Throwing Exceptions: Two More Ways to Model Errors

The exception-and-handler pattern you just built is the workhorse: something goes wrong, you throw an exception, and a handler turns it into a classified entry in the errors array. It cannot express every failure, though. Two other techniques cover the cases it can't reach, and we wire both into the running app below so you can see them working. Each stays situational: the exception-and-handler pattern remains the default, and you reach for these only when a specific situation calls for one.

Returning Data and an Error Together

The partial responses in Step 4 came for free: each field in a query resolves on its own, so a thrown EntityNotFoundException nulled just that one field while its siblings were untouched. Your resolvers did nothing special to get that.

DataFetcherResult covers a different, narrower case: when a single resolver must return a value and an error at the same time. Throwing an exception can't do it (it nulls the whole field), and returning a plain value can't either (there is nowhere to attach the error). A bulk lookup is the classic example, so let's add one: a moviesByIds query that takes a list of ids and returns the movies that exist, attaching a single error that names any ids it could not find rather than nulling the whole list over one bad id.

This is a real schema change - a new field on the Query type:

📁 src/main/resources/graphql/schema.graphqls - add to type Query:

type Query {
# ...existing movie and person queries...
moviesByIds(ids: [ID!]!): [Movie!]!
}

Give the service a thin method over Spring Data's built-in findAllById:

📁 src/main/java/com/graphqlguy/moviedb/movie/MovieService.java - add:

public List<Movie> findAllById(List<Long> ids) {
return movieRepository.findAllById(ids);
}

Now add the resolver. It returns a DataFetcherResult<List<Movie>> rather than a plain List<Movie>, which is what lets it carry the found movies and an error in a single response:

📁 src/main/java/com/graphqlguy/moviedb/movie/MovieController.java - add the moviesByIds query:

@QueryMapping
public DataFetcherResult<List<Movie>> moviesByIds(@Argument List<Long> ids,
DataFetchingEnvironment env) {
List<Movie> found = movieService.findAllById(ids);
Set<Long> foundIds = found.stream().map(Movie::getId).collect(Collectors.toSet());
List<Long> missing = ids.stream().filter(id -> !foundIds.contains(id)).toList();

var result = DataFetcherResult.<List<Movie>>newResult().data(found);
if (!missing.isEmpty()) {
result.error(GraphqlErrorBuilder.newError(env)
.message("Movies not found: " + missing)
.errorType(ErrorType.NOT_FOUND)
.build());
}
return result.build();
}

The new imports are graphql.execution.DataFetcherResult, graphql.schema.DataFetchingEnvironment, graphql.GraphqlErrorBuilder, and org.springframework.graphql.execution.ErrorType, plus java.util.Set and java.util.stream.Collectors.

Restart the application and query a mix of real and missing ids in GraphiQL:

query {
moviesByIds(ids: ["1", "2", "999"]) {
id
title
}
}

The movies that exist come back in data, and the one id that doesn't rides alongside them in errors - both for the same field:

{
"data": {
"moviesByIds": [
{ "id": "1", "title": "The Shawshank Redemption" },
{ "id": "2", "title": "The Godfather" }
]
},
"errors": [
{
"message": "Movies not found: [999]",
"path": ["moviesByIds"],
"extensions": {
"classification": "NOT_FOUND"
}
}
]
}

That is the contrast with Step 4's movie1/movie2, where the partial-ness was across separate fields; here it is within one field. Most resolvers never need it - you throw an exception and let field resolution produce the partial response for you. Reach for DataFetcherResult only when one field must carry partial data and an error at once.

Modeling Expected Errors in the Schema

Some failures are a normal, expected part of a feature's flow, and the client is going to branch on them rather than treat them as faults. The top-level errors array is an awkward home for those: its entries are untyped (the client parses a message string or digs through extensions), they sit outside data, and they are easy to overlook in a partial response.

You have already built the seed of the alternative. In Class 4, deletePerson does not throw an exception when it can't remove someone who is still cast in a movie - it returns DeletePersonResponse(false, "Person is linked to a movie", null), because being linked to a movie is an expected state the client should handle, not a crash. That response object is "errors as data" in spirit: the outcome rides inside data where the client can read it.

The weak spot is that free-text message. It forces the client to string-match "Person is linked to a movie", which breaks the moment you reword the sentence or translate it. Let's evolve DeletePersonResponse to make the failure typed: keep success as a quick boolean flag, drop the free-text message, and add an error field whose type is an enum of the expected failures the client branches on.

Start with the schema:

📁 src/main/resources/graphql/schema.graphqls - replace type DeletePersonResponse:

type DeletePersonResponse {
success: Boolean!
deletedId: ID
error: DeletePersonError
}

enum DeletePersonError {
LINKED_TO_MOVIE
}

success stays because a single boolean is the fastest thing for a client to check. message is gone: the new error field carries the same "why" in a form the client can switch on, so the human-readable sentence was redundant. error is nullable - it holds null on success and names the reason on a failure the client is expected to handle. The enum has a single member today; it is a type rather than a boolean precisely so new expected outcomes can join it later without breaking clients that already branch on it.

Mirror the enum in Java. It is a plain enum, living in the same shared package as the response:

📁 src/main/java/com/graphqlguy/moviedb/shared/DeletePersonError.java (new file):

package com.graphqlguy.moviedb.shared;

public enum DeletePersonError {
LINKED_TO_MOVIE
}

graphql-java maps a GraphQL enum value to the Java enum constant of the same name, so returning DeletePersonError.LINKED_TO_MOVIE serializes to the string "LINKED_TO_MOVIE" on the wire with no extra wiring.

Update the record to carry the typed error in place of the message:

📁 src/main/java/com/graphqlguy/moviedb/shared/DeletePersonResponse.java:

package com.graphqlguy.moviedb.shared;

public record DeletePersonResponse(boolean success, Long deletedId, DeletePersonError error) {}

Finally, update the service. A missing person is a caller bug, so - exactly like deleteMovie in Step 3 and the updatePerson you already changed - it throws EntityNotFoundException rather than returning a response. The one genuinely expected outcome, a person still linked to a movie, is what comes back as typed data:

📁 src/main/java/com/graphqlguy/moviedb/person/PersonService.java - update deletePerson:

@Transactional
public DeletePersonResponse deletePerson(Long id) {
Person person = personRepository.findById(id)
.orElseThrow(() -> new EntityNotFoundException("Person", id));
if (movieRepository.existsByDirectorsContaining(person)
|| movieCastRepository.existsByPerson(person)) {
return new DeletePersonResponse(false, null, DeletePersonError.LINKED_TO_MOVIE);
}
personRepository.deleteById(id);
return new DeletePersonResponse(true, id, null);
}

Add the import import com.graphqlguy.moviedb.shared.DeletePersonError;. (EntityNotFoundException is already imported from the updatePerson change in Step 3.)

A blocked delete now comes back fully typed. Frank Darabont (id 1) directs The Shawshank Redemption, so removing him is refused, and the client switches on a known value instead of matching a sentence:

mutation {
deletePerson(id: "1") {
success
deletedId
error
}
}
{ "data": { "deletePerson": { "success": false, "deletedId": null, "error": "LINKED_TO_MOVIE" } } }

Notice this sitting beside the deleteMovie refactor from Step 3. Both treat a missing entity as a caller bug and throw EntityNotFoundException - deleteMovie for a missing movie, deletePerson for a missing person. Where they differ is the one outcome deletePerson treats as expected: a person still linked to a movie, which it returns as typed data instead of an error. One class, two kinds of failure - a caller bug becomes a thrown exception, an expected outcome becomes typed data - which is exactly what the decision rule below is about.

A single error field reports one failure at a time. When a single write can fail in several ways at once - a createPerson that sends a future birthYear and clears the required name - the convention is to wrap the result in a payload type with a plural userErrors array, where each entry names its own input path. Popularized by Shopify's Admin API and the Relay mutation guidelines, it looks like this:

type CreatePersonPayload {   # Relay's "...Payload" name, not our "...Response"
person: Person
userErrors: [UserError!]!
}

type UserError {
field: [String!] # path to the offending input, e.g. ["input", "birthYear"]
message: String!
code: PersonErrorCode # a stable, typed code, like the enum above
}

A form UI can then highlight every bad field in one round trip instead of discovering them one rejection at a time. None of our mutations need this yet - each fails one way at a time - so treat this as an illustration, not a type to add to our schema. The names come straight from the pattern's origins: Relay calls the wrapper a ...Payload, Shopify calls the array userErrors. That is deliberately not our house style - our own write types stay ...Input and ...Response - so if you ever reach for this, keep our names and borrow only the shape: the plural userErrors array. Read the options as a spectrum: a single error field when just one expected failure is possible, a userErrors array when several can happen at once, and - the third point on the line - a union.

For a field that is cleanly one of a result or a typed error - with no extra payload fields to carry alongside - a union expresses the same idea directly, and the client branches with inline fragments. We build that union machinery, and show it applied to expected errors, in Class 12.

The cost is real and worth naming. Beyond a slightly larger schema, an error you move into data is invisible to everything that watches the top-level errors array: the Apollo onError links, the Sentry reporting, and the classification-driven retries from the tip earlier in this chapter never see it, and the catch-all's reference-id trail does not cover it. Every client must branch on the typed field or silently miss the failure. That is precisely why the line is worth drawing carefully. The decision rule is the one from the DeleteMovieResponse box above, stated more sharply: if the client should handle the outcome as part of normal flow, model it in the schema as data; if it means something genuinely went wrong, throw an exception and let it become a classified GraphQL error. A person(id) lookup for an id that never existed stays an exception, because that is a bug in the caller. "This person is still cast in a movie, so I won't delete them" becomes schema data, because handling that case is the feature.

Exercises

Exercise 1: Add a Service-Layer Validation Rule

Add a service-layer rule to createPerson that rejects birthYear values in the future (i.e., birthYear > Year.now().getValue()). Throw an InvalidInputException with the field name "birthYear".

Why is this a service-layer rule and not a Bean validation rule? Bean validation annotations accept literal constants. @Max(2026) would work for one year only; the year after, you would need a recompile. The actual constraint depends on Year.now() at request time, which annotations cannot reach.

Solution

PersonService.java - add at the beginning of createPerson:

if (input.birthYear() != null && input.birthYear() > Year.now().getValue()) {
throw new InvalidInputException("birthYear", "Birth year cannot be in the future");
}

Test it:

mutation {
createPerson(input: {
name: "Future Person"
birthYear: 3000
}) { id }
}

The response will include an error with "classification": "BAD_REQUEST" and "field": "birthYear".

Exercise 2: Multiple Errors

What happens if you create a query that causes errors in multiple fields? Try using aliases to query both an existing and non-existing movie. Observe how GraphQL handles partial success.

Exercise 3: Unhandled Exceptions

Try throwing a plain RuntimeException("Something went wrong") from a resolver (temporarily). It falls through to the handleUnexpected catch-all: the client gets a generic INTERNAL_ERROR carrying a reference id but no trace of the words "Something went wrong", while the full exception is logged on the server against that same reference. Confirm both halves - find the reference in the response, then locate it in the application logs. The detail stays server-side, which is exactly the point.

Common Issues

Issue: Exception details not showing

Error: GraphQL returns "INTERNAL_ERROR" instead of your custom message Solution: Your exception is falling through to the handleUnexpected catch-all instead of a dedicated handler. Because that handler matches Exception, anything without its own more-specific @GraphQlExceptionHandler method lands there and gets the generic message. Add a handler for that exception type, and make sure the handler class has @ControllerAdvice.

Issue: Error path is wrong

Error: The path in the error doesn't match the field Solution: Make sure you're passing the DataFetchingEnvironment to GraphqlErrorBuilder.newError(env). Without it, the builder can't determine the path.

Summary

In this class, you learned:

  • GraphQL errors are not HTTP errors - under the default application/json media type a well-formed operation returns HTTP 200 even when there are errors (only the newer application/graphql-response+json uses 4xx, and only for pre-execution request errors), so check the errors array rather than the status code; errors travel there alongside any successful data
  • Validation errors vs. execution errors - validation errors happen before any resolver runs and produce a response with no data key; execution errors happen during resolver calls and produce data + errors. Clients branch retry logic on this distinction
  • Custom exceptions should be runtime exceptions that carry meaningful, client-safe information
  • @GraphQlExceptionHandler converts exceptions into structured GraphQL errors with message, path, classification, and extensions
  • A catch-all Exception handler logs the full failure server-side against a reference id and returns a generic, detail-free message - observability without leaking internals, and it must stay the least-specific handler so it never shadows the others
  • Error classifications (NOT_FOUND, BAD_REQUEST, etc.) let clients programmatically distinguish error types
  • Extensions are free-form metadata on errors - use them for field names, entity types, or any context the client needs
  • Partial responses are GraphQL's superpower - one field failing doesn't kill the entire request
  • Not every error is a thrown exception - expected outcomes can be modeled as typed schema data (we evolved DeletePersonResponse to carry a typed error enum), and DataFetcherResult lets one resolver return partial data alongside an error (as the new moviesByIds query does)

Further Reading

The two-channel model in this chapter - throw an exception for the exceptional, model the expected as typed data - is not one framework's house style. It is the convention the whole GraphQL ecosystem converges on, and these are the primary sources behind it:

What's Next?

In Class 6: Security & Authentication, we'll add:

  • User registration and login with JWT tokens
  • Role-based access control (USER vs. ADMIN)
  • Protecting mutations with @PreAuthorize
  • How authentication works in a GraphQL context