Mastering HATEOAS with Spring Boot: Elevating REST APIs
Introduction
RESTful APIs are often treated as fixed contracts, with clients relying on predefined URLs and response formats, however, this approach creates tight coupling between client and server, making the system harder to adapt or evolve without breaking existing integrations.
Here’s where HATEOAS comes into play.
Instead of assuming fixed URLs and response structures, HATEOAS lets the server embed actionable links directly into the response, these links guide the client on what can be done next, like retrieving a single category, navigating pages, or updating a resource, without the client needing to know endpoint details in advance.
By following these links, the client becomes decoupled from the server’s internal URL structure, changes on the backend like renaming endpoints or adding new capabilities don’t break existing clients, as long as the link semantics remain consistent.
In essence, HATEOAS makes your API self-descriptive and future-proof, enabling a more dynamic and resilient interaction model between client and server.
The Richardson Maturity Model: Understanding REST in Levels
To understand where HATEOAS fits in the REST landscape, we need to look at the Richardson Maturity Model (RMM) a simple framework created by Leonard Richardson to evaluate how "RESTful" an API truly is.
The model defines four levels, each representing a deeper adherence to REST principles:
Level 0 – The Swamp of POX
At this level, APIs expose a single endpoint typically using POST with all operations funneled through it. There's no concept of resources or HTTP methods. It’s basically RPC over HTTP.
Level 1 – Resources
This level introduces resource-oriented URIs., instead of /doAction, you now have endpoints like /products or /orders, making it easier to reason about the system's structure.
Level 2 – HTTP Verbs
Here, the API starts using HTTP methods properly:
Most REST APIs today stop at this level and it’s generally enough for many use cases.
Level 3 – Hypermedia Controls (HATEOAS)
This is the highest level of maturity, APIs at this level include hypermedia links in responses to describe what actions can be performed next. The client doesn’t need to construct URLs it simply follows the links provided.
Implementing HATEOAS in GET /categories using TDD
We followed a TDD approach to implement the HATEOAS-enabled getAllCategories() endpoint in our microservice, here’s how we built it starting from a failing test, and evolving toward a fully functional, hypermedia-driven response.
Recommended by LinkedIn
Step 1: Write the Test First
We began by writing an integration test that described the desired behavior:
@Test
void shouldReturnPaginatedCategoriesWithHateoasLinks() throws JsonProcessingException {
// Arrange
String url = productEndpoint + "?page=0&size=2";
// Act
ResponseEntity<String> response = restTemplate.exchange(url, HttpMethod.GET, null, String.class);
// Assert
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
var root = mapper.readTree(response.getBody());
assertThat(root.has("_embedded")).isTrue();
assertThat(root.path("_embedded").has("categoryDTOList")).isTrue();
assertThat(root.has("_links")).isTrue();
assertThat(root.path("page").get("size").asInt()).isEqualTo(2);
var firstCategory = root.path("_embedded").path("categoryDTOList").get(0);
assertThat(firstCategory.has("name")).isTrue();
assertThat(firstCategory.has("_links")).isTrue();
assertThat(firstCategory.path("_links").has("self")).isTrue();
}
Step 2: Add the HATEOAS Dependency
We added the Spring HATEOAS dependency in the pom.xml to enable hypermedia support:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-hateoas</artifactId>
</dependency>
Step 3: Implement the Assembler
We created a CategoryModelAssembler that adds self, delete, and update links to each category:
public EntityModel<CategoryDTO> toModel(CategoryDTO categoryDTO) {
return EntityModel.of(
categoryDTO,
linkTo(methodOn(CategoryController.class).getCategory(categoryDTO.name())).withSelfRel(),
linkTo(methodOn(CategoryController.class).deleteCategory(categoryDTO.name())).withRel(DELETE.rel()),
linkTo(methodOn(CategoryController.class).updateCategory(categoryDTO.name(), new UpdateCategoryDTO(categoryDTO.description()))).withRel(UPDATE.rel())
);
}
The link relations ("self", "delete", "update") are standardized using an enum for clarity and consistency.
Step 4: Make the Test Pass
We updated the controller to return a PagedModel using PagedResourcesAssembler:
@GetMapping
public ResponseEntity<PagedModel<EntityModel<CategoryDTO>>> getAllCategories(
@RequestParam(defaultValue = "0") @Min(0) int page,
@RequestParam(defaultValue = "10") @Min(1) int size,
PagedResourcesAssembler<CategoryDTO> assembler) {
Pageable pageable = PageRequest.of(page, size);
Page<CategoryDTO> result = categoryService.getAllCategories(pageable);
return ResponseEntity.ok(assembler.toModel(result, categoryModelAssembler));
}
Step 5: Confirm with Test Results
Once the implementation was complete, we re-ran the test and verified that:
Wrapping Up
We implemented a HATEOAS-compliant GET /categories endpoint using TDD, added pagination, and enriched responses with hypermedia links. The service is tested, structured, and Docker-ready.
What’s Next?
In the next article, we’ll deploy it to AWS, taking it from local container to a live, cloud-hosted microservice.
You can find the full source code in: https://github.com/eofe/product-catalog-service