Skip to main content

Error Handling in Spring GraphQL - From Chaos to Clarity

· 6 min read
GraphQL Guy

Error Handling

Errors happen. The question is: how do you communicate them to clients? Learn to transform cryptic exceptions into actionable GraphQL errors.

The Problem with Default Error Handling

When something goes wrong in your Spring GraphQL application, the default behavior isn't ideal:

@QueryMapping
public Book bookById(@Argument String id) {
return bookRepository.findById(id)
.orElseThrow(() -> new RuntimeException("Book not found"));
}

Default response:

{
"errors": [
{
"message": "INTERNAL_ERROR for 8d3f2...",
"locations": [{"line": 2, "column": 3}],
"path": ["bookById"],
"extensions": {
"classification": "INTERNAL_ERROR"
}
}
],
"data": {
"bookById": null
}
}

Problems:

  • Generic "INTERNAL_ERROR" tells clients nothing useful
  • Original message is hidden for security reasons
  • No structured error information

Let's fix this.

GraphQL Error Anatomy

A GraphQL error has this structure:

{
"message": "Human-readable error description",
"locations": [{"line": 2, "column": 3}],
"path": ["bookById"],
"extensions": {
"classification": "NOT_FOUND",
"code": "BOOK_NOT_FOUND",
"timestamp": "2024-01-29T10:30:00Z"
}
}
FieldPurpose
messageHuman-readable description
locationsWhere in the query the error occurred
pathWhich field failed
extensionsCustom metadata (error codes, timestamps, etc.)

Creating Custom Exception Classes

First, create a hierarchy of domain exceptions:

public abstract class GraphQLException extends RuntimeException {

private final String errorCode;
private final ErrorClassification classification;

protected GraphQLException(String message, String errorCode,
ErrorClassification classification) {
super(message);
this.errorCode = errorCode;
this.classification = classification;
}

public String getErrorCode() {
return errorCode;
}

public ErrorClassification getClassification() {
return classification;
}
}
public class BookNotFoundException extends GraphQLException {

private final String bookId;

public BookNotFoundException(String bookId) {
super(
String.format("Book with id '%s' not found", bookId),
"BOOK_NOT_FOUND",
ErrorClassification.NOT_FOUND
);
this.bookId = bookId;
}

public String getBookId() {
return bookId;
}
}
public class ValidationException extends GraphQLException {

private final List<FieldError> fieldErrors;

public ValidationException(List<FieldError> fieldErrors) {
super(
"Validation failed",
"VALIDATION_ERROR",
ErrorClassification.BAD_REQUEST
);
this.fieldErrors = fieldErrors;
}

public List<FieldError> getFieldErrors() {
return fieldErrors;
}

public record FieldError(String field, String message) {}
}
public class AuthorizationException extends GraphQLException {

public AuthorizationException(String resource) {
super(
String.format("Not authorized to access %s", resource),
"UNAUTHORIZED",
ErrorClassification.UNAUTHORIZED
);
}
}

Custom Error Classifications

Define your own classification enum:

public enum ErrorClassification implements graphql.ErrorClassification {
NOT_FOUND,
BAD_REQUEST,
UNAUTHORIZED,
FORBIDDEN,
CONFLICT,
INTERNAL_ERROR;

@Override
public String toSpecification(GraphQLError error) {
return this.name();
}
}

The DataFetcherExceptionResolver

Spring GraphQL uses DataFetcherExceptionResolverAdapter to convert exceptions to GraphQL errors:

@Component
public class CustomExceptionResolver extends DataFetcherExceptionResolverAdapter {

private static final Logger log = LoggerFactory.getLogger(CustomExceptionResolver.class);

@Override
protected GraphQLError resolveToSingleError(Throwable ex, DataFetchingEnvironment env) {

if (ex instanceof GraphQLException graphQLEx) {
return GraphqlErrorBuilder.newError(env)
.message(graphQLEx.getMessage())
.errorType(graphQLEx.getClassification())
.extensions(Map.of(
"code", graphQLEx.getErrorCode(),
"timestamp", Instant.now().toString()
))
.build();
}

if (ex instanceof ConstraintViolationException cve) {
return handleValidationError(cve, env);
}

// Log unexpected errors
log.error("Unexpected error during GraphQL execution", ex);

// Don't expose internal error details
return GraphqlErrorBuilder.newError(env)
.message("An unexpected error occurred")
.errorType(ErrorClassification.INTERNAL_ERROR)
.extensions(Map.of("code", "INTERNAL_ERROR"))
.build();
}

private GraphQLError handleValidationError(ConstraintViolationException ex,
DataFetchingEnvironment env) {
List<Map<String, String>> fieldErrors = ex.getConstraintViolations().stream()
.map(violation -> Map.of(
"field", violation.getPropertyPath().toString(),
"message", violation.getMessage()
))
.toList();

return GraphqlErrorBuilder.newError(env)
.message("Validation failed")
.errorType(ErrorClassification.BAD_REQUEST)
.extensions(Map.of(
"code", "VALIDATION_ERROR",
"fieldErrors", fieldErrors
))
.build();
}
}

Now errors look like:

{
"errors": [
{
"message": "Book with id '999' not found",
"locations": [{"line": 2, "column": 3}],
"path": ["bookById"],
"extensions": {
"classification": "NOT_FOUND",
"code": "BOOK_NOT_FOUND",
"timestamp": "2024-01-29T10:30:00Z"
}
}
],
"data": {
"bookById": null
}
}

Handling Multiple Errors

Sometimes a single request can have multiple errors. Use resolveToMultipleErrors:

