Spring Boot Skills
GitHub
API Design

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.md

Dependency

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-hateoas</artifactId>
</dependency>
  • self — always, on every resource response
  • collection — link back to the list endpoint
  • related resources — when a client commonly needs to navigate to them
  • actions — 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 PagedResourcesAssembler for 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 extend RepresentationModel
  • Agent puts link logic in controller — extract to RepresentationModelAssembler
  • Agent manually builds pagination links — use PagedResourcesAssembler instead
  • Agent forgets self link — every resource must have a self link
FROM THE REPOSITORY

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 evidence
ADD TO YOUR AGENT

Install Boot 3 skills

claude plugin marketplace add rrezartprebreza/spring-boot-skills
claude plugin install spring-boot-3-skills@spring-boot-skills