Lightweight JSON:API for internal REST endpoints
You want standards for your internal APIs but big frameworks and reflection magic are not your friends? Read this article to get a template for it.
Introduction
REST + JSON is today often the first choice for synchronous APIs but neither of the two was invented for the job. As a consequence the aspiring software engineer will look in vain for solutions to standard issues like error handling, pagination and object relationships. Specifications like JSON:API can close this gap and arguably stop the bikeshedding . But how do you actually write compatible Java code?
This article presents a small template for writing JSON:API code without magic. No big framework, no heavy lifting library just a bit of boilerplate. This approach is geared towards internal APIs where a small amount of standardization and the core featureset of JSON:API is already a benefit, but full fletched support is not needed. The advantages I seek to realize are
- a transparent code base, no big learning curve, easy onboarding
- keep stuff in the presentation layer, no leaking into services or business objects
- do not mess with other tools, specifically keep Jackson and Swagger Annotations working
- keep it simple stupid
Meet Project Bookworm
To demonstrate the concepts, take a look at project Bookworm https://github.com/worldline/project-bookworm . A simple Spring Boot application around libraries, books and authors. The foundation is a typical three-tired application with a focus on the presentation layer including REST endpoints, JSON API helpers, REST DTOs and Swagger. The business and data layer exist only as mocks, sufficiently detailed to get the idea. The project provides an automatically created live Swagger documentation for some simple interactions. Lets explore some of the building blocks:
Data Wrapper - to GET data
The most basic need of an API is often to provide data to clients. The AuthorsRestController demonstrates exactly this. To conform to JSON:API we have to create JSON in the appropriate format:
"data": {
"type": "authors",
"id": "1948",
"attributes": {
"firstName": "Terry",
"lastName": "Pratchett"
}
}
Small boilerplate DTOs, constructed from the business objects serve as the foundation. They create the outline structure with type and id in combination with an inner record class for the attributes element. The sole purpose of these DTOs is to be a external / API level representation of the data. They are thus well suited to carry annotations for documentation purposes without cluttering the code base.
@JsonPropertyOrder({"type", "id", "attributes"})
public record AuthorDTO(String id, AuthorAttributes attributes) {
// ... constructors, type getter ...
private record AuthorAttributes(
@Schema(description = "first name") String firstName,
@Schema(description = "surename") String lastName) {
}
Then to complete the JSON document we need to wrap everything in the data element. The helper outlined below supports collections and single elements and transparently creates the required object structure.
public record Data<T>(T data) {
public static <T> Data<T> wrap(T single) {
return new Data<>(single);
}
public static <T> Data<List<T>> wrap(List<T> list) {
return new Data<>(list);
}
public static <T> Data<Set<T>> wrap(Set<T> set) {
return new Data<>(set);
}
}
To use it in a REST endpoint you simply provide the DTO which will be propperly wrapped up in the JSON data element.
return new Data<>(new LibraryDTO(MockData.library));
Error Handling - by ControllerAdvice
Error handling is implemented using Spring’s @ControllerAdvice and the ErrorObject and ErrorArray record classes. This allows efficient handling of exceptions with catch all clauses.
@ControllerAdvice
@RequestMapping(produces = "application/vnd.api+json")
public class BaseErrorHandler {
@ExceptionHandler(ResponseStatusException.class)
public ResponseEntity<ErrorArray> handleGenericException(ResponseStatusException e) {
var errorArray = new ErrorArray(
new ErrorObject(
String.valueOf(e.getStatusCode().value()),
null,
e.getMessage(),
null
)
);
return new ResponseEntity<>(errorArray, INTERNAL_SERVER_ERROR);
}
}
But also seamlessly supports rich mappings of specific exceptions to ease troubleshooting and/or guide the end user. For example the BookNotFoundException includes the book ID, which is reflected in the user friendly details message. Client side automation is also possible by evaluating the source.pointer and status code or the stable title element can give the necessary clues.
"errors": [
{
"status": "404",
"source": {
"pointer": "/data/id"
},
"title": "Book NOT FOUND",
"detail": "The book with the ID 1234 is not known to the library system"
}
]
The ErrorObject in bookworm implements a subset of the possible attributes from the JSON:API specification but this can easily be tailored to the needs of your application.
Relationships and Resource Identifiers
Simple endpoints are well and good but connected data and object relationships is where the fun begins.

A problem - well known to anyone who interacted with a database in his career. What do you load & display? Just the book? The book and the authors? As integrated part of the resource or just as a pointer/reference? And up to which depth? There are a few common solutions that can be mixed and matched:
- omit relationships
- integrate them (up to level n)
- reference them (by ID, reference pointer, URL, …)
Authors write books but neither GET /books nor GET /authors contain that relationship, it is omitted and for a simple listing this might just be the way to go. For GET /books/{id} on the other hand, I have chosen to include more details of the actual business model. The relationship is properly referenced using the relationships element from JSON:API which can be integrated in the DTO as shown below:
public class BookRelDTO extends BookDTO {
private final BookRelationships relationships;
public BookRelDTO(BookBO book) {
super(book);
relationships = extractRelationships(book.authors());
}
public record BookRelationships(Data<Set<ResourceIdentifier>> authors)
{}
private static BookRelationships extractRelationships(Set<AuthorBO> authors) {
var identifiers = authors.stream()
.map(a -> new ResourceIdentifier("author", String.valueOf(a.id())))
.collect(Collectors.toSet());
return new BookRelationships(Data.wrap(identifiers));
}
// ... other methods omitted for brevity
}
Going one step further one could also use JSON:APIs included element and integrate the authors into the books. Alternatively for a simpler solution, add an authors attribute, making use of nested and composed objects instead of formal relationships.
"attributes": {
"title": "The Hitchhiker's Guide to the Galaxy",
"authors": [
{
"firstname": "Douglas",
"lastname": "Adams"
}
]
}
Balancing REST Principles and Practical Architecture
For books and authors this is all trivial, we can choose any of the suggested approaches to surface the right level of detail on our API. The relationship between books and libraries is more interesting because it includes change. Books will be borrowed, returned and maybe lost.
What is a book (in your data)? Is it a physical printed copy or is it an everlasting spiritual creation? More practical, is it ok to store the book status with the book, because it is a book entity? Or do I want to reference THE book from all libraries that contain it and handle the status on this reference? Also, how do I manipulate such relationship?
JSON:API allows to update relationships to reflect change but does not directly support attributes on relationships. One solution would be to use two relationships, one for borrowed books and one for available books. Alternatively we can manipulate a status attribute on the book entity directly. A third approach is to model book-rental as a separate REST resource - which would be especially appropriate, if we have to deal with past rentals or actions on rentals (prolongation, …).
If you take a look at the LibrariesRestController you will notice that I settled for the quick and dirty solution. The book is a physical entity with a status that can be PATCHED on the libraries resource i.e. the relationship between book and library is not modelled in JSON:API. The relationship is also not visible on the GET /libraries endpoints and arguably the whole solution is not perfectly RESTful but its easy to implement, keeps the API small and avoids PATCH operations on whole libraries.
Such decisions can be tough. Luckily JSON:API has you covered with a well designed, RESTful standard solution while not beeing dogmatic about it. The specificatoin consists of a small core, many optional features and some SHOULD rules.
Conclusion
The presented approach leverages the core benefits of JSON:API while keeping the implementation simple. The goal was to create a solid foundation that solves common problems and provides guidance for RESTful APIs. While trading some of the higher level features of JSON:API and arguably rigorous REST compliance for simplicity.
This template serves as a starting point - the full code is available on Worldline GitHub - have fun