Error Handling in Spring GraphQL - From Chaos to Clarity

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"
}
}
| Field | Purpose |
|---|---|
message | Human-readable description |
locations | Where in the query the error occurred |
path | Which field failed |
extensions | Custom 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
| Scenario | Approach |
|---|---|
| Resource not found | Custom exception → NOT_FOUND classification |
| Validation error | List of field errors in extensions |
| Authorization failure | UNAUTHORIZED classification |
| Expected business errors | Union types in schema |
| Unexpected errors | Generic 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.