import org.springframework.hateoas.RepresentationModel;
import java.math.BigDecimal;
public class ProductModel extends RepresentationModel<ProductModel> {
private final Long id;
private final String name;
private final BigDecimal price;
public ProductModel(Long id, String name, BigDecimal price) {
this.id = id;
this.name = name;
this.price = price;
}
public Long getId() {
return id;
}
public String getName() {
return name;
}
public BigDecimal getPrice() {
return price;
}
}
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import java.math.BigDecimal;
@Entity
public class Product {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String name;
private BigDecimal price;
protected Product() {
}
public Product(String name, BigDecimal price) {
this.name = name;
this.price = price;
}
public Long getId() {
return id;
}
public String getName() {
return name;
}
public BigDecimal getPrice() {
return price;
}
}
import org.springframework.hateoas.server.mvc.RepresentationModelAssemblerSupport;
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 ProductModelAssembler
extends RepresentationModelAssemblerSupport<Product, ProductModel> {
public ProductModelAssembler() {
super(ProductController.class, ProductModel.class);
}
@Override
public ProductModel toModel(Product product) {
ProductModel model = new ProductModel(
product.getId(),
product.getName(),
product.getPrice());
model.add(linkTo(methodOn(ProductController.class)
.getProduct(product.getId()))
.withSelfRel());
model.add(linkTo(methodOn(ProductController.class)
.listProducts(null, null))
.withRel("collection"));
return model;
}
}
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.web.PageableDefault;
import org.springframework.data.web.PagedResourcesAssembler;
import org.springframework.hateoas.PagedModel;
import org.springframework.http.ResponseEntity;
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;
@RestController
@RequestMapping("/api/products")
public class ProductController {
private final ProductRepository repository;
private final ProductModelAssembler assembler;
public ProductController(ProductRepository repository, ProductModelAssembler assembler) {
this.repository = repository;
this.assembler = assembler;
}
@GetMapping
public PagedModel<ProductModel> listProducts(
@PageableDefault(size = 20) Pageable pageable,
PagedResourcesAssembler<Product> pagedAssembler) {
Page<Product> page = repository.findAll(pageable);
return pagedAssembler.toModel(page, assembler);
}
@GetMapping("/{id}")
public ResponseEntity<ProductModel> getProduct(@PathVariable Long id) {
return repository.findById(id)
.map(assembler::toModel)
.map(ResponseEntity::ok)
.orElse(ResponseEntity.notFound().build());
}
}
This snippet shows how a Spring Boot controller returns a page of results wrapped as a HATEOAS PagedModel, so clients navigate via hypermedia links instead of guessing URL parameters. The core idea of HATEOAS is that a response carries the controls needed to move around the API — self, next, prev, first, last — so pagination state lives in the payload rather than in client-side URL construction.
In Product entity, the JPA entity is a plain aggregate with an id, name, and price. It exists mainly so the repository can return a Page<Product>; nothing about the entity is coupled to the representation.
ProductModelAssembler implements RepresentationModelAssembler, the standard Spring HATEOAS hook for converting a domain object into a RepresentationModel. Its toModel builds a ProductModel and attaches a self link generated with linkTo(methodOn(...)), which reflects over the controller method so the URL is never hardcoded. Centralizing link creation here keeps every product representation consistent and avoids duplicating URI logic across endpoints.
ProductController is where pagination and hypermedia meet. It accepts a Spring Pageable, queries the repository, then delegates to a PagedResourcesAssembler. That assembler is the key piece: toModel(page, assembler) turns a Page into a PagedModel, embedding each item with its self link and adding the navigational links plus a page metadata block (size, total elements, total pages, number). The @GetMapping returning PagedModel<ProductModel> means the framework serializes it as HAL, including _links and _embedded.
The main trade-off is that offset pagination via Pageable is simple and portable but can drift or skip rows when the underlying data changes between page fetches, and deep offsets get slow because the database must scan and discard earlier rows. For large or frequently mutated datasets, keyset (cursor) pagination is more stable, though it complicates last-link generation. A subtle pitfall is forgetting to expose the assembler as a bean or bypassing methodOn, which produces brittle string URLs. This approach is worth reaching for when an API should be self-descriptive and evolvable, letting clients follow next until it disappears rather than encoding page math themselves.
Related snips
class PostsController < ApplicationController
def index
@posts = Post.includes(:author)
.order(created_at: :desc)
.page(params[:page])
.per(10)
Turbo Frames: infinite scroll with lazy-loading frame
type User {
id: ID!
name: String!
email: String!
posts: [Post!]!
createdAt: String!
GraphQL API with Spring Boot
class SignupForm
include ActiveModel::Model
include ActiveModel::Attributes
attribute :account_name, :string
attribute :email, :string
Shallow Controller, Deep Params: Form Object Pattern
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
static targets = ["form"]
static values = { delay: { type: Number, default: 250 } }
Debounced live search with Stimulus + Turbo Streams
package com.example.starter.config;
import com.example.starter.properties.CustomProperties;
import com.example.starter.service.CustomService;
import org.springframework.boot.autoconfigure.AutoConfiguration;
import org.springframework.boot.autoconfigure.condition.ConditionalOnClass;
Custom Spring Boot starters
package com.example.demo.config;
import org.apache.kafka.clients.consumer.ConsumerConfig;
import org.apache.kafka.clients.producer.ProducerConfig;
import org.apache.kafka.common.serialization.StringDeserializer;
import org.apache.kafka.common.serialization.StringSerializer;
Messaging with Apache Kafka
Share this code
Here's the card — post it anywhere.