Course topics

By WebNest Studio

Spring Boot Tutorial

Hypermedia APIs with Spring HATEOAS

In most JSON APIs, clients hard-code every URL and every rule about what they are allowed to do next. HATEOAS — Hypermedia As The Engine Of Application State — takes a different approach: each response includes links to related resources and to the actions currently possible. An order that can still be cancelled includes a cancel link; a shipped order does not. Clients follow links instead of constructing URLs.

Spring HATEOAS makes building such responses straightforward. This lesson explains the idea, adds self and relation links with WebMvcLinkBuilder, builds collection responses, uses representation model assemblers to keep controllers clean, shows conditional links for state transitions, and discusses when hypermedia is worth it.

Why Hypermedia

Links make an API more self-describing and let the server evolve URLs without breaking clients that follow links. Conditional links move business rules ("can this order be cancelled?") to the server, so web and mobile clients stay consistent. HATEOAS is the top level of the Richardson Maturity Model (see the best-practices lesson). The trade-off is larger responses and more client logic to interpret links, so many internal APIs skip it while public, long-lived APIs benefit most.

Core Types

Spring HATEOAS provides representation models that carry links:

  • EntityModel<T> — wraps one object plus links.
  • CollectionModel<T> — wraps a collection plus links.
  • PagedModel<T> — a page of results with first/prev/next/last links (built with PagedResourcesAssembler).
  • Link and LinkRelation — a URL with a relation name such as self, orders or cancel.
  • WebMvcLinkBuilder.linkTo(methodOn(Controller.class).method(args)) — builds links from controller methods, so URLs are never hard-coded.

HAL: The Default Format

Spring HATEOAS renders responses in HAL (application/hal+json): links appear under _links and embedded collections under _embedded. Other media types such as HAL-FORMS (which also describes the fields of available actions) can be enabled when needed.

Representation Model Assemblers

Building links in every controller method gets repetitive. A RepresentationModelAssembler converts a domain object or DTO into an EntityModel with its links in one place, and is reused by all endpoints that return that type.

Examples

Dependency and a resource with self and related links

Java
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-hateoas</artifactId>
</dependency>

import static org.springframework.hateoas.server.mvc.WebMvcLinkBuilder.*;

@RestController
@RequestMapping("/api/users")
public class UserController {

    private final UserService users;

    public UserController(UserService users) {
        this.users = users;
    }

    @GetMapping("/{id}")
    public EntityModel<UserDto> get(@PathVariable Long id) {
        UserDto user = users.get(id);
        return EntityModel.of(user,
            linkTo(methodOn(UserController.class).get(id)).withSelfRel(),
            linkTo(methodOn(UserController.class).all()).withRel("users"),
            linkTo(methodOn(PostController.class).postsOf(id)).withRel("posts"));
    }

    @GetMapping
    public CollectionModel<EntityModel<UserDto>> all() {
        List<EntityModel<UserDto>> list = users.all().stream()
            .map(u -> EntityModel.of(u, linkTo(methodOn(UserController.class).get(u.id())).withSelfRel()))
            .toList();
        return CollectionModel.of(list, linkTo(methodOn(UserController.class).all()).withSelfRel());
    }
}
Output
GET /api/users/7   Accept: application/hal+json
{
  "id": 7,
  "name": "Asha Rao",
  "_links": {
    "self":  { "href": "http://localhost:8080/api/users/7" },
    "users": { "href": "http://localhost:8080/api/users" },
    "posts": { "href": "http://localhost:8080/api/users/7/posts" }
  }
}

An assembler with conditional state-transition links

Java
@Component
public class OrderModelAssembler implements RepresentationModelAssembler<OrderDto, EntityModel<OrderDto>> {

    @Override
    public EntityModel<OrderDto> toModel(OrderDto order) {
        EntityModel<OrderDto> model = EntityModel.of(order,
            linkTo(methodOn(OrderController.class).get(order.id())).withSelfRel(),
            linkTo(methodOn(OrderController.class).all(null)).withRel("orders"));

        // Only offer actions that are valid in the current state
        if (order.status() == OrderStatus.PLACED) {
            model.add(linkTo(methodOn(OrderController.class).cancel(order.id())).withRel("cancel"));
            model.add(linkTo(methodOn(OrderController.class).pay(order.id())).withRel("pay"));
        }
        if (order.status() == OrderStatus.SHIPPED) {
            model.add(linkTo(methodOn(OrderController.class).track(order.id())).withRel("track"));
        }
        return model;
    }
}

@GetMapping("/{id}")
public EntityModel<OrderDto> get(@PathVariable Long id) {
    return assembler.toModel(orders.get(id));
}
Output
Order 41 (PLACED):  "_links": { "self": {...}, "orders": {...}, "cancel": {...}, "pay": {...} }
Order 42 (SHIPPED): "_links": { "self": {...}, "orders": {...}, "track": {...} }
(The client shows a Cancel button only when a "cancel" link is present.)

Paged collections with navigation links

Java
@GetMapping
public PagedModel<EntityModel<OrderDto>> all(Pageable pageable) {
    Page<OrderDto> page = orders.page(pageable);
    return pagedAssembler.toModel(page, assembler);    // PagedResourcesAssembler<OrderDto> injected
}
Output
GET /api/orders?page=1&size=2
{
  "_embedded": { "orderDtoList": [ {...}, {...} ] },
  "_links": {
    "first": { "href": "http://localhost:8080/api/orders?page=0&size=2" },
    "prev":  { "href": "http://localhost:8080/api/orders?page=0&size=2" },
    "self":  { "href": "http://localhost:8080/api/orders?page=1&size=2" },
    "next":  { "href": "http://localhost:8080/api/orders?page=2&size=2" },
    "last":  { "href": "http://localhost:8080/api/orders?page=9&size=2" }
  },
  "page": { "size": 2, "totalElements": 20, "totalPages": 10, "number": 1 }
}

Common Mistakes

  • Hard-coding URLs in links instead of using linkTo(methodOn(...)), so links break when mappings change.
  • Adding every possible action link regardless of state, defeating the purpose of conditional links.
  • Forgetting forward-headers support behind a proxy, so links contain internal hostnames or http:// URLs.
  • Adopting HATEOAS for a small internal API where clients will never use the links.
  • Mixing HAL responses and plain JSON inconsistently across endpoints of the same API.

Key Points to Remember

  • HATEOAS adds links to responses so clients discover related resources and allowed actions.
  • spring-boot-starter-hateoas provides EntityModel, CollectionModel, PagedModel and WebMvcLinkBuilder.
  • Responses use HAL by default: _links and _embedded.
  • RepresentationModelAssembler centralises link creation; conditional links express state transitions.
  • Use hypermedia for public, evolving APIs where its flexibility pays off.

Practice the examples

Change an input, predict the result, then compare it with the output. Explain why the result changes.

Use your local JDK or project IDE for these examples. Codelab currently runs Python and HTML/CSS/JavaScript; framework examples may need project dependencies.