Spring HATEOAS
Use when adding hypermedia links to REST responses, building self-describing APIs, or implementing Spring HATEOAS. Use when you see EntityModel, CollectionModel, or RepresentationModel in the project.
View SKILL.mdDependency
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-hateoas</artifactId>
</dependency>
When to Add Links
self— always, on every resource responsecollection— link back to the list endpointrelated resources— when a client commonly needs to navigate to themactions— links to state transitions (e.g.,cancel,ship) when valid for current state
Resource Model
@Getter // Jackson serializes through getters — without them the body is only "_links"
@AllArgsConstructor(access = AccessLevel.PRIVATE)
public class OrderModel extends RepresentationModel<OrderModel> {
private final UUID id;
private final String status;
private final String customerEmail;
private final Instant createdAt;
// Static factory with links
public static OrderModel from(Order order) {
OrderModel model = new OrderModel(
order.getId(), order.getStatus().name(),
order.getCustomerEmail(), order.getCreatedAt()
);
// Self link — always
model.add(linkTo(methodOn(OrderController.class).getById(order.getId())).withSelfRel());
// Collection link
model.add(linkTo(methodOn(OrderController.class).list(null)).withRel("orders"));
// Conditional action links based on state
if (order.getStatus() == OrderStatus.PENDING) {
model.add(linkTo(methodOn(OrderController.class)
.cancelOrder(order.getId())).withRel("cancel"));
}
if (order.getStatus() == OrderStatus.PROCESSING) {
model.add(linkTo(methodOn(OrderController.class)
.shipOrder(order.getId())).withRel("ship"));
}
return model;
}
}
Controller
@RestController
@RequestMapping("/api/v1/orders")
public class OrderController {
@GetMapping("/{id}")
public ResponseEntity<OrderModel> getById(@PathVariable UUID id) {
Order order = orderService.findById(id);
return ResponseEntity.ok(OrderModel.from(order));
}
@GetMapping
public ResponseEntity<CollectionModel<OrderModel>> list(Pageable pageable) {
Page<Order> orders = orderService.findAll(pageable);
List<OrderModel> models = orders.getContent().stream()
.map(OrderModel::from)
.toList();
CollectionModel<OrderModel> collection = CollectionModel.of(models,
linkTo(methodOn(OrderController.class).list(pageable)).withSelfRel()
);
// Pagination links
if (orders.hasNext()) {
collection.add(linkTo(methodOn(OrderController.class)
.list(pageable.next())).withRel(IanaLinkRelations.NEXT));
}
if (orders.hasPrevious()) {
collection.add(linkTo(methodOn(OrderController.class)
.list(pageable.previousOrFirst())).withRel(IanaLinkRelations.PREV));
}
return ResponseEntity.ok(collection);
}
}
Response Shape
{
"id": "550e8400-e29b-41d4-a716-446655440000",
"status": "PENDING",
"customerEmail": "user@example.com",
"_links": {
"self": { "href": "http://api.example.com/api/v1/orders/550e8400" },
"orders": { "href": "http://api.example.com/api/v1/orders" },
"cancel": { "href": "http://api.example.com/api/v1/orders/550e8400/cancel" }
}
}
RepresentationModelAssembler Pattern
- Spring's recommended way to build HATEOAS models from entities
- Implements
RepresentationModelAssembler<Entity, Model>— reusable across controllers - Inject the assembler into controllers instead of calling
Model.from()directly
@Component
public class OrderModelAssembler implements RepresentationModelAssembler<Order, EntityModel<OrderResponse>> {
@Override
public EntityModel<OrderResponse> toModel(Order order) {
EntityModel<OrderResponse> model = EntityModel.of(OrderResponse.from(order),
linkTo(methodOn(OrderController.class).getById(order.getId())).withSelfRel(),
linkTo(methodOn(OrderController.class).list(null)).withRel("orders"));
if (order.getStatus() == OrderStatus.PENDING) {
model.add(linkTo(methodOn(OrderController.class)
.cancelOrder(order.getId())).withRel("cancel"));
}
return model;
}
}
PagedModel for Paginated Collections
- Use
PagedResourcesAssemblerfor automatic pagination links (first, prev, next, last) - Inject
PagedResourcesAssembler<Order>into controllers — Spring creates it automatically
@GetMapping
public ResponseEntity<PagedModel<EntityModel<OrderResponse>>> list(
Pageable pageable, PagedResourcesAssembler<Order> pagedAssembler) {
Page<Order> orders = orderService.findAll(pageable);
PagedModel<EntityModel<OrderResponse>> pagedModel =
pagedAssembler.toModel(orders, orderModelAssembler);
return ResponseEntity.ok(pagedModel);
}
Gotchas
- Agent adds all links regardless of state — only add action links when the action is valid
- Agent hardcodes URLs in links — always use
linkTo(methodOn(...))for type-safe links - Agent returns plain DTO — wrap in
EntityModel.of(dto, links...)or extendRepresentationModel - Agent puts link logic in controller — extract to
RepresentationModelAssembler - Agent manually builds pagination links — use
PagedResourcesAssemblerinstead - Agent forgets
selflink — every resource must have aselflink
Examples & templates
bad-hateoas-controller.javaexamples
Source on GitHub
// ❌ BAD — hardcoded URLs, all links regardless of state, no assembler, missing self link
@RestController
@RequestMapping("/api/v1/orders")
public class OrderController {
@Autowired
private OrderService orderService;
@GetMapping("/{id}")
public OrderResponse getById(@PathVariable UUID id) {
Order order = orderService.findById(id);
OrderResponse response = OrderResponse.from(order);
// Returns plain DTO — no HATEOAS links at all!
return response;
}
@GetMapping("/{id}/with-links")
public Map<String, Object> getWithLinks(@PathVariable UUID id) {
Order order = orderService.findById(id);
return Map.of(
"data", OrderResponse.from(order),
"_links", Map.of(
// ❌ Hardcoded URLs instead of linkTo(methodOn(...))
"self", "http://localhost:8080/api/v1/orders/" + id,
"orders", "http://localhost:8080/api/v1/orders",
// ❌ All action links regardless of order state
"cancel", "http://localhost:8080/api/v1/orders/" + id + "/cancel",
"ship", "http://localhost:8080/api/v1/orders/" + id + "/ship",
"confirm", "http://localhost:8080/api/v1/orders/" + id + "/confirm"
)
);
}
@GetMapping
public List<OrderResponse> list(Pageable pageable) {
// ❌ Returns plain list — no CollectionModel, no pagination links
return orderService.findAll(pageable).getContent().stream()
.map(OrderResponse::from)
.toList();
}
// ❌ Link-building logic scattered in controller instead of assembler
}good-hateoas-controller.javaexamples
Source on GitHub
// ✅ GOOD — RepresentationModelAssembler, conditional links, PagedResourcesAssembler
@Component
@RequiredArgsConstructor
public class OrderModelAssembler
implements RepresentationModelAssembler<Order, EntityModel<OrderResponse>> {
@Override
public EntityModel<OrderResponse> toModel(Order order) {
EntityModel<OrderResponse> model = EntityModel.of(
OrderResponse.from(order),
linkTo(methodOn(OrderController.class).getById(order.getId())).withSelfRel(),
linkTo(methodOn(OrderController.class).list(null, null)).withRel("orders"));
// Conditional action links — only when the action is valid
if (order.getStatus() == OrderStatus.PENDING) {
model.add(linkTo(methodOn(OrderController.class)
.confirm(order.getId())).withRel("confirm"));
model.add(linkTo(methodOn(OrderController.class)
.cancel(order.getId())).withRel("cancel"));
}
if (order.getStatus() == OrderStatus.CONFIRMED) {
model.add(linkTo(methodOn(OrderController.class)
.ship(order.getId())).withRel("ship"));
}
return model;
}
}
@RestController
@RequestMapping("/api/v1/orders")
@RequiredArgsConstructor
public class OrderController {
private final OrderService orderService;
private final OrderModelAssembler assembler;
@GetMapping("/{id}")
public ResponseEntity<EntityModel<OrderResponse>> getById(@PathVariable UUID id) {
Order order = orderService.findById(id);
return ResponseEntity.ok(assembler.toModel(order));
}
@GetMapping
public ResponseEntity<PagedModel<EntityModel<OrderResponse>>> list(
Pageable pageable,
PagedResourcesAssembler<Order> pagedAssembler) {
Page<Order> orders = orderService.findAll(pageable);
PagedModel<EntityModel<OrderResponse>> pagedModel =
pagedAssembler.toModel(orders, assembler);
return ResponseEntity.ok(pagedModel);
}
}OrderModelAssembler.javatemplates
Source on GitHub
package com.example.order.controller;
import com.example.order.dto.response.OrderResponse;
import com.example.order.entity.Order;
import com.example.order.entity.OrderStatus;
import org.springframework.hateoas.EntityModel;
import org.springframework.hateoas.server.RepresentationModelAssembler;
import org.springframework.lang.NonNull;
import org.springframework.stereotype.Component;
import static org.springframework.hateoas.server.mvc.WebMvcLinkBuilder.linkTo;
import static org.springframework.hateoas.server.mvc.WebMvcLinkBuilder.methodOn;
@Component
public class OrderModelAssembler
implements RepresentationModelAssembler<Order, EntityModel<OrderResponse>> {
@Override
@NonNull
public EntityModel<OrderResponse> toModel(@NonNull Order order) {
EntityModel<OrderResponse> model = EntityModel.of(
OrderResponse.from(order),
// Self link — always present
linkTo(methodOn(OrderController.class).getById(order.getId())).withSelfRel(),
// Collection link
linkTo(methodOn(OrderController.class).list(null, null)).withRel("orders"));
// Conditional action links based on current state
OrderStatus status = order.getStatus();
if (status == OrderStatus.PENDING) {
model.add(linkTo(methodOn(OrderController.class)
.confirm(order.getId())).withRel("confirm"));
model.add(linkTo(methodOn(OrderController.class)
.cancel(order.getId())).withRel("cancel"));
}
if (status == OrderStatus.CONFIRMED) {
model.add(linkTo(methodOn(OrderController.class)
.ship(order.getId())).withRel("ship"));
}
if (status == OrderStatus.SHIPPED) {
model.add(linkTo(methodOn(OrderController.class)
.deliver(order.getId())).withRel("deliver"));
}
return model;
}
}Verification scope
- Compiled sources
- No
- Runtime checks
- No
Coverage describes checked-in fixtures, not a guarantee for every snippet or project.
Coverage matrix & agent evidenceADD TO YOUR AGENTInstall Boot 3 skills
Install Boot 3 skills
claude plugin marketplace add rrezartprebreza/spring-boot-skills
claude plugin install spring-boot-3-skills@spring-boot-skillscodex plugin marketplace add rrezartprebreza/spring-boot-skills
codex plugin add spring-boot-3-skills@spring-boot-skills