Introduction
Hypermedia as the Engine of Application State (HATEOAS) states that every response should include links that tell the client what it can do next. Instead of relying on fixed URI patterns stored in code, the client discovers actions at runtime by following the links supplied by the server. This keeps the client decoupled from the server’s URI layout and simplifies evolution of the API.
Minimal HATEOAS JSON Example
Without HATEOAS a user representation looks like {“id”:7,”name”:”Ada”} The client must know the update endpoint /users/7 and the delete endpoint /users/7. With HATEOAS the same information becomes {“id”:7,”name”:”Ada”,”_links”:{“self”:{“href”:”/users/7″},”update”:{“href”:”/users/7″,”method”:”PUT”},”delete”:{“href”:”/users/7″,”method”:”DELETE”}}} The server has now advertised the next possible actions.
Spring Boot Implementation of HATEOAS
Add spring-boot-starter-hateoas to the project. Turn a plain entity into a RepresentationModel so that links can be attached.
@Entity
class Task extends RepresentationModel<Task> {
@Id @GeneratedValue Long id;
String title;
boolean completed;
}
A controller method builds the response with WebMvcLinkBuilder.
@GetMapping("/tasks/{id}")
public EntityModel<Task> one(@PathVariable Long id){
Task task = repo.findById(id).orElseThrow();
task.add(linkTo(methodOn(TaskController.class).one(id)).withSelfRel());
task.add(linkTo(methodOn(TaskController.class).all()).withRel("tasks"));
if(!task.completed){
task.add(linkTo(methodOn(TaskController.class).complete(id)).withRel("complete"));
}
return EntityModel.of(task);
}
The produced JSON is
{
"id":3,"title":"Refactor tests","completed":false,
"_links":{
"self":{"href":"/tasks/3"},
"tasks":{"href":"/tasks"},
"complete":{"href":"/tasks/3/complete"}
}
}
Assembler Pattern
Repeated link creation in every controller method violates DRY. A small assembler centralises the rules.
@Component
class TaskModelAssembler implements RepresentationModelAssembler<Task,EntityModel<Task>>{
public EntityModel<Task> toModel(Task task){
EntityModel<Task> model = EntityModel.of(task);
model.add(linkTo(methodOn(TaskController.class).one(task.getId())).withSelfRel());
model.add(linkTo(methodOn(TaskController.class).all()).withRel("tasks"));
if(!task.completed){
model.add(linkTo(methodOn(TaskController.class).complete(task.getId())).withRel("complete"));
}
return model;
}
}
The controller now delegates the assembly.
@Autowired TaskModelAssembler assembler;
@GetMapping("/tasks/{id}")
public ResponseEntity<EntityModel<Task>> one(@PathVariable Long id){
Task task = repo.findById(id).orElseThrow();
return ResponseEntity.ok(assembler.toModel(task));
}
Collection Resources
Returning a list is handled with CollectionModel.
@GetMapping("/tasks")
public CollectionModel<EntityModel<Task>> all(){
List<EntityModel<Task>> tasks = repo.findAll().stream()
.map(assembler::toModel)
.collect(Collectors.toList());
return CollectionModel.of(tasks,
linkTo(methodOn(TaskController.class).all()).withSelfRel());
}
The HAL representation becomes
{
"_embedded":{
"taskList":[{...}]
},
"_links":{
"self":{"href":"/tasks"}
}
}
Conclusion
Keep links relevant to the current state; a completed task should no longer advertise the complete action. Use IANA link relations such as self, collection, next, prev when they fit. Reserve custom relations for domain-specific actions. Treat URIs as opaque strings on the client side; never build them by concatenation. Version the API by adding new links or relations rather than changing existing ones.
