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.