← Back to list

HATEOAS Links in Spring Boot REST APIs

Hypermedia driven REST APIs can be confusing, largely because responses do more than return plain JSON data. HATEOAS (Hypermedia as the…

Alexander Obregon · 2026-01-29 22:32 · 28 claps · 13.7 min read
#spring-boot #java #hateoas #programming #software-development
Open on Medium ↗
Wiki topics: 💻 · Programming

HATEOAS Links in Spring Boot REST APIs

Image Source

Image Source

Hypermedia driven REST APIs can be confusing, largely because responses do more than return plain JSON data. HATEOAS (Hypermedia as the Engine of Application State) adds a navigation layer on top of resource representations so clients discover available actions by following links in the response instead of stitching URLs together in code. Spring Boot integrates with Spring HATEOAS to focus on link creation and representation types, so controllers wrap domain objects in models that carry both data and links. With that arrangement in place, clients follow links for paging, jump to related resources, and see which state transitions are valid for a given resource, all driven by the server’s responses.

I publish free articles like this daily, if you want to support my work and get access to exclusive content and weekly recaps, consider subscribing to my Substack.

HATEOAS in REST APIs with Spring Boot

In a HATEOAS focused REST API, the response payload carries both domain data and a small map of link relations that act as a navigation table for the client. Spring HATEOAS plugs into Spring MVC so controller methods can return model types that hold a payload plus a list of links, and the framework inspects controller mappings to turn those links into full URLs. When a request arrives with an Accept header for application/hal+json, Spring Boot serializes those models into HAL, placing link relations in a _links section and, when needed, embedded resources in an _embedded section. In Spring Boot’s default Spring HATEOAS setup, requests that accept application/json also receive an application/hal+json response unless spring.hateoas.use-hal-as-default-json-media-type is set to false. Clients then read relations like self, next, or domain specific names from _links, follow them through regular HTTP requests, and move through paging or workflow steps based on what the server published in that response.

What HATEOAS Means For Clients

Clients that follow HATEOAS treat a REST API less like a fixed list of hard coded URLs and more like a web site made of machine readable links. A client still needs initial knowledge such as the entry point URL and the media type, but once the first response arrives, navigation comes from link relations. Instead of assembling strings like /customers/17/orders inside application code, a client walks through relations such as self, orders, or next that appear in a _links section in the JSON.

HAL, the Hypertext Application Language, is a compact convention for JSON hypermedia that pairs nicely with HATEOAS. HAL based responses usually carry a _links object that maps relation names to objects with href fields, and an _embedded object for nested resources when needed. Spring HATEOAS supports HAL through the application/hal+json media type, which Spring Boot configures through its HATEOAS starter so REST controllers can return model types and let the framework take care of rendering links and embedded items. Clients that send an Accept header with this media type receive HAL JSON with _links and _embedded populated from the server side models.

Take this small HAL response for a single resource:

{
  "id": 7,
  "name": "Alex",
  "email": "alex@example.com",
  "_links": {
    "self": {
      "href": "https://api.example.com/customers/7"
    },
    "orders": {
      "href": "https://api.example.com/customers/7/orders"
    }
  }
}

That kind of structure tells a client where to find the customer resource again through the self relation and where to go for related data through the orders relation. A mobile app or browser based client does not need to know the exact path layout in advance, as long as it knows which relation names matter and how to follow them.

Navigation does not stop at single resources. A HAL based page of results adds links for page flow such as next, prev, first, and last, along with metadata about the current page. Spring projects that use Spring HATEOAS with Spring Data often rely on a page wrapper from Spring HATEOAS that carries these links and metadata in one place, which means clients can move through pages by chasing next links until they go away. That removes the need to reverse engineer query parameters from documentation or separate diagrams, because the running service publishes valid links for the current state of the data.

Spring HATEOAS also supports relation discovery from responses, so a client can inspect link relations dynamically instead of guessing which fields exist. While many consumer applications still encode expectations about relation names such as self and next, the intent with HATEOAS is that a protocol between client and server can evolve as new relations appear and old ones change semantics, without hard binding clients to a specific URL pattern.

Seeing a very small Spring MVC controller that returns a hypermedia friendly representation gives a sense of how this works in code:

import org.springframework.hateoas.EntityModel;
import org.springframework.hateoas.server.mvc.WebMvcLinkBuilder;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RestController;

@RestController
class ProfileController {

    @GetMapping("/profiles/{username}")
    EntityModel<ProfileDto> getProfile(@PathVariable String username) {
        ProfileDto profile = new ProfileDto(username, "Kaitlyn", "kaitlyn@example.com");

        EntityModel<ProfileDto> model = EntityModel.of(profile);

        model.add(
                WebMvcLinkBuilder.linkTo(
                        WebMvcLinkBuilder.methodOn(ProfileController.class)
                                .getProfile(username)
                ).withSelfRel()
        );

        model.add(
                WebMvcLinkBuilder.linkTo(
                        WebMvcLinkBuilder.methodOn(ProfileController.class)
                                .getFollowers(username)
                ).withRel("followers")
        );

        return model;
    }

    @GetMapping("/profiles/{username}/followers")
    EntityModel<String> getFollowers(@PathVariable String username) {
        return EntityModel.of("followers for " + username);
    }
}

Clients that call GET /profiles/alex and ask for HAL JSON see both the profile fields and the _links section that came from the EntityModel and its attached Link instances. Consumers that trust the followers relation do not need to hard code /profiles/{username}/followers, because the link already carries the full URI.

Spring HATEOAS Building Blocks

Spring HATEOAS builds on top of Spring MVC with a small set of model types and helpers that capture both resource state and navigation data. Controllers still work with domain entities and DTOs, but return wrapper types that carry links, metadata, and sometimes embedded content. Spring MVC projects typically add spring-boot-starter-hateoas, which brings in Spring HATEOAS and configures HAL JSON rendering and link discovery with Spring Boot auto-configuration. For Spring WebFlux, Spring Boot recommends adding org.springframework.hateoas:spring-hateoas alongside spring-boot-starter-webflux instead of using spring-boot-starter-hateoas.

Representation of single resources often starts with RepresentationModel, a base class for representations that carry links and can also include your own fields, and EntityModel<T>, which wraps an existing payload of type T with a set of links. Collection endpoints rely on CollectionModel<T> for simple lists and PagedModel<T> for lists backed by Spring Data pages, where PagedModel keeps both items and page metadata. The Link type carries a relation name and an href, and helper methods from WebMvcLinkBuilder such as linkTo and methodOn create links that stay in sync with controller mappings instead of hand written paths.

Custom model classes can extend RepresentationModel to express API level entry points or domain specific views that carry their own fields and link relations. This helps keep controller code small while collecting link building logic in model or assembler classes.

import org.springframework.hateoas.RepresentationModel;

public class ApiRootModel extends RepresentationModel<ApiRootModel> {

    private final String serviceName;
    private final String version;

    public ApiRootModel(String serviceName, String version) {
        this.serviceName = serviceName;
        this.version = version;
    }

    public String getServiceName() {
        return serviceName;
    }

    public String getVersion() {
        return version;
    }
}

That model can act as the payload for an index style endpoint that gives clients their first set of links:

import org.springframework.hateoas.server.mvc.WebMvcLinkBuilder;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController
class ApiRootController {

    @GetMapping("/")
    ApiRootModel root() {
        ApiRootModel model = new ApiRootModel("Customer Service", "1.0");

        model.add(
                WebMvcLinkBuilder.linkTo(
                        WebMvcLinkBuilder.methodOn(CustomerController.class)
                                .getCustomer(1L)
                ).withRel("sample-customer")
        );

        model.add(
                WebMvcLinkBuilder.linkTo(
                        WebMvcLinkBuilder.methodOn(CustomerController.class)
                                .listCustomers()
                ).withRel("customers")
        );

        return model;
    }
}

Clients that start at the root path see a small payload with metadata about the service and link relations that lead into customer data. That entry point no longer needs separate documentation listing every URL, because the initial response already exposes relations that matter for navigation.

Model assembly becomes repetitive if each controller method creates EntityModel instances by hand. Spring HATEOAS addresses that by providing the RepresentationModelAssembler<T, R> interface. An implementation of this interface knows how to turn a domain object of type T into a representation type R, usually adding links and perhaps embedding related models. Controllers can then call the assembler instead of adding links on their own, which keeps link construction in one place.

import org.springframework.hateoas.EntityModel;
import org.springframework.hateoas.server.RepresentationModelAssembler;
import org.springframework.stereotype.Component;

import static org.springframework.hateoas.server.mvc.WebMvcLinkBuilder.linkTo;
import static org.springframework.hateoas.server.mvc.WebMvcLinkBuilder.methodOn;

@Component
class ArticleModelAssembler
        implements RepresentationModelAssembler<Article, EntityModel<Article>> {

    @Override
    public EntityModel<Article> toModel(Article article) {
        EntityModel<Article> model = EntityModel.of(article);

        model.add(
                linkTo(methodOn(ArticleController.class)
                        .getArticle(article.getId()))
                        .withSelfRel()
        );

        model.add(
                linkTo(methodOn(ArticleController.class)
                        .listArticles())
                        .withRel("articles")
        );

        if (article.isPublished()) {
            model.add(
                    linkTo(methodOn(ArticleController.class)
                            .getComments(article.getId()))
                            .withRel("comments")
            );
        }

        return model;
    }
}

This assembler adds links that depend on the state of the Article entity, such as a comments relation only when the article is published. Controllers that work with ArticleModelAssembler can return EntityModel<Article> and trust that links appear in a consistent way across endpoints.

Collection level models rely on CollectionModel or PagedModel to express both the set of items and links at the collection level. CollectionModel holds a collection of EntityModel<T> or related types, plus its own _links section. Let’s take a look at a basic factory method that wraps a list and attaches a self link shows how this comes together:

import org.springframework.hateoas.CollectionModel;
import org.springframework.hateoas.EntityModel;

import java.util.List;

import static org.springframework.hateoas.server.mvc.WebMvcLinkBuilder.linkTo;
import static org.springframework.hateoas.server.mvc.WebMvcLinkBuilder.methodOn;

class ArticleCollectionFactory {

    static CollectionModel<EntityModel<Article>> toCollection(List<Article> articles,
                                                              ArticleModelAssembler assembler) {

        List<EntityModel<Article>> models = articles.stream()
                .map(assembler::toModel)
                .toList();

        CollectionModel<EntityModel<Article>> collection = CollectionModel.of(models);

        collection.add(
                linkTo(methodOn(ArticleController.class)
                        .listArticles())
                        .withSelfRel()
        );

        return collection;
    }
}

Services that work with Spring Data paging rely on PagedModel instead of CollectionModel so that page information and links for moving between pages stay alongside the items. PagedResourcesAssembler from Spring Data Web support takes a Page<T> and a RepresentationModelAssembler<T, R> and produces a PagedModel<R> without extra manual link assembly. That combination allows controllers to focus on repository queries while Spring HATEOAS handles pagination links and metadata in a consistent way across endpoints that expose pages.

Adding Hypermedia Links In Practice

Real benefits of HATEOAS show up when links are attached to real domain data instead of toy examples. Spring HATEOAS ties into Spring MVC so controllers can return wrappers such as EntityModel, CollectionModel, or PagedModel, and those wrappers hold both the payload and the links that express navigation. After those wrappers are in place, most of the repetitive work around link construction and pagination can move into assemblers and helper classes, while controllers focus on finding data and returning the right model type.

Single Resource Responses With Links

Single resources are usually where clients start interacting with hypermedia. A client fetches one object, then wants to know what to do next. That might mean reloading the same object later, jumping to a parent resource, or moving to a related collection such as orders for a customer or comments for an article. Spring HATEOAS handles this by wrapping the domain object in EntityModel and attaching one or more Link instances with relation names and target URLs.

Let’s consider a customer domain type. To keep things simple and on point, say the service holds customers from Eau Claire, Wisconsin and nearby cities:

import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.Id;

@Entity
public class Customer {

    @Id
    @GeneratedValue
    private Long id;

    private String fullName;

    private String email;

    private String city;

    protected Customer() {
    }

    public Customer(String fullName, String email, String city) {
        this.fullName = fullName;
        this.email = email;
        this.city = city;
    }

    public Long getId() {
        return id;
    }

    public String getFullName() {
        return fullName;
    }

    public String getEmail() {
        return email;
    }

    public String getCity() {
        return city;
    }
}

That entity on its own has no link information. Spring HATEOAS adds the hypermedia part through an assembler that knows how to wrap Customer in EntityModel<Customer> and attach links produced by linkTo and methodOn:

import org.springframework.hateoas.EntityModel;
import org.springframework.hateoas.server.RepresentationModelAssembler;
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 CustomerModelAssembler
        implements RepresentationModelAssembler<Customer, EntityModel<Customer>> {

    @Override
    public EntityModel<Customer> toModel(Customer customer) {
        EntityModel<Customer> model = EntityModel.of(customer);

        model.add(
                linkTo(methodOn(CustomerController.class)
                        .getCustomer(customer.getId()))
                        .withSelfRel()
        );

        model.add(
                linkTo(methodOn(CustomerController.class)
                        .listCustomers())
                        .withRel("customers")
        );

        model.add(
                linkTo(methodOn(CustomerController.class)
                        .getOrdersForCustomer(customer.getId()))
                        .withRel("orders")
        );

        return model;
    }
}

The assembler calls the controller method signatures through methodOn, which allows Spring MVC to reuse existing @RequestMapping metadata to build URLs. That keeps links tied directly to controller mappings, so route changes are less likely to fall out of sync with hypermedia links.

Customer controllers can delegate link work to the assembler and keep their logic focused on finding and returning the right resource:

import org.springframework.hateoas.EntityModel;
import org.springframework.hateoas.CollectionModel;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

import java.util.List;

import static org.springframework.hateoas.server.mvc.WebMvcLinkBuilder.linkTo;
import static org.springframework.hateoas.server.mvc.WebMvcLinkBuilder.methodOn;

@RestController
@RequestMapping("/customers")
public class CustomerController {

    private final CustomerRepository repository;
    private final CustomerModelAssembler assembler;

    public CustomerController(CustomerRepository repository,
                              CustomerModelAssembler assembler) {
        this.repository = repository;
        this.assembler = assembler;
    }

    @GetMapping("/{id}")
    public EntityModel<Customer> getCustomer(@PathVariable Long id) {
        Customer customer = repository.findById(id)
                .orElseThrow(() -> new CustomerNotFoundException(id));

        return assembler.toModel(customer);
    }

    @GetMapping
    public CollectionModel<EntityModel<Customer>> listCustomers() {
        List<EntityModel<Customer>> models = repository.findAll().stream()
                .map(assembler::toModel)
                .toList();

        CollectionModel<EntityModel<Customer>> collection =
                CollectionModel.of(models);

        collection.add(
                linkTo(methodOn(CustomerController.class)
                        .listCustomers())
                        .withSelfRel()
        );

        return collection;
    }

    @GetMapping("/{id}/orders")
    public CollectionModel<EntityModel<OrderSummary>> getOrdersForCustomer(
            @PathVariable Long id) {
        // implementation omitted
        return CollectionModel.empty();
    }
}

Serialization of EntityModel<Customer> with the HATEOAS starter on the classpath produces HAL JSON with customer fields at the top level and link relations under _links. A client that receives this payload reads self for reloading the customer later, customers for navigation back to the collection, and orders to move into order data owned by this customer. Sometimes a single resource contains extra computed views that are not part of the JPA entity. In those cases, a dedicated DTO class can wrap those fields while still being carried inside an EntityModel. That preserves the same structure of domain data plus links, while giving the server room to shape the JSON payload for clients without altering the persistence model.

Pagination Links With PagedModel

Pagination is where HATEOAS links save clients from rebuilding query parameter rules. With a typical offset based paging scheme, a consumer normally has to know names like page and size, along with how sort options are encoded. HATEOAS puts links such as next and prev into the response so clients follow those links and let the server assemble query parameters.

Spring Data integrates with Spring HATEOAS through PagedResourcesAssembler. This helper accepts a Page<T> and a RepresentationModelAssembler<T, R> and produces a PagedModel<R> that holds item models, page metadata, and a set of pagination links. In Spring Boot, the assembler can be injected into a controller that accepts a Pageable argument:

import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.web.PagedResourcesAssembler;
import org.springframework.hateoas.EntityModel;
import org.springframework.hateoas.PagedModel;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController
public class OrderPageController {

    private final OrderRepository repository;
    private final OrderModelAssembler assembler;
    private final PagedResourcesAssembler<Order> pagedAssembler;

    public OrderPageController(OrderRepository repository,
                               OrderModelAssembler assembler,
                               PagedResourcesAssembler<Order> pagedAssembler) {
        this.repository = repository;
        this.assembler = assembler;
        this.pagedAssembler = pagedAssembler;
    }

    @GetMapping("/orders")
    public PagedModel<EntityModel<Order>> listOrders(Pageable pageable) {

        Page<Order> page = repository.findAll(pageable);

        return pagedAssembler.toModel(page, assembler);
    }
}

Page information stays in the Page<Order> instance, while link construction and wrapping into EntityModel<Order> happens through the assembler and the PagedResourcesAssembler. The resulting PagedModel has items, a page section with size and total counts, and a _links section that holds self, next, and related pagination links based on the current request URL and page metadata.

Let’s take a look at a trimmed HAL response from GET /orders?page=0&size=2 that clients receive:

{
  "_embedded": {
    "orderList": [
      {
        "id": 10,
        "status": "PAID",
        "_links": {
          "self": { "href": "https://api.example.com/orders/10" },
          "customer": { "href": "https://api.example.com/customers/7" }
        }
      },
      {
        "id": 11,
        "status": "CREATED",
        "_links": {
          "self": { "href": "https://api.example.com/orders/11" },
          "customer": { "href": "https://api.example.com/customers/9" }
        }
      }
    ]
  },
  "_links": {
    "self": { "href": "https://api.example.com/orders?page=0&size=2" },
    "next": { "href": "https://api.example.com/orders?page=1&size=2" }
  },
  "page": {
    "size": 2,
    "totalElements": 5,
    "totalPages": 3,
    "number": 0
  }
}

Clients no longer need to hard code how to build URLs for subsequent pages. A mobile app or SPA can read next from _links, call that URL, then check for next again on the following page. When next disappears, the last page has been reached.

Sorting works the same way. When Pageable contains sort information, the links that PagedResourcesAssembler builds will reflect that sort configuration. For example, a call to /orders?page=0&size=2&sort=status,asc produces self and next links with the same sort query parameter, so the client does not need special logic to preserve sorting rules while moving through pages.

Sometimes a service exposes both HATEOAS and plain JSON views for the same data. That can be done by returning Page<Order> for certain endpoints and PagedModel<EntityModel<Order>> for others, combined with content negotiation or separate paths. In HATEOAS oriented endpoints, the ability to move through pages by following relations stays central, because clients that honor hypermedia avoid coupling themselves tightly to paging query parameter conventions.

State Transition Links For Workflows

Resource state changes are another place where hypermedia links provide guidance to clients. An order, a ticket, or a registration request moves through a series of states, and only certain transitions are valid from a given state. Rather than asking clients to guess which action endpoints are safe, Spring HATEOAS makes it natural to expose only links that match the current state of the resource.

Take an order lifecycle with states such as CREATED, PAID, SHIPPED, and CANCELLED. Business rules might allow payment and cancellation while an order is new, shipment only after payment, and no actions once canceled or shipped. An enum encapsulates these states:

public enum OrderStatus {
    CREATED,
    PAID,
    SHIPPED,
    CANCELLED
}

The order entity keeps track of its current status and stores monetary data:

import jakarta.persistence.Entity;
import jakarta.persistence.EnumType;
import jakarta.persistence.Enumerated;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.Id;

import java.math.BigDecimal;

@Entity
public class Order {

    @Id
    @GeneratedValue
    private Long id;

    @Enumerated(EnumType.STRING)
    private OrderStatus status;

    private BigDecimal total;

    protected Order() {
    }

    public Order(OrderStatus status, BigDecimal total) {
        this.status = status;
        this.total = total;
    }

    public Long getId() {
        return id;
    }

    public OrderStatus getStatus() {
        return status;
    }

    public BigDecimal getTotal() {
        return total;
    }

    public void markPaid() {
        this.status = OrderStatus.PAID;
    }

    public void markShipped() {
        this.status = OrderStatus.SHIPPED;
    }

    public void cancel() {
        this.status = OrderStatus.CANCELLED;
    }
}

Hypermedia links that drive the workflow live in an assembler that checks OrderStatus and attaches relations for actions that are valid for that state. That way, an order in CREATED status exposes pay and cancel links, while an order in PAID status exposes a ship link, and a shipped or canceled order exposes no state changing links at all:

import org.springframework.hateoas.EntityModel;
import org.springframework.hateoas.server.RepresentationModelAssembler;
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<Order>> {

    @Override
    public EntityModel<Order> toModel(Order order) {
        EntityModel<Order> model = EntityModel.of(order);

        model.add(
                linkTo(methodOn(OrderWorkflowController.class)
                        .getOrder(order.getId()))
                        .withSelfRel()
        );

        model.add(
                linkTo(methodOn(OrderWorkflowController.class)
                        .listOrders())
                        .withRel("orders")
        );

        if (order.getStatus() == OrderStatus.CREATED) {
            model.add(
                    linkTo(methodOn(OrderWorkflowController.class)
                            .pay(order.getId()))
                            .withRel("pay")
            );
            model.add(
                    linkTo(methodOn(OrderWorkflowController.class)
                            .cancel(order.getId()))
                            .withRel("cancel")
            );
        }

        if (order.getStatus() == OrderStatus.PAID) {
            model.add(
                    linkTo(methodOn(OrderWorkflowController.class)
                            .ship(order.getId()))
                            .withRel("ship")
            );
        }

        return model;
    }
}

Workflow controllers process those actions and always return the assembled model so the client gets fresh state and fresh link relations after each change:

import org.springframework.hateoas.CollectionModel;
import org.springframework.hateoas.EntityModel;
import org.springframework.web.bind.annotation.*;

import java.util.List;

@RestController
@RequestMapping("/workflow/orders")
public class OrderWorkflowController {

    private final OrderRepository repository;
    private final OrderModelAssembler assembler;

    public OrderWorkflowController(OrderRepository repository,
                                   OrderModelAssembler assembler) {
        this.repository = repository;
        this.assembler = assembler;
    }

    @GetMapping("/{id}")
    public EntityModel<Order> getOrder(@PathVariable Long id) {
        Order order = repository.findById(id)
                .orElseThrow(() -> new OrderNotFoundException(id));

        return assembler.toModel(order);
    }

    @GetMapping
    public CollectionModel<EntityModel<Order>> listOrders() {
        List<EntityModel<Order>> models = repository.findAll().stream()
                .map(assembler::toModel)
                .toList();

        return CollectionModel.of(models);
    }

    @PostMapping("/{id}/pay")
    public EntityModel<Order> pay(@PathVariable Long id) {
        Order order = repository.findById(id)
                .orElseThrow(() -> new OrderNotFoundException(id));

        if (order.getStatus() != OrderStatus.CREATED) {
            throw new IllegalStateException("Order cannot be paid in status " + order.getStatus());
        }

        order.markPaid();
        repository.save(order);

        return assembler.toModel(order);
    }

    @PostMapping("/{id}/cancel")
    public EntityModel<Order> cancel(@PathVariable Long id) {
        Order order = repository.findById(id)
                .orElseThrow(() -> new OrderNotFoundException(id));

        if (order.getStatus() != OrderStatus.CREATED) {
            throw new IllegalStateException("Order cannot be cancelled in status " + order.getStatus());
        }

        order.cancel();
        repository.save(order);

        return assembler.toModel(order);
    }

    @PostMapping("/{id}/ship")
    public EntityModel<Order> ship(@PathVariable Long id) {
        Order order = repository.findById(id)
                .orElseThrow(() -> new OrderNotFoundException(id));

        if (order.getStatus() != OrderStatus.PAID) {
            throw new IllegalStateException("Order cannot be shipped in status " + order.getStatus());
        }

        order.markShipped();
        repository.save(order);

        return assembler.toModel(order);
    }
}

Clients that follow hypermedia start by calling GET /workflow/orders/{id}, inspect _links, and look for relations such as pay or cancel. Sending a POST request to the pay link moves the order to PAID status, and the response body then carries updated links, in this case a ship relation but no longer a pay or cancel relation. That arrangement lets both client and server keep workflow rules in one place, with the server publishing allowed transitions and the client acting on those published links instead of hard coding separate decision logic about which actions are legal in which state.

Conclusion

HATEOAS support in Spring Boot rests on a small group of model types, link builders, and assemblers that turn plain controller methods into hypermedia endpoints. RepresentationModel, EntityModel, CollectionModel, and PagedModel carry links alongside data, while helpers such as linkTo, methodOn, and PagedResourcesAssembler keep URLs aligned with mappings and paging rules. Controllers stay focused on fetching and updating domain objects, and clients rely on HAL responses to follow self, next, and workflow relations such as pay or ship, so navigation, pagination, and state changes all flow from links that the server emits in real time.

  1. *Spring Boot Documentation For Spring HATEOAS*
  2. *Spring HATEOAS Reference Documentation*
  3. *Spring HATEOAS API Javadoc*
  4. *Spring Data PagedResourcesAssembler Documentation*
  5. *HAL Hypertext Application Language Specification*

Thanks for reading! If you found this helpful, highlighting, clapping, or leaving a comment really helps me out.

Spring Boot icon by Icons8

Spring Boot icon by Icons8


메타데이터
post_id
2c33e3a9f03f
slug
hateoas-links-in-spring-boot-rest-apis-2c33e3a9f03f
url
https://medium.com/@AlexanderObregon/hateoas-links-in-spring-boot-rest-apis-2c33e3a9f03f
canonical_url
https://medium.com/@AlexanderObregon/hateoas-links-in-spring-boot-rest-apis-2c33e3a9f03f
author_url
https://medium.com/@AlexanderObregon
status
ok
fetched_at
2026-06-09 15:37:30