Class 6: Error Handling
Duration: 30 minutes Difficulty: Intermediate Prerequisites: Completed Classes 1-5
What You'll Learn
By the end of this class, you will:
- Understand GraphQL's error response structure
- Create custom exceptions for different error types
- Implement validation with meaningful messages
- Handle partial errors (some fields succeed, others fail)
- Classify errors for clients
GraphQL Error Response Structure
Unlike REST, GraphQL can return both data AND errors:
{
"data": {
"movie": {
"title": "The Dark Knight",
"director": null
}
},
"errors": [
{
"message": "Director not found",
"path": ["movie", "director"],
"extensions": {
"classification": "NOT_FOUND"
}
}
]
}
This is powerful! The client gets the data that succeeded and knows exactly what failed.
┌─────────────────────────────────────────────────────────────────────┐
│ GRAPHQL ERROR ANATOMY │
├─────────────────────────────────────────────────────────────────────┤
│ │
│ { │
│ "message": "Human-readable error message", │
│ "locations": [{ "line": 2, "column": 3 }], // Where in query │
│ "path": ["movie", "director"], // Which field │
│ "extensions": { // Custom metadata │
│ "classification": "NOT_FOUND", │
│ "code": "MOVIE_DIRECTOR_404", │
│ "timestamp": "2024-01-15T10:30:00Z" │
│ } │
│ } │
│ │
└─────────────────────────────────────────────────────────────────────┘
Step 1: Create Custom Exceptions
Let's create a hierarchy of domain-specific exceptions:
📁 src/main/java/com/example/moviedb/exception/GraphQLException.java
package com.example.moviedb.exception;
import graphql.ErrorClassification;
public abstract class GraphQLException extends RuntimeException {
public GraphQLException(String message) {
super(message);
}
public GraphQLException(String message, Throwable cause) {
super(message, cause);
}
public abstract ErrorClassification getErrorType();
}
📁 src/main/java/com/example/moviedb/exception/NotFoundException.java
package com.example.moviedb.exception;
import graphql.ErrorClassification;
public class NotFoundException extends GraphQLException {
private final String resourceType;
private final String resourceId;
public NotFoundException(String resourceType, String resourceId) {
super(String.format("%s not found with id: %s", resourceType, resourceId));
this.resourceType = resourceType;
this.resourceId = resourceId;
}
public String getResourceType() {
return resourceType;
}
public String getResourceId() {
return resourceId;
}
@Override
public ErrorClassification getErrorType() {
return CustomErrorType.NOT_FOUND;
}
}
📁 src/main/java/com/example/moviedb/exception/ValidationException.java
package com.example.moviedb.exception;
import graphql.ErrorClassification;
import java.util.Map;
public class ValidationException extends GraphQLException {
private final Map<String, String> violations;
public ValidationException(String message) {
super(message);
this.violations = Map.of();
}
public ValidationException(String message, Map<String, String> violations) {
super(message);
this.violations = violations;
}
public Map<String, String> getViolations() {
return violations;
}
@Override
public ErrorClassification getErrorType() {
return CustomErrorType.VALIDATION_ERROR;
}
}
📁 src/main/java/com/example/moviedb/exception/ForbiddenException.java
package com.example.moviedb.exception;
import graphql.ErrorClassification;
public class ForbiddenException extends GraphQLException {
public ForbiddenException(String message) {
super(message);
}
@Override
public ErrorClassification getErrorType() {
return CustomErrorType.FORBIDDEN;
}
}
📁 src/main/java/com/example/moviedb/exception/CustomErrorType.java
package com.example.moviedb.exception;
import graphql.ErrorClassification;
public enum CustomErrorType implements ErrorClassification {
NOT_FOUND,
VALIDATION_ERROR,
FORBIDDEN,
INTERNAL_ERROR,
BAD_REQUEST;
@Override
public Object toSpecification(graphql.GraphQLError error) {
return this.name();
}
}
Step 2: Create an Exception Resolver
Spring GraphQL uses DataFetcherExceptionResolver to convert exceptions to GraphQL errors:
📁 src/main/java/com/example/moviedb/exception/GraphQLExceptionResolver.java
package com.example.moviedb.exception;
import graphql.GraphQLError;
import graphql.GraphqlErrorBuilder;
import graphql.schema.DataFetchingEnvironment;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.graphql.execution.DataFetcherExceptionResolverAdapter;
import org.springframework.graphql.execution.ErrorType;
import org.springframework.stereotype.Component;
import java.util.HashMap;
import java.util.Map;
@Component
public class GraphQLExceptionResolver extends DataFetcherExceptionResolverAdapter {
private static final Logger log = LoggerFactory.getLogger(GraphQLExceptionResolver.class);
@Override
protected GraphQLError resolveToSingleError(Throwable ex, DataFetchingEnvironment env) {
// Handle our custom exceptions
if (ex instanceof NotFoundException nfe) {
return GraphqlErrorBuilder.newError(env)
.message(nfe.getMessage())
.errorType(nfe.getErrorType())
.extensions(Map.of(
"resourceType", nfe.getResourceType(),
"resourceId", nfe.getResourceId()
))
.build();
}
if (ex instanceof ValidationException ve) {
Map<String, Object> extensions = new HashMap<>();
extensions.put("violations", ve.getViolations());
return GraphqlErrorBuilder.newError(env)
.message(ve.getMessage())
.errorType(ve.getErrorType())
.extensions(extensions)
.build();
}
if (ex instanceof ForbiddenException fe) {
return GraphqlErrorBuilder.newError(env)
.message(fe.getMessage())
.errorType(fe.getErrorType())
.build();
}
if (ex instanceof IllegalArgumentException iae) {
return GraphqlErrorBuilder.newError(env)
.message(iae.getMessage())
.errorType(ErrorType.BAD_REQUEST)
.build();
}
// Log unexpected exceptions
log.error("Unexpected error during GraphQL execution", ex);
// Return generic error for unexpected exceptions (don't expose details)
return GraphqlErrorBuilder.newError(env)
.message("An unexpected error occurred")
.errorType(ErrorType.INTERNAL_ERROR)
.build();
}
}
Step 3: Update Controllers to Use Exceptions
📁 Update src/main/java/com/example/moviedb/controller/MovieController.java:
@QueryMapping
public Movie movie(@Argument String id) {
return movieRepository.findById(id)
.orElseThrow(() -> new NotFoundException("Movie", id));
}
@QueryMapping
public Director director(@Argument String id) {
return directorRepository.findById(id)
.orElseThrow(() -> new NotFoundException("Director", id));
}
@QueryMapping
public Actor actor(@Argument String id) {
return actorRepository.findById(id)
.orElseThrow(() -> new NotFoundException("Actor", id));
}
📁 Update src/main/java/com/example/moviedb/controller/MutationController.java:
package com.example.moviedb.controller;
import com.example.moviedb.dto.*;
import com.example.moviedb.exception.NotFoundException;
import com.example.moviedb.exception.ValidationException;
import com.example.moviedb.model.Actor;
import com.example.moviedb.model.Director;
import com.example.moviedb.model.Movie;
import com.example.moviedb.repository.ActorRepository;
import com.example.moviedb.repository.DirectorRepository;
import com.example.moviedb.repository.MovieRepository;
import org.springframework.graphql.data.method.annotation.Argument;
import org.springframework.graphql.data.method.annotation.MutationMapping;
import org.springframework.stereotype.Controller;
import java.time.Year;
import java.util.*;
@Controller
public class MutationController {
private final MovieRepository movieRepository;
private final ActorRepository actorRepository;
private final DirectorRepository directorRepository;
public MutationController(MovieRepository movieRepository,
ActorRepository actorRepository,
DirectorRepository directorRepository) {
this.movieRepository = movieRepository;
this.actorRepository = actorRepository;
this.directorRepository = directorRepository;
}
@MutationMapping
public Movie createMovie(@Argument CreateMovieInput input) {
// Validate input
validateCreateMovieInput(input);
// Verify director exists
directorRepository.findById(input.getDirectorId())
.orElseThrow(() -> new NotFoundException("Director", input.getDirectorId()));
// Verify actors exist
if (input.getActorIds() != null) {
for (String actorId : input.getActorIds()) {
actorRepository.findById(actorId)
.orElseThrow(() -> new NotFoundException("Actor", actorId));
}
}
Movie movie = new Movie(
null,
input.getTitle(),
input.getReleaseYear(),
input.getGenre(),
input.getRating(),
input.getRuntime(),
input.getPlot(),
false,
input.getDirectorId(),
input.getActorIds() != null ? input.getActorIds() : List.of()
);
return movieRepository.save(movie);
}
private void validateCreateMovieInput(CreateMovieInput input) {
Map<String, String> violations = new HashMap<>();
// Title validation
if (input.getTitle() == null || input.getTitle().trim().isEmpty()) {
violations.put("title", "Title is required");
} else if (input.getTitle().length() > 200) {
violations.put("title", "Title must be 200 characters or less");
}
// Year validation
int currentYear = Year.now().getValue();
if (input.getReleaseYear() < 1888) {
violations.put("releaseYear", "Release year must be 1888 or later");
} else if (input.getReleaseYear() > currentYear + 5) {
violations.put("releaseYear", "Release year cannot be more than 5 years in the future");
}
// Rating validation
if (input.getRating() != null) {
if (input.getRating() < 0 || input.getRating() > 10) {
violations.put("rating", "Rating must be between 0 and 10");
}
}
// Runtime validation
if (input.getRuntime() != null) {
if (input.getRuntime() < 1) {
violations.put("runtime", "Runtime must be at least 1 minute");
} else if (input.getRuntime() > 1000) {
violations.put("runtime", "Runtime must be 1000 minutes or less");
}
}
if (!violations.isEmpty()) {
throw new ValidationException("Invalid movie data", violations);
}
}
@MutationMapping
public Movie updateMovie(@Argument String id, @Argument UpdateMovieInput input) {
Movie existing = movieRepository.findById(id)
.orElseThrow(() -> new NotFoundException("Movie", id));
// Validate update input
if (input.getRating() != null && (input.getRating() < 0 || input.getRating() > 10)) {
throw new ValidationException("Rating must be between 0 and 10");
}
Movie updated = new Movie(
existing.getId(),
input.getTitle() != null ? input.getTitle() : existing.getTitle(),
input.getReleaseYear() != null ? input.getReleaseYear() : existing.getReleaseYear(),
input.getGenre() != null ? input.getGenre() : existing.getGenre(),
input.getRating() != null ? input.getRating() : existing.getRating(),
input.getRuntime() != null ? input.getRuntime() : existing.getRuntime(),
input.getPlot() != null ? input.getPlot() : existing.getPlot(),
input.getInTheaters() != null ? input.getInTheaters() : existing.isInTheaters(),
existing.getDirectorId(),
existing.getActorIds()
);
return movieRepository.save(updated);
}
@MutationMapping
public DeleteResponse deleteMovie(@Argument String id) {
// Check if exists first
movieRepository.findById(id)
.orElseThrow(() -> new NotFoundException("Movie", id));
boolean deleted = movieRepository.delete(id);
return DeleteResponse.success(id);
}
@MutationMapping
public Actor createActor(@Argument CreateActorInput input) {
if (input.getName() == null || input.getName().trim().isEmpty()) {
throw new ValidationException("Actor name is required");
}
Actor actor = new Actor(
null,
input.getName(),
input.getBirthYear(),
input.getNationality()
);
return actorRepository.save(actor);
}
@MutationMapping
public Director createDirector(@Argument CreateDirectorInput input) {
if (input.getName() == null || input.getName().trim().isEmpty()) {
throw new ValidationException("Director name is required");
}
Director director = new Director(
null,
input.getName(),
input.getBirthYear(),
input.getNationality()
);
return directorRepository.save(director);
}
@MutationMapping
public Movie addActorToMovie(@Argument String movieId, @Argument String actorId) {
Movie movie = movieRepository.findById(movieId)
.orElseThrow(() -> new NotFoundException("Movie", movieId));
actorRepository.findById(actorId)
.orElseThrow(() -> new NotFoundException("Actor", actorId));
if (movie.getActorIds().contains(actorId)) {
throw new ValidationException("Actor is already in this movie");
}
List<String> actorIds = new ArrayList<>(movie.getActorIds());
actorIds.add(actorId);
Movie updated = new Movie(
movie.getId(), movie.getTitle(), movie.getReleaseYear(),
movie.getGenre(), movie.getRating(), movie.getRuntime(),
movie.getPlot(), movie.isInTheaters(), movie.getDirectorId(),
actorIds
);
return movieRepository.save(updated);
}
@MutationMapping
public Movie removeActorFromMovie(@Argument String movieId, @Argument String actorId) {
Movie movie = movieRepository.findById(movieId)
.orElseThrow(() -> new NotFoundException("Movie", movieId));
if (!movie.getActorIds().contains(actorId)) {
throw new ValidationException("Actor is not in this movie");
}
List<String> actorIds = new ArrayList<>(movie.getActorIds());
actorIds.remove(actorId);
Movie updated = new Movie(
movie.getId(), movie.getTitle(), movie.getReleaseYear(),
movie.getGenre(), movie.getRating(), movie.getRuntime(),
movie.getPlot(), movie.isInTheaters(), movie.getDirectorId(),
actorIds
);
return movieRepository.save(updated);
}
}
Step 4: Test Error Handling
Restart your application and try these queries:
Test NotFoundException
query {
movie(id: "9999") {
title
}
}
Response:
{
"data": {
"movie": null
},
"errors": [
{
"message": "Movie not found with id: 9999",
"path": ["movie"],
"extensions": {
"classification": "NOT_FOUND",
"resourceType": "Movie",
"resourceId": "9999"
}
}
]
}
Test ValidationException
mutation {
createMovie(input: {
title: ""
releaseYear: 1800
genre: ACTION
rating: 15.0
directorId: "1"
}) {
id
}
}
Response:
{
"data": {
"createMovie": null
},
"errors": [
{
"message": "Invalid movie data",
"path": ["createMovie"],
"extensions": {
"classification": "VALIDATION_ERROR",
"violations": {
"title": "Title is required",
"releaseYear": "Release year must be 1888 or later",
"rating": "Rating must be between 0 and 10"
}
}
}
]
}
Partial Errors
GraphQL can return partial data when some fields fail:
query {
movie(id: "1") {
title
director {
name
}
}
movie2: movie(id: "9999") {
title
}
}
If movie 9999 doesn't exist:
{
"data": {
"movie": {
"title": "The Shawshank Redemption",
"director": {
"name": "Frank Darabont"
}
},
"movie2": null
},
"errors": [
{
"message": "Movie not found with id: 9999",
"path": ["movie2"]
}
]
}
The client gets data for movie 1 and knows movie2 failed!
Handling Errors in Nested Fields
What happens when a nested field fails?
📁 Add error simulation to a field resolver:
@SchemaMapping(typeName = "Movie", field = "externalRating")
public Double externalRating(Movie movie) {
// Simulate external API call that sometimes fails
if (Math.random() > 0.5) {
throw new RuntimeException("External rating service unavailable");
}
return movie.getRating() * 0.9;
}
Add to schema:
type Movie {
# ... existing fields
externalRating: Float # Can be null - graceful degradation
}
Query:
query {
movies {
title
rating
externalRating
}
}
Some movies will have externalRating: null with an error, others will succeed.
Error Classification Best Practices
┌─────────────────────────────────────────────────────────────────────┐
│ ERROR CLASSIFICATION GUIDE │
├─────────────────────────────────────────────────────────────────────┤
│ │
│ Classification │ When to Use │
│ ───────────────────────────────────────────────────────────────── │
│ NOT_FOUND │ Resource doesn't exist │
│ VALIDATION_ERROR │ Input failed validation rules │
│ FORBIDDEN │ User lacks permission │
│ UNAUTHORIZED │ User not authenticated │
│ BAD_REQUEST │ Malformed request or invalid arguments │
│ INTERNAL_ERROR │ Unexpected server error (don't expose details) │
│ │
│ Client Handling: │
│ ───────────────────────────────────────────────────────────────── │
│ NOT_FOUND │ Show "not found" message │
│ VALIDATION_ERROR │ Highlight invalid fields │
│ FORBIDDEN │ Show "access denied" message │
│ UNAUTHORIZED │ Redirect to login │
│ BAD_REQUEST │ Show error message │
│ INTERNAL_ERROR │ Show generic error, retry option │
│ │
└─────────────────────────────────────────────────────────────────────┘
Adding Error Codes
For programmatic error handling, add error codes:
public class NotFoundException extends GraphQLException {
// ... existing code
public String getErrorCode() {
return String.format("%s_NOT_FOUND", resourceType.toUpperCase());
}
}
Update resolver:
if (ex instanceof NotFoundException nfe) {
return GraphqlErrorBuilder.newError(env)
.message(nfe.getMessage())
.errorType(nfe.getErrorType())
.extensions(Map.of(
"code", nfe.getErrorCode(), // e.g., "MOVIE_NOT_FOUND"
"resourceType", nfe.getResourceType(),
"resourceId", nfe.getResourceId()
))
.build();
}
Exercises
Exercise 1: Add ConflictException
Create an exception for conflicts (e.g., trying to add an actor that's already in a movie):
public class ConflictException extends GraphQLException {
// classification: CONFLICT
}
Exercise 2: Rate Limiting Error
Create a RateLimitedException with a retryAfterSeconds field in extensions.
Exercise 3: Multiple Errors
Update validateCreateMovieInput to return a single error with all violations listed, not throw on the first error.
Summary
In this class, you learned:
✅ GraphQL's error response structure with data + errors
✅ Creating custom domain exceptions
✅ Implementing DataFetcherExceptionResolverAdapter
✅ Adding error classifications and extensions
✅ Handling partial errors gracefully
✅ Input validation with detailed error messages
What's Next?
In Class 7: Subscriptions, we'll learn:
- Real-time updates with GraphQL Subscriptions
- WebSocket configuration
- Implementing subscription resolvers
- Testing real-time functionality
Your API is about to become real-time!