HATEOAS (Hypermedia as the Engine of Application State) is a constraint of the REST architecture that makes APIs discoverable and self-describing. Spring HATEOAS provides comprehensive support for building hypermedia-driven RESTful APIs in Spring applications.
Key Concepts of HATEOAS
- Hypermedia Controls: Links that guide clients through API interactions
- Discoverability: Clients can navigate APIs without prior knowledge
- State Transitions: Links represent possible state transitions
- Loose Coupling: Clients interact with APIs dynamically
Setup and Dependencies
Maven Dependencies
<!-- Spring Boot Starter HATEOAS --> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-hateoas</artifactId> </dependency> <!-- For HAL browser (optional) --> <dependency> <groupId>org.springframework.data</groupId> <artifactId>spring-data-rest-hal-browser</artifactId> </dependency>
Gradle Dependencies
implementation 'org.springframework.boot:spring-boot-starter-hateoas' implementation 'org.springframework.data:spring-data-rest-hal-browser'
Basic HATEOAS Implementation
Example 1: Simple Entity with HATEOAS
import org.springframework.hateoas.RepresentationModel;
import org.springframework.hateoas.server.mvc.WebMvcLinkBuilder;
// Entity class
public class Product {
private Long id;
private String name;
private String description;
private BigDecimal price;
// constructors, getters, setters
public Product() {}
public Product(Long id, String name, String description, BigDecimal price) {
this.id = id;
this.name = name;
this.description = description;
this.price = price;
}
// getters and setters
public Long getId() { return id; }
public void setId(Long id) { this.id = id; }
public String getName() { return name; }
public void setName(String name) { this.name = name; }
public String getDescription() { return description; }
public void setDescription(String description) { this.description = description; }
public BigDecimal getPrice() { return price; }
public void setPrice(BigDecimal price) { this.price = price; }
}
// Resource model (extends RepresentationModel)
public class ProductResource extends RepresentationModel<ProductResource> {
private final Product product;
public ProductResource(Product product) {
this.product = product;
addLinks();
}
private void addLinks() {
// Self link
add(WebMvcLinkBuilder.linkTo(
WebMvcLinkBuilder.methodOn(ProductController.class)
.getProduct(product.getId()))
.withSelfRel());
// Update link
add(WebMvcLinkBuilder.linkTo(
WebMvcLinkBuilder.methodOn(ProductController.class)
.updateProduct(product.getId(), null))
.withRel("update"));
// Delete link
add(WebMvcLinkBuilder.linkTo(
WebMvcLinkBuilder.methodOn(ProductController.class)
.deleteProduct(product.getId()))
.withRel("delete"));
// Collection link
add(WebMvcLinkBuilder.linkTo(ProductController.class)
.withRel("products"));
}
// Delegate getters to the product
public Long getId() { return product.getId(); }
public String getName() { return product.getName(); }
public String getDescription() { return product.getDescription(); }
public BigDecimal getPrice() { return product.getPrice(); }
}
// Controller
@RestController
@RequestMapping("/api/products")
public class ProductController {
@GetMapping("/{id}")
public ResponseEntity<ProductResource> getProduct(@PathVariable Long id) {
Product product = productService.findById(id)
.orElseThrow(() -> new ProductNotFoundException(id));
ProductResource resource = new ProductResource(product);
return ResponseEntity.ok(resource);
}
@GetMapping
public ResponseEntity<CollectionModel<ProductResource>> getAllProducts() {
List<ProductResource> products = productService.findAll().stream()
.map(ProductResource::new)
.collect(Collectors.toList());
CollectionModel<ProductResource> resources = CollectionModel.of(products);
// Add self link to collection
resources.add(WebMvcLinkBuilder.linkTo(
WebMvcLinkBuilder.methodOn(ProductController.class)
.getAllProducts())
.withSelfRel());
// Add link to create new product
resources.add(WebMvcLinkBuilder.linkTo(
WebMvcLinkBuilder.methodOn(ProductController.class)
.createProduct(null))
.withRel("create"));
return ResponseEntity.ok(resources);
}
@PostMapping
public ResponseEntity<ProductResource> createProduct(@RequestBody Product product) {
Product saved = productService.save(product);
ProductResource resource = new ProductResource(saved);
return ResponseEntity.created(
WebMvcLinkBuilder.linkTo(
WebMvcLinkBuilder.methodOn(ProductController.class)
.getProduct(saved.getId()))
.toUri()
).body(resource);
}
// Other methods: update, delete
}
// Exception handler
@ControllerAdvice
public class ProductExceptionHandler {
@ExceptionHandler(ProductNotFoundException.class)
public ResponseEntity<Object> handleProductNotFound(ProductNotFoundException ex) {
Map<String, Object> body = new LinkedHashMap<>();
body.put("timestamp", LocalDateTime.now());
body.put("message", ex.getMessage());
// Add helpful links
EntityModel<Map<String, Object>> resource = EntityModel.of(body);
resource.add(WebMvcLinkBuilder.linkTo(ProductController.class).withRel("products"));
return new ResponseEntity<>(resource, HttpStatus.NOT_FOUND);
}
}
Advanced HATEOAS Patterns
Example 2: Pagination with HATEOAS
@RestController
@RequestMapping("/api/users")
public class UserController {
private final UserService userService;
private final UserResourceAssembler assembler;
public UserController(UserService userService, UserResourceAssembler assembler) {
this.userService = userService;
this.assembler = assembler;
}
@GetMapping
public ResponseEntity<CollectionModel<EntityModel<User>>> getUsers(
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "10") int size) {
Page<User> userPage = userService.findAll(PageRequest.of(page, size));
Page<EntityModel<User>> userModels = userPage.map(assembler::toModel);
PagedModel<EntityModel<User>> pagedModel = PagedModel.of(
userModels.getContent(),
new PagedModel.PageMetadata(
userModels.getSize(),
userModels.getNumber(),
userModels.getTotalElements(),
userModels.getTotalPages()
)
);
// Add navigation links
pagedModel.add(Link.of("/api/users?page=" + page + "&size=" + size).withSelfRel());
if (userModels.hasPrevious()) {
pagedModel.add(Link.of("/api/users?page=" + (page - 1) + "&size=" + size).withRel("prev"));
}
if (userModels.hasNext()) {
pagedModel.add(Link.of("/api/users?page=" + (page + 1) + "&size=" + size).withRel("next"));
}
pagedModel.add(Link.of("/api/users?page=0&size=" + size).withRel("first"));
pagedModel.add(Link.of("/api/users?page=" + (userModels.getTotalPages() - 1) + "&size=" + size).withRel("last"));
return ResponseEntity.ok(pagedModel);
}
}
// Resource Assembler
@Component
public class UserResourceAssembler implements RepresentationModelAssembler<User, EntityModel<User>> {
@Override
public EntityModel<User> toModel(User user) {
return EntityModel.of(user,
WebMvcLinkBuilder.linkTo(
WebMvcLinkBuilder.methodOn(UserController.class)
.getUser(user.getId()))
.withSelfRel(),
WebMvcLinkBuilder.linkTo(UserController.class)
.withRel("users"),
WebMvcLinkBuilder.linkTo(
WebMvcLinkBuilder.methodOn(OrderController.class)
.getUserOrders(user.getId()))
.withRel("orders")
);
}
@Override
public CollectionModel<EntityModel<User>> toCollectionModel(Iterable<? extends User> entities) {
List<EntityModel<User>> userModels = StreamSupport.stream(entities.spliterator(), false)
.map(this::toModel)
.collect(Collectors.toList());
return CollectionModel.of(userModels,
WebMvcLinkBuilder.linkTo(UserController.class).withSelfRel());
}
}
Example 3: Complex Resource with Embedded Resources
// Order with embedded order items
public class OrderResource extends RepresentationModel<OrderResource> {
private Long id;
private LocalDateTime orderDate;
private String status;
private BigDecimal totalAmount;
@JsonProperty("_embedded")
private Map<String, Object> embedded = new HashMap<>();
public OrderResource(Order order) {
this.id = order.getId();
this.orderDate = order.getOrderDate();
this.status = order.getStatus();
this.totalAmount = order.getTotalAmount();
addLinks();
}
private void addLinks() {
add(WebMvcLinkBuilder.linkTo(
WebMvcLinkBuilder.methodOn(OrderController.class)
.getOrder(id))
.withSelfRel());
add(WebMvcLinkBuilder.linkTo(
WebMvcLinkBuilder.methodOn(OrderController.class)
.cancelOrder(id))
.withRel("cancel"));
add(WebMvcLinkBuilder.linkTo(
WebMvcLinkBuilder.methodOn(UserController.class)
.getUser(getCustomerId()))
.withRel("customer"));
}
public void embedOrderItems(List<OrderItemResource> items) {
embedded.put("orderItems", items);
}
public void embedCustomer(UserResource customer) {
embedded.put("customer", customer);
}
// getters and setters
}
// Order Controller
@RestController
@RequestMapping("/api/orders")
public class OrderController {
private final OrderService orderService;
private final OrderResourceAssembler orderAssembler;
private final OrderItemResourceAssembler itemAssembler;
private final UserResourceAssembler userAssembler;
@GetMapping("/{orderId}")
public ResponseEntity<OrderResource> getOrder(@PathVariable Long orderId) {
Order order = orderService.findById(orderId)
.orElseThrow(() -> new OrderNotFoundException(orderId));
OrderResource resource = orderAssembler.toModel(order);
// Embed order items
List<OrderItemResource> items = order.getItems().stream()
.map(itemAssembler::toModel)
.collect(Collectors.toList());
resource.embedOrderItems(items);
// Embed customer
UserResource customer = userAssembler.toModel(order.getCustomer());
resource.embedCustomer(customer);
return ResponseEntity.ok(resource);
}
@GetMapping("/{orderId}/items")
public ResponseEntity<CollectionModel<OrderItemResource>> getOrderItems(@PathVariable Long orderId) {
List<OrderItemResource> items = orderService.findOrderItems(orderId).stream()
.map(itemAssembler::toModel)
.collect(Collectors.toList());
CollectionModel<OrderItemResource> collection = CollectionModel.of(items);
collection.add(WebMvcLinkBuilder.linkTo(
WebMvcLinkBuilder.methodOn(OrderController.class)
.getOrderItems(orderId))
.withSelfRel());
collection.add(WebMvcLinkBuilder.linkTo(
WebMvcLinkBuilder.methodOn(OrderController.class)
.getOrder(orderId))
.withRel("order"));
return ResponseEntity.ok(collection);
}
}
HAL (Hypertext Application Language) Implementation
Example 4: HAL-Style Responses
@Configuration
@EnableHypermediaSupport(type = EnableHypermediaSupport.HypermediaType.HAL)
public class HalConfig {
// HAL is enabled by default in Spring HATEOAS
}
// Custom HAL response
public class HalResponse<T> extends RepresentationModel<HalResponse<T>> {
private T data;
private Map<String, Object> embedded = new HashMap<>();
public HalResponse(T data) {
this.data = data;
}
public void embed(String relation, Object resource) {
embedded.put(relation, resource);
}
@JsonProperty("_embedded")
public Map<String, Object> getEmbedded() {
return embedded.isEmpty() ? null : embedded;
}
// getters and setters
public T getData() { return data; }
public void setData(T data) { this.data = data; }
}
// HAL-based controller
@RestController
@RequestMapping("/api/hal")
public class HalController {
@GetMapping("/products/{id}")
public ResponseEntity<HalResponse<Product>> getProductHal(@PathVariable Long id) {
Product product = productService.findById(id)
.orElseThrow(() -> new ProductNotFoundException(id));
HalResponse<Product> response = new HalResponse<>(product);
// Add links
response.add(WebMvcLinkBuilder.linkTo(
WebMvcLinkBuilder.methodOn(HalController.class)
.getProductHal(id))
.withSelfRel());
response.add(WebMvcLinkBuilder.linkTo(
WebMvcLinkBuilder.methodOn(HalController.class)
.getProductReviews(id))
.withRel("reviews"));
// Embed related resources if needed
if (!product.getCategories().isEmpty()) {
List<Category> categories = product.getCategories();
response.embed("categories", categories);
}
return ResponseEntity.ok(response);
}
@GetMapping("/products/{id}/reviews")
public ResponseEntity<HalResponse<List<Review>>> getProductReviews(@PathVariable Long id) {
List<Review> reviews = reviewService.findByProductId(id);
HalResponse<List<Review>> response = new HalResponse<>(reviews);
response.add(WebMvcLinkBuilder.linkTo(
WebMvcLinkBuilder.methodOn(HalController.class)
.getProductReviews(id))
.withSelfRel());
response.add(WebMvcLinkBuilder.linkTo(
WebMvcLinkBuilder.methodOn(HalController.class)
.getProductHal(id))
.withRel("product"));
return ResponseEntity.ok(response);
}
}
Conditional Links and State-Based Navigation
Example 5: State-Based HATEOAS
@Component
public class OrderStateResourceAssembler implements RepresentationModelAssembler<Order, EntityModel<Order>> {
@Override
public EntityModel<Order> toModel(Order order) {
EntityModel<Order> model = EntityModel.of(order);
// Always available links
model.add(WebMvcLinkBuilder.linkTo(
WebMvcLinkBuilder.methodOn(OrderController.class)
.getOrder(order.getId()))
.withSelfRel());
// State-dependent links
switch (order.getStatus()) {
case "CREATED":
model.add(WebMvcLinkBuilder.linkTo(
WebMvcLinkBuilder.methodOn(OrderController.class)
.cancelOrder(order.getId()))
.withRel("cancel"));
model.add(WebMvcLinkBuilder.linkTo(
WebMvcLinkBuilder.methodOn(OrderController.class)
.payOrder(order.getId(), null))
.withRel("pay"));
break;
case "PAID":
model.add(WebMvcLinkBuilder.linkTo(
WebMvcLinkBuilder.methodOn(OrderController.class)
.shipOrder(order.getId()))
.withRel("ship"));
break;
case "SHIPPED":
model.add(WebMvcLinkBuilder.linkTo(
WebMvcLinkBuilder.methodOn(OrderController.class)
.deliverOrder(order.getId()))
.withRel("deliver"));
break;
case "DELIVERED":
model.add(WebMvcLinkBuilder.linkTo(
WebMvcLinkBuilder.methodOn(OrderController.class)
.returnOrder(order.getId()))
.withRel("return"));
break;
}
// Role-based links (if using Spring Security)
if (hasAdminRole()) {
model.add(WebMvcLinkBuilder.linkTo(
WebMvcLinkBuilder.methodOn(AdminController.class)
.getOrderAudit(order.getId()))
.withRel("audit"));
}
return model;
}
private boolean hasAdminRole() {
// Check Spring Security context for admin role
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
return authentication != null &&
authentication.getAuthorities().stream()
.anyMatch(a -> a.getAuthority().equals("ROLE_ADMIN"));
}
}
Testing HATEOAS Endpoints
Example 6: Testing HATEOAS Responses
@SpringBootTest
@AutoConfigureTestDatabase
@AutoConfigureWebTestClient
class ProductControllerTest {
@Autowired
private WebTestClient webTestClient;
@Autowired
private ProductRepository productRepository;
@Test
void getProduct_ShouldReturnProductWithLinks() {
// Arrange
Product product = new Product(1L, "Test Product", "Description", new BigDecimal("99.99"));
productRepository.save(product);
// Act & Assert
webTestClient.get().uri("/api/products/{id}", 1L)
.exchange()
.expectStatus().isOk()
.expectBody()
.jsonPath("$.id").isEqualTo(1)
.jsonPath("$.name").isEqualTo("Test Product")
.jsonPath("$._links.self.href").exists()
.jsonPath("$._links.update.href").exists()
.jsonPath("$._links.delete.href").exists()
.jsonPath("$._links.products.href").exists();
}
@Test
void createProduct_ShouldReturnCreatedWithLocationHeader() {
// Arrange
Product newProduct = new Product(null, "New Product", "New Description", new BigDecimal("49.99"));
// Act & Assert
webTestClient.post().uri("/api/products")
.contentType(MediaType.APPLICATION_JSON)
.bodyValue(newProduct)
.exchange()
.expectStatus().isCreated()
.expectHeader().exists("Location")
.expectBody()
.jsonPath("$.id").exists()
.jsonPath("$.name").isEqualTo("New Product")
.jsonPath("$._links.self.href").exists();
}
}
// MockMvc testing
@WebMvcTest(ProductController.class)
class ProductControllerMockTest {
@Autowired
private MockMvc mockMvc;
@MockBean
private ProductService productService;
@Test
void getProduct_ShouldReturnHalResponse() throws Exception {
// Arrange
Product product = new Product(1L, "Test", "Desc", BigDecimal.TEN);
when(productService.findById(1L)).thenReturn(Optional.of(product));
// Act & Assert
mockMvc.perform(get("/api/products/1")
.accept(MediaTypes.HAL_JSON))
.andExpect(status().isOk())
.andExpect(jsonPath("$.id").value(1))
.andExpect(jsonPath("$._links.self.href").exists())
.andExpect(jsonPath("$._links.products.href").exists());
}
}
Best Practices and Patterns
1. Use RepresentationModelAssembler
@Component
public class ProductResourceAssembler
implements RepresentationModelAssembler<Product, ProductResource> {
@Override
public ProductResource toModel(Product product) {
ProductResource resource = new ProductResource(product);
// Add common links
resource.add(linkTo(methodOn(ProductController.class)
.getProduct(product.getId())).withSelfRel());
resource.add(linkTo(ProductController.class).withRel("products"));
return resource;
}
}
2. Consistent Link Relations
public class LinkRelations {
public static final String SELF = "self";
public static final String CREATE = "create";
public static final String UPDATE = "update";
public static final String DELETE = "delete";
public static final String SEARCH = "search";
public static final String NEXT = "next";
public static final String PREV = "prev";
public static final String FIRST = "first";
public static final String LAST = "last";
}
3. Error Handling with HATEOAS
@ControllerAdvice
public class HateoasExceptionHandler {
@ExceptionHandler(ResourceNotFoundException.class)
public ResponseEntity<EntityModel<ErrorResponse>> handleNotFound(ResourceNotFoundException ex) {
ErrorResponse error = new ErrorResponse("NOT_FOUND", ex.getMessage());
EntityModel<ErrorResponse> resource = EntityModel.of(error);
resource.add(linkTo(methodOn(HomeController.class).home()).withRel("home"));
resource.add(linkTo(methodOn(DocumentationController.class).apiDocs()).withRel("docs"));
return ResponseEntity.status(HttpStatus.NOT_FOUND).body(resource);
}
}
Conclusion
Spring HATEOAS provides powerful tools for building hypermedia-driven REST APIs:
- Discoverable APIs: Clients can navigate without prior knowledge
- State Transitions: Links represent possible actions
- Loose Coupling: Clients don't hardcode URLs
- Rich Metadata: APIs provide context and relationships
Key benefits include:
- Better API discoverability and self-documentation
- Reduced client-server coupling
- Built-in support for common hypermedia formats (HAL, etc.)
- Seamless integration with Spring ecosystem
- Comprehensive testing support
For modern REST API development, implementing HATEOAS principles with Spring HATEOAS leads to more robust, maintainable, and user-friendly APIs that can evolve independently of their clients.