Skip to main content

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

Spring HATEOAS Implementation

The ecommerce API uses Spring HATEOAS to add hypermedia links to REST responses. The implementation uses three key components:
  1. EntityModel: Wraps a single resource with links
  2. PagedModel: Wraps paginated collections with links
  3. 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

@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")
        );
    }
}
Using methodOn() instead of hardcoded URLs ensures links stay correct even if endpoint paths change.

UserAssembler

@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 (rel) describe the relationship between the current resource and the linked resource.
RelationDescriptionExample
selfThe current resourceGET /products/1
collectionParent collectionGET /products
firstFirst page of collectionGET /products?page=0
lastLast page of collectionGET /products?page=4
nextNext pageGET /products?page=2
prevPrevious pageGET /products?page=0
// 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
Spring HATEOAS provides multiple ways to create 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
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

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

Build docs developers (and LLMs) love