Error Handling
JsonApi4j automatically converts exceptions into JSON:API error responses. Every exception thrown during request processing is caught, mapped to an ErrorsDoc, and returned with the appropriate HTTP status code. No manual error formatting is needed.
How It Works
When an exception is thrown during request processing:
- The dispatcher servlet catches the exception
- The
ErrorHandlerFactoriesRegistryfinds a matchingErrorsDocSupplierfor the exception class - The supplier converts the exception into an
ErrorsDoc(containing one or moreErrorObjects) and an HTTP status code - The servlet writes the JSON:API error response
If no handler matches the exact exception class, the registry searches all registered exception classes and selects the most specific ancestor (closest parent in the class hierarchy). If nothing matches, a generic 500 Internal Server Error is returned.
Exception thrown
│
▼
ErrorHandlerFactoriesRegistry.getErrorResponseMapper(exception)
│
├── Exact class match found? → Use that handler
│
├── Ancestor match found? → Use the most specific ancestor handler
│
└── No match → 500 Internal Server Error
│
▼
ErrorsDocSupplier.getErrorResponse(exception) → ErrorsDoc
ErrorsDocSupplier.getHttpStatus(exception) → HTTP status code
│
▼
JSON:API error response written to client
4xx errors are logged at WARN level (client errors). 5xx errors are logged at ERROR level with full stack trace.
Error Response Structure
Every error response follows the JSON:API error format. The response body is an ErrorsDoc containing a list of ErrorObjects:
{
"errors": [
{
"id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"status": "400",
"code": "VALUE_IS_ABSENT",
"detail": "Field 'name' must not be null",
"source": {
"parameter": "name"
}
}
]
}
Each ErrorObject has the following fields:
| Field | Type | Description |
|---|---|---|
id |
String | Unique identifier (UUID) for this error occurrence |
status |
String | HTTP status code as a string (e.g., "404") |
code |
String | Application-specific error code (e.g., "NOT_FOUND") |
detail |
String | Human-readable explanation of the error |
source |
Object | Points to the error source — contains pointer, parameter, or header |
title |
String | Short summary of the error (optional) |
links |
Object | Links related to the error (optional) |
meta |
Object | Additional metadata (optional) |
Built-in Exception Handling
JsonApi4j registers two default error handler factories.
DefaultErrorHandlerFactory
Covers all framework-specific exceptions from the core and REST layers, as well as user-facing exceptions from the pro.api4.jsonapi4j.exception package. The registry matches the most specific exception class first — subclass handlers take priority over the JsonApi4jException catch-all.
| Exception | HTTP Status | Error Code | When It Occurs |
|---|---|---|---|
JsonApiRequestValidationException |
400 | From exception | Single validation failure — produces one error object |
CompositeJsonApiRequestValidationException |
400 | From errors | Multiple validation failures collected by JsonApiRequestValidator — produces multiple error objects in one response |
ResourceNotFoundException |
404 | NOT_FOUND |
Resource with the given ID does not exist |
JsonApi4jException |
From exception | From exception | Catch-all for exceptions that extend JsonApi4jException — uses the exception’s own httpStatus and errorCode |
DataRetrievalException |
502 | BAD_GATEWAY |
Downstream service failed to return data |
MappingException |
500 | INTERNAL_SERVER_ERROR |
DTO-to-JSON:API object failure |
OperationNotFoundException |
404 | NOT_FOUND |
Requested operation is not implemented for this resource |
Collect-All-Errors Validation
When using JsonApiRequestValidator.forRequest(request), the validator runs all configured validators (path, parameters, headers, body) and collects errors rather than stopping at the first failure. If multiple validators fail, the response contains all errors at once:
{
"errors": [
{
"id": "...",
"status": "400",
"code": "VALUE_IS_ABSENT",
"detail": "value can't be null",
"source": { "pointer": "/data/attributes" }
},
{
"id": "...",
"status": "400",
"code": "INVALID_ENUM_VALUE",
"detail": "'wrong' value is not allowed, available values: [countries]",
"source": { "pointer": "/data/relationships/citizenships/data/0/type" }
}
]
}
Each developer-provided validator lambda is atomic — if a lambda throws, the error is collected and the next validator runs. Errors within a single lambda are still fail-fast (e.g., a null check followed by a method call on the same value). This design prevents NPEs from interdependent checks while still collecting errors across independent validators.
Error Codes
Error codes are represented by the ErrorCode interface (single method: toCode()). The framework provides DefaultErrorCodes with 32 built-in codes organized by category:
Request validation: GENERIC_REQUEST_ERROR, MISSING_REQUIRED_PARAMETER, MISSING_REQUIRED_HEADER, INVALID_ENUM_VALUE, VALUE_IS_ABSENT, VALUE_EMPTY, VALUE_TOO_SHORT, VALUE_TOO_LONG, VALUE_TOO_HIGH, VALUE_TOO_LOW, VALUE_INVALID_FORMAT, ARRAY_LENGTH_TOO_SHORT, ARRAY_LENGTH_TOO_LONG, CONFLICTING_PARAMETERS, INVALID_CURSOR, INVALID_LIMIT, INVALID_PAYLOAD
HTTP/server: NOT_FOUND, METHOD_NOT_SUPPORTED, NOT_ACCEPTABLE, UNSUPPORTED_MEDIA_TYPE, CONFLICT, BAD_GATEWAY, INTERNAL_SERVER_ERROR, SERVICE_UNAVAILABLE, MAX_AMOUNT_OF_RESOURCES
Authentication/authorization: UNAUTHORIZED, ACCESS_TOKEN_REVOKED, ACCESS_TOKEN_EXPIRED, FORBIDDEN, INSUFFICIENT_SCOPES, INSUFFICIENT_ACCESS_TIER
You can define your own error codes by implementing ErrorCode:
public enum MyErrorCodes implements ErrorCode {
DUPLICATE_EMAIL,
ACCOUNT_SUSPENDED,
QUOTA_EXCEEDED;
@Override
public String toCode() {
return name();
}
}
Custom Error Handlers
To handle your own exceptions, implement ErrorHandlerFactory and register it as a bean. The framework auto-discovers custom factories and adds them to the registry alongside the built-in ones.
1. Define Your Exception
public class DuplicateEmailException extends RuntimeException {
private final String email;
public DuplicateEmailException(String email) {
super("Email already registered: " + email);
this.email = email;
}
public String getEmail() {
return email;
}
}
2. Create an ErrorHandlerFactory
public class MyErrorHandlerFactory implements ErrorHandlerFactory {
@Override
public Map<Class<? extends Throwable>, ErrorsDocSupplier<?>> getErrorResponseMappers() {
return Map.of(
DuplicateEmailException.class, new ErrorsDocSupplier<DuplicateEmailException>() {
@Override
public ErrorsDoc getErrorResponse(DuplicateEmailException ex) {
return ErrorsDocFactory.conflictErrorsDoc(
"Email " + ex.getEmail() + " is already registered"
);
}
@Override
public int getHttpStatus(DuplicateEmailException ex) {
return 409;
}
}
);
}
}
ErrorsDocFactory provides convenience methods for common HTTP statuses: badRequestErrorsDoc(), resourceNotFoundErrorsDoc(), conflictErrorsDoc(), badGatewayErrorsDoc(), internalServerErrorsDoc(), unsupportedMediaTypeErrorsDoc(), notAcceptableErrorsDoc(), methodNotSupportedErrorsDoc(), and the generic genericErrorsDoc(status, code, detail).
3. Register It
Define the factory as a Spring bean. The framework auto-discovers all ErrorHandlerFactory beans via ObjectProvider.
@Configuration
public class ErrorConfig {
@Bean
public ErrorHandlerFactory myErrorHandlerFactory() {
return new MyErrorHandlerFactory();
}
}Provide the factory as a CDI bean. The framework discovers all ErrorHandlerFactory instances via Instance<ErrorHandlerFactory>.
public class ErrorConfig {
@Produces
@Singleton
public ErrorHandlerFactory myErrorHandlerFactory() {
return new MyErrorHandlerFactory();
}
}Register the factory on the ErrorHandlerFactoriesRegistry before the framework initializes, or set it as a ServletContext attribute.
ErrorHandlerFactoriesRegistry registry = new JsonApi4jErrorHandlerFactoriesRegistry();
registry.registerAll(new DefaultErrorHandlerFactory());
registry.registerAll(new MyErrorHandlerFactory());
servletContext.setAttribute(
JsonApi4jServletContainerInitializer.ERROR_HANDLER_FACTORIES_REGISTRY_ATT_NAME,
registry
);Custom factories are registered after the built-in ones. If you register a handler for an exception class that already has a built-in handler, your handler replaces it. When no exact match exists, the registry selects the most specific registered ancestor — so a custom handler for a parent exception class won’t shadow a more specific built-in handler (or vice versa).
Throwing Errors from Operations
The simplest way to return an error from your operation code is to throw a JsonApi4jException with the appropriate status, code, and detail:
@Override
public UserDto readById(JsonApiRequest request) {
UserDto user = userDb.findById(request.getResourceId());
if (user == null) {
throw new ResourceNotFoundException(
"users", request.getResourceId()
);
}
if (user.isSuspended()) {
throw new JsonApi4jException(
403, MyErrorCodes.ACCOUNT_SUSPENDED,
"Account " + request.getResourceId() + " is suspended"
);
}
return user;
}
For validation errors, use JsonApiRequestValidator.forRequest(request) with the fluent assertion API. The builder handles error sources automatically for path, parameter, and body field validators:
@Override
public void validate(JsonApiRequest request) {
forRequest(request)
.parameters(params -> params
.withFilterValidator("region", regions ->
regions.ifPresent().allSatisfy(region ->
Validate.assertThat(region).isOneOf(SUPPORTED_REGIONS))))
.validate();
}
You can also throw JsonApiRequestValidationException directly with an error code, detail message, and optionally an error source. If you don’t need a specific error code, use the single-argument constructor — it defaults to GENERIC_REQUEST_ERROR:
throw new JsonApiRequestValidationException("name must not be blank");
For situations where a resource is not found — either throw ResourceNotFoundException directly or use the exists(predicate) assertion which throws it automatically when the predicate fails:
// Standalone
assertThat(request.getResourceId()).exists(id -> userDb.readById(id) != null);
// Inside a body validator
.withResourceIdValidator(id -> id.exists(resourceId -> userDb.readById(resourceId) != null))
Exception Hierarchy
RuntimeException
│
├── JsonApi4jException (httpStatus, errorCode, detail) — user-facing
│ ├── JsonApiRequestValidationException (+ source) — 400 (single error)
│ │ ├── InvalidCursorException — 400 INVALID_CURSOR
│ │ ├── InvalidLimitException — 400 INVALID_LIMIT
│ │ └── InvalidPayloadException — 400 INVALID_PAYLOAD
│ ├── ResourceNotFoundException — 404 NOT_FOUND
│ ├── MethodNotSupportedException — 405 METHOD_NOT_SUPPORTED
│ ├── NotAcceptableException — 406 NOT_ACCEPTABLE
│ └── UnsupportedMediaTypeException — 415 UNSUPPORTED_MEDIA_TYPE
│
├── CompositeJsonApiRequestValidationException — 400 (multiple errors)
│ └── (collected by JsonApiRequestValidator)
│
├── DataRetrievalException — framework-internal
│ └── (caught and mapped to 502 BAD_GATEWAY)
├── MappingException — 500 INTERNAL_SERVER_ERROR
└── OperationNotFoundException — 404 NOT_FOUND
User-facing exceptions (extend JsonApi4jException) are designed to be thrown from your operation code. They carry their own HTTP status and error code, so the framework converts them into proper JSON:API error responses automatically.
Framework-internal exceptions (DataRetrievalException, MappingException, OperationNotFoundException) are thrown by the framework itself and handled by DefaultErrorHandlerFactory with dedicated handlers. You generally don’t throw these directly, except DataRetrievalException when a downstream service call fails.