What is HATEOAS?
HATEOAS (Hypermedia as the Engine of Application State) is a constraint of REST architecture where API responses include hypermedia links that guide clients on what actions they can take next.
Instead of hardcoding URLs in client applications, HATEOAS allows servers to tell clients which endpoints are available and how to interact with resources.
Why HATEOAS?
Self-Documenting APIs
Clients discover available actions from the API responses
Reduces need for extensive external documentation
Links show valid state transitions
Loose Coupling
Clients don’t need to hardcode URLs
Server can change URLs without breaking clients
Easier API evolution
Discoverability
Clients can navigate the API by following links
Similar to how humans navigate web pages
Reduces client-side logic
Without HATEOAS: {
"id" : 1 ,
"name" : "Laptop" ,
"price" : 999.99
}
Client must know: “To get this product, use GET /products/1” With HATEOAS: {
"id" : 1 ,
"name" : "Laptop" ,
"price" : 999.99 ,
"_links" : {
"self" : {
"href" : "http://localhost:8080/products/1"
},
"products" : {
"href" : "http://localhost:8080/products?page=0&size=20"
}
}
}
Server tells client: “Here’s how to access this resource and related resources”
Spring HATEOAS Implementation
The ecommerce API uses Spring HATEOAS to add hypermedia links to REST responses. The implementation uses three key components:
EntityModel : Wraps a single resource with links
PagedModel : Wraps paginated collections with links
Assemblers : Transform entities into hypermedia resources
EntityModel
EntityModel<T> wraps a resource object and adds hypermedia links.
// Controller method returning EntityModel
@ GetMapping ( "/products/{id}" )
EntityModel < ProductView > findOne (@ PathVariable Long id) {
Product product = productRepository . findById (id)
. orElseThrow (() -> new ProductNotFoundException (id));
return productAssembler . toModel (product);
}
Response Structure: {
"id" : 1 ,
"name" : "Wireless Mouse" ,
"description" : "Ergonomic wireless mouse" ,
"price" : 29.99 ,
"stockQuantity" : 150 ,
"_links" : {
"self" : {
"href" : "http://localhost:8080/products/1"
},
"products" : {
"href" : "http://localhost:8080/products?page=0&size=20"
}
}
}
Key Points:
The resource data (id, name, price, etc.) is at the root level
Links are nested under _links (HAL format)
self link points to this specific resource
Additional links guide to related resources
PagedModel
PagedModel<T> wraps paginated collections with navigation links.
// Controller method returning PagedModel
@ GetMapping ( "/products" )
PagedModel < EntityModel < ProductView >> findAll ( Pageable pageable) {
Page < Product > page = productRepository . findAll (pageable);
Page < ProductView > productViewPage = page . map (ProductView ::new );
return pagedResourceAssembler . toModel (productViewPage);
}
Response Structure: {
"_embedded" : {
"productViewList" : [
{
"id" : 1 ,
"name" : "Wireless Mouse" ,
"price" : 29.99 ,
"_links" : {
"self" : { "href" : "http://localhost:8080/products/1" }
}
},
{
"id" : 2 ,
"name" : "Keyboard" ,
"price" : 79.99 ,
"_links" : {
"self" : { "href" : "http://localhost:8080/products/2" }
}
}
]
},
"_links" : {
"first" : { "href" : "http://localhost:8080/products?page=0&size=20" },
"self" : { "href" : "http://localhost:8080/products?page=0&size=20" },
"next" : { "href" : "http://localhost:8080/products?page=1&size=20" },
"last" : { "href" : "http://localhost:8080/products?page=4&size=20" }
},
"page" : {
"size" : 20 ,
"totalElements" : 100 ,
"totalPages" : 5 ,
"number" : 0
}
}
Key Features:
Resources nested under _embedded
Each item has its own links
Pagination metadata in page object
Navigation links: first, self, next, last
Spring HATEOAS uses the HAL (Hypertext Application Language) format by default, which is a widely-adopted standard for hypermedia APIs.
RepresentationModelAssembler Pattern
Assemblers implement RepresentationModelAssembler to convert entities into HATEOAS resources. This pattern separates entity structure from API representation.
ProductAssembler
Implementation
Breakdown
Usage in Controller
@ Component
public class ProductAssembler
implements RepresentationModelAssembler < Product , EntityModel < ProductView >> {
@ Override
public EntityModel < ProductView > toModel ( Product entity ) {
ProductView productView = new ProductView (entity);
return EntityModel . of (productView,
linkTo ( methodOn ( ProductController . class )
. findOne ( productView . getId ())). withSelfRel (),
linkTo ( methodOn ( ProductController . class )
. findAll ( PageRequest . of ( 0 , 20 ))). withRel ( "products" )
);
}
}
Step 1: Convert Entity to View ProductView productView = new ProductView (entity);
Creates a Data Transfer Object (DTO) that excludes sensitive fields and formats data for API responses. Step 2: Add Self Link linkTo ( methodOn ( ProductController . class )
. findOne ( productView . getId ())). withSelfRel ()
methodOn(): Gets a proxy of the controller
findOne(): References the endpoint method
withSelfRel(): Marks as the “self” link
Result: {"self": {"href": "/products/1"}}
Step 3: Add Related Links linkTo ( methodOn ( ProductController . class )
. findAll ( PageRequest . of ( 0 , 20 ))). withRel ( "products" )
Links to the products collection
Result: {"products": {"href": "/products?page=0&size=20"}}
@ RestController
public class ProductController {
private final ProductRepository productRepository ;
private final ProductAssembler productAssembler ;
@ GetMapping ( "/products/{id}" )
EntityModel < ProductView > findOne (@ PathVariable Long id ) {
Product product = productRepository . findById (id)
. orElseThrow (() -> new ProductNotFoundException (id));
// Assembler transforms entity to hypermedia resource
return productAssembler . toModel (product);
}
}
Using methodOn() instead of hardcoded URLs ensures links stay correct even if endpoint paths change.
UserAssembler
UserAssembler Implementation
@ Component
public class UserAssembler
implements RepresentationModelAssembler < User , EntityModel < UserView >> {
@ Override
public EntityModel < UserView > toModel ( User user ) {
UserView userView = new UserView ();
userView . setId ( user . getId ());
userView . setName ( user . getName ());
userView . setEmail ( user . getEmail ());
userView . setRole ( user . getRole ());
userView . setCreatedAt ( user . getCreatedAt ());
return EntityModel . of (userView,
linkTo ( methodOn ( UserController . class )
. findOne ( user . getId ())). withSelfRel (),
linkTo ( methodOn ( UserController . class )
. findAll ()). withRel ( "Users" )
);
}
}
Key Differences from ProductAssembler:
Manually sets UserView fields (doesn’t use constructor)
Omits password field for security
Links to non-paginated /users endpoint
Uses “Users” as the collection link relation name
Link Relations
Link relations (rel) describe the relationship between the current resource and the linked resource.
Standard Link Relations
Relation Description Example selfThe current resource GET /products/1collectionParent collection GET /productsfirstFirst page of collection GET /products?page=0lastLast page of collection GET /products?page=4nextNext page GET /products?page=2prevPrevious page GET /products?page=0
Custom Link Relations
// Add custom link with descriptive relation name
linkTo ( methodOn ( ProductController . class )
. findAll ( PageRequest . of ( 0 , 20 ))). withRel ( "products" )
Result:
{
"_links" : {
"products" : {
"href" : "http://localhost:8080/products?page=0&size=20"
}
}
}
Choose meaningful relation names that describe what the link leads to (e.g., “products”, “Users”, “categories”).
Complete Response Examples
Single Product Response
{
"id" : 1 ,
"name" : "Wireless Mouse" ,
"description" : "Ergonomic wireless mouse with USB receiver" ,
"price" : 29.99 ,
"stockQuantity" : 150 ,
"categories" : [
{
"id" : 1 ,
"category" : {
"id" : 1 ,
"name" : "Electronics"
}
},
{
"id" : 2 ,
"category" : {
"id" : 3 ,
"name" : "Accessories"
}
}
],
"createdAt" : "2026-03-01T10:00:00" ,
"updatedAt" : "2026-03-03T14:30:00" ,
"_links" : {
"self" : {
"href" : "http://localhost:8080/products/1"
},
"products" : {
"href" : "http://localhost:8080/products?page=0&size=20"
}
}
}
User Collection Response
{
"_embedded" : {
"userViewList" : [
{
"id" : 1 ,
"name" : "John Doe" ,
"email" : "[email protected] " ,
"role" : "CUSTOMER" ,
"createdAt" : "2026-01-15T08:30:00" ,
"_links" : {
"self" : {
"href" : "http://localhost:8080/users/1"
},
"Users" : {
"href" : "http://localhost:8080/users"
}
}
},
{
"id" : 2 ,
"name" : "Jane Smith" ,
"email" : "[email protected] " ,
"role" : "ADMIN" ,
"createdAt" : "2026-01-20T12:00:00" ,
"_links" : {
"self" : {
"href" : "http://localhost:8080/users/2"
},
"Users" : {
"href" : "http://localhost:8080/users"
}
}
}
]
},
"_links" : {
"self" : {
"href" : "http://localhost:8080/users"
}
}
}
Note : This uses CollectionModel (not PagedModel) as there’s no pagination on the users endpoint.
Created Resource Response
Request: {
"name" : "Mechanical Keyboard" ,
"description" : "RGB backlit mechanical gaming keyboard" ,
"price" : 129.99 ,
"stockQuantity" : 50 ,
"category" : [ "Electronics" , "Gaming" ]
}
Response (201 Created): HTTP / 1.1 201 Created
Location : http://localhost:8080/products/42
Content-Type : application/hal+json
{
"id" : 42 ,
"name" : "Mechanical Keyboard" ,
"description" : "RGB backlit mechanical gaming keyboard" ,
"price" : 129.99 ,
"stockQuantity" : 50 ,
"categories" : [
{
"id" : 15 ,
"category" : { "id" : 1 , "name" : "Electronics" }
},
{
"id" : 16 ,
"category" : { "id" : 5 , "name" : "Gaming" }
}
],
"createdAt" : "2026-03-03T16:45:00" ,
"updatedAt" : "2026-03-03T16:45:00" ,
"_links" : {
"self" : {
"href" : "http://localhost:8080/products/42"
},
"products" : {
"href" : "http://localhost:8080/products?page=0&size=20"
}
}
}
Key Features:
201 Created status code
Location header with new resource URL
Full resource representation in response body
HATEOAS links for navigation
Building Links Programmatically
Spring HATEOAS provides multiple ways to create links:
Using methodOn()
Using linkTo()
Manual Links
import static org.springframework.hateoas.server.mvc.WebMvcLinkBuilder. * ;
// Type-safe link building
linkTo ( methodOn ( ProductController . class )
. findOne ( product . getId ())). withSelfRel ()
Benefits:
Type-safe (compilation errors if method changes)
Automatically handles path variables
Refactoring-friendly
// Link to controller without method
linkTo ( ProductController . class ). withRel ( "products" )
// Result: /products
Use case : When linking to the controller’s base pathimport org.springframework.hateoas.Link;
// Create link from URI string
Link link = Link . of ( "http://example.com/products/1" , "self" );
// Or use URI template
Link templated = Link . of ( "/products/{id}" , "product" )
. expand (productId);
Use case : External URLs or complex link structures
Always prefer methodOn() for internal API links to maintain type safety and consistency.
PagedResourcesAssembler
For paginated endpoints, inject PagedResourcesAssembler to automatically create pagination links:
@ RestController
public class ProductController {
private final ProductRepository productRepository ;
private final PagedResourcesAssembler < ProductView > pagedResourceAssembler ;
public ProductController (
ProductRepository productRepository ,
PagedResourcesAssembler < ProductView > pagedResourceAssembler
) {
this . productRepository = productRepository;
this . pagedResourceAssembler = pagedResourceAssembler;
}
@ GetMapping ( "/products" )
PagedModel < EntityModel < ProductView >> findAll ( Pageable pageable ) {
Page < Product > page = productRepository . findAll (pageable);
Page < ProductView > productViewPage = page . map (ProductView ::new );
// Automatically adds first, prev, self, next, last links
return pagedResourceAssembler . toModel (productViewPage);
}
}
What it provides:
Pagination metadata (page.size, page.totalElements, etc.)
Navigation links (first, prev, self, next, last)
Proper HAL format with _embedded
PagedResourcesAssembler is generic over the DTO type (ProductView), not the entity type.
Best Practices
// ✓ Good: Use view/DTO objects
EntityModel < ProductView > toModel ( Product entity) {
ProductView view = new ProductView (entity);
return EntityModel . of (view, /* links */ );
}
// ✗ Bad: Expose entities directly
EntityModel < Product > toModel ( Product entity) {
return EntityModel . of (entity, /* links */ );
}
Reasons:
Hide sensitive fields (passwords, internal IDs)
Prevent lazy-loading exceptions
Decouple API from database schema
Control JSON serialization
Consistent Link Relations
// ✓ Good: Consistent naming
. withRel ( "products" ) // Always lowercase
. withRel ( "product-categories" )
// ✗ Bad: Inconsistent naming
. withRel ( "Products" ) // Mixed case
. withRel ( "product_categories" ) // Underscores
Follow a consistent naming convention for link relations across your API.
// ✓ Good: Links to related resources
EntityModel . of (productView,
linkTo ( methodOn ( ProductController . class ). findOne (id)). withSelfRel (),
linkTo ( methodOn ( ProductController . class ). findAll (pageable)). withRel ( "products" ),
linkTo ( methodOn ( CategoryController . class ). findByProduct (id)). withRel ( "categories" ),
linkTo ( methodOn ( UserController . class ). findOne (userId)). withRel ( "seller" )
);
// ✗ Bad: Only self link
EntityModel . of (productView,
linkTo ( methodOn ( ProductController . class ). findOne (id)). withSelfRel ()
);
Include links to related resources that clients might need.
Client Usage Example
Clients can navigate the API by following links:
// 1. Start at root/products
fetch ( 'http://localhost:8080/products' )
. then ( res => res . json ())
. then ( data => {
// 2. Extract first product's self link
const firstProductUrl = data . _embedded . productViewList [ 0 ]. _links . self . href ;
// 3. Fetch specific product
return fetch ( firstProductUrl );
})
. then ( res => res . json ())
. then ( product => {
console . log ( 'Product:' , product . name );
console . log ( 'Back to all products:' , product . _links . products . href );
});
Clients should prefer using link relations over hardcoded URLs for flexibility and maintainability.
Next Steps
Architecture Review the overall system architecture
Data Models Explore the entities behind these resources