@Override
protected List<GraphQLError> resolveToMultipleErrors(Throwable ex,
DataFetchingEnvironment env) {
if (ex instanceof ValidationException ve) {
return ve.getFieldErrors().stream()
.map(fieldError -> GraphqlErrorBuilder.newError(env)
.message(fieldError.message())
.errorType(ErrorClassification.BAD_REQUEST)
.extensions(Map.of(
"code", "VALIDATION_ERROR",
"field", fieldError.field()
))
.build())
.toList();
}

return null; // Fall back to resolveToSingleError
}

Response with multiple validation errors:

{
"errors": [
{
"message": "Title is required",
"extensions": { "code": "VALIDATION_ERROR", "field": "title" }
},
{
"message": "Published year must be between 1000 and 2100",
"extensions": { "code": "VALIDATION_ERROR", "field": "publishedYear" }
}
]
}

Partial Responses

GraphQL can return both data and errors when some fields succeed and others fail:

query {
book1: bookById(id: "1") { title }
book2: bookById(id: "999") { title }
book3: bookById(id: "3") { title }
}
{
"errors": [
{
"message": "Book with id '999' not found",
"path": ["book2"]
}
],
"data": {
"book1": { "title": "The Great Gatsby" },
"book2": null,
"book3": { "title": "1984" }
}
}

This is a powerful feature! Clients get all available data plus specific error information.

Union Types for Expected Errors

For errors that are part of normal business logic, consider using union types:

type Query {
bookById(id: ID!): BookResult!
}

union BookResult = Book | BookNotFoundError | UnauthorizedError

type BookNotFoundError {
message: String!
bookId: ID!
}

type UnauthorizedError {
message: String!
}

Implementation:

@QueryMapping
public Object bookById(@Argument String id, DataFetchingEnvironment env) {
if (!authService.canAccessBooks(getCurrentUser())) {
return new UnauthorizedError("Not authorized to access books");
}

return bookRepository.findById(id)
.map(book -> (Object) book)
.orElse(new BookNotFoundError("Book not found", id));
}

Client query:

query {
bookById(id: "123") {
... on Book {
title
author { name }
}
... on BookNotFoundError {
message
bookId
}
... on UnauthorizedError {
message
}
}
}

Benefits:

  • Errors are part of the schema (self-documenting)
  • Type-safe error handling in clients
  • Clear distinction between expected and unexpected errors

Logging and Monitoring

Track errors for debugging and monitoring:

@Component
public class ErrorLoggingInstrumentation extends SimpleInstrumentation {

private static final Logger log = LoggerFactory.getLogger(ErrorLoggingInstrumentation.class);
private final MeterRegistry meterRegistry;

public ErrorLoggingInstrumentation(MeterRegistry meterRegistry) {
this.meterRegistry = meterRegistry;
}

@Override
public InstrumentationContext<ExecutionResult> beginExecution(
InstrumentationExecutionParameters parameters) {

return SimpleInstrumentationContext.whenCompleted((result, throwable) -> {
if (result.getErrors() != null && !result.getErrors().isEmpty()) {
for (GraphQLError error : result.getErrors()) {
String classification = error.getExtensions() != null
? (String) error.getExtensions().get("classification")
: "UNKNOWN";

log.warn("GraphQL error: {} - {} - path: {}",
classification,
error.getMessage(),
error.getPath());

meterRegistry.counter("graphql.errors",
"classification", classification).increment();
}
}
});
}
}

Client-Side Error Handling

Teach your frontend developers to handle errors properly:

// JavaScript client example
async function fetchBook(id) {
const response = await fetch('/graphql', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
query: `query GetBook($id: ID!) {
bookById(id: $id) { title author { name } }
}`,
variables: { id }
})
});

const { data, errors } = await response.json();

if (errors) {
for (const error of errors) {
switch (error.extensions?.code) {
case 'BOOK_NOT_FOUND':
showNotification('Book not found');
break;
case 'UNAUTHORIZED':
redirectToLogin();
break;
default:
showNotification('Something went wrong');
console.error(error);
}
}
}

return data?.bookById;
}

Error Handling Best Practices

1. Never Expose Internal Details

// Bad - exposes SQL details
throw new RuntimeException("ORA-00942: table or view does not exist");

// Good - generic message, log the details
log.error("Database error", e);
throw new GraphQLException("Unable to fetch data", "DATABASE_ERROR", ...);

2. Use Consistent Error Codes

Define a registry of error codes:

public final class ErrorCodes {
public static final String NOT_FOUND = "NOT_FOUND";
public static final String VALIDATION_ERROR = "VALIDATION_ERROR";
public static final String UNAUTHORIZED = "UNAUTHORIZED";
public static final String FORBIDDEN = "FORBIDDEN";
public static final String RATE_LIMITED = "RATE_LIMITED";
public static final String INTERNAL_ERROR = "INTERNAL_ERROR";
}

3. Include Helpful Context

// Bad
throw new BookNotFoundException("Not found");

// Good
throw new BookNotFoundException(bookId); // Message includes the ID

4. Document Errors in Schema

Use descriptions:

type Mutation {
"""
Create a new book.

Errors:
- VALIDATION_ERROR: Invalid input
- AUTHOR_NOT_FOUND: Author doesn't exist
- DUPLICATE_ISBN: ISBN already exists
"""
createBook(input: CreateBookInput!): Book!
}

Summary

ScenarioApproach
Resource not foundCustom exception → NOT_FOUND classification
Validation errorList of field errors in extensions
Authorization failureUNAUTHORIZED classification
Expected business errorsUnion types in schema
Unexpected errorsGeneric message, log details

Good error handling makes APIs a joy to use. Your clients will know exactly what went wrong and how to fix it.

Next up: Subscriptions - real-time data with WebSocket support in Spring GraphQL.