API Versioning & HATEOAS in Spring Boot

Introduction
Your API is live, clients are using it, and now you need to make breaking changes. How do you evolve your API without breaking existing consumers? And how do you make your API self-discoverable so clients don't need to hardcode URLs?
These are two critical challenges in API design: versioning (managing change) and HATEOAS (enabling discoverability). Together, they form the foundation of mature, production-ready REST APIs.
What You'll Learn
✅ Understand why API versioning matters and when to version
✅ Implement URI-based versioning (/api/v1/, /api/v2/)
✅ Implement header-based versioning (custom headers, Accept header)
✅ Implement content negotiation versioning (media types)
✅ Compare versioning strategies with trade-offs
✅ Understand HATEOAS and the Richardson Maturity Model
✅ Build hypermedia-driven APIs with Spring HATEOAS
✅ Create RepresentationModel, EntityModel, and CollectionModel responses
✅ Use WebMvcLinkBuilder for type-safe link generation
✅ Implement RepresentationModelAssembler for reusable conversions
✅ Combine versioning with HATEOAS for production APIs
Prerequisites
- Java 17+ installed
- Spring Boot basics (Getting Started guide)
- REST API fundamentals (REST API Best Practices)
- Familiarity with Spring MVC controllers (REST API Advanced Patterns)
1. Why API Versioning Matters
The Breaking Change Problem
Imagine you have a User endpoint returning:
{
"name": "John Doe",
"email": "john@example.com"
}Now you need to split name into firstName and lastName. If you just change the response, every existing client breaks. API versioning solves this by letting old and new clients coexist.
When to Version
Version when you make breaking changes:
- Removing or renaming fields
- Changing field data types
- Restructuring response format
- Changing endpoint behavior
Don't version for:
- Adding new optional fields (backward-compatible)
- Adding new endpoints
- Bug fixes
- Performance improvements
Versioning Strategies Overview
2. URI Path Versioning
The most widely used approach. The version number is part of the URL path.
Project Setup
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-hateoas</artifactId>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>
</dependencies>Domain Models
Define separate DTOs for each API version:
// Shared entity
@Entity
@Table(name = "users")
@Data
@NoArgsConstructor
@AllArgsConstructor
public class User {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String firstName;
private String lastName;
private String email;
private String phone;
private LocalDate dateOfBirth;
@CreationTimestamp
private LocalDateTime createdAt;
}// V1 DTO — legacy format with combined name
@Data
@AllArgsConstructor
public class UserV1Dto {
private Long id;
private String name; // "John Doe" (combined)
private String email;
}
// V2 DTO — modern format with separated fields
@Data
@AllArgsConstructor
public class UserV2Dto {
private Long id;
private String firstName;
private String lastName;
private String email;
private String phone;
private LocalDate dateOfBirth;
}Version-Specific Controllers
@RestController
@RequestMapping("/api/v1/users")
public class UserV1Controller {
private final UserService userService;
public UserV1Controller(UserService userService) {
this.userService = userService;
}
@GetMapping
public ResponseEntity<List<UserV1Dto>> getAllUsers() {
List<UserV1Dto> users = userService.getAllUsersV1();
return ResponseEntity.ok(users);
}
@GetMapping("/{id}")
public ResponseEntity<UserV1Dto> getUser(@PathVariable Long id) {
UserV1Dto user = userService.getUserV1(id);
return ResponseEntity.ok(user);
}
@PostMapping
public ResponseEntity<UserV1Dto> createUser(@RequestBody CreateUserV1Request request) {
UserV1Dto user = userService.createUserV1(request);
return ResponseEntity.status(HttpStatus.CREATED).body(user);
}
}@RestController
@RequestMapping("/api/v2/users")
public class UserV2Controller {
private final UserService userService;
public UserV2Controller(UserService userService) {
this.userService = userService;
}
@GetMapping
public ResponseEntity<List<UserV2Dto>> getAllUsers(
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "20") int size) {
List<UserV2Dto> users = userService.getAllUsersV2(page, size);
return ResponseEntity.ok(users);
}
@GetMapping("/{id}")
public ResponseEntity<UserV2Dto> getUser(@PathVariable Long id) {
UserV2Dto user = userService.getUserV2(id);
return ResponseEntity.ok(user);
}
@PostMapping
public ResponseEntity<UserV2Dto> createUser(
@Valid @RequestBody CreateUserV2Request request) {
UserV2Dto user = userService.createUserV2(request);
return ResponseEntity.status(HttpStatus.CREATED).body(user);
}
}Service Layer with Version Mapping
@Service
public class UserService {
private final UserRepository userRepository;
public UserService(UserRepository userRepository) {
this.userRepository = userRepository;
}
// V1: Combined name format
public UserV1Dto getUserV1(Long id) {
User user = userRepository.findById(id)
.orElseThrow(() -> new ResourceNotFoundException("User", "id", id));
return new UserV1Dto(
user.getId(),
user.getFirstName() + " " + user.getLastName(),
user.getEmail()
);
}
// V2: Full user details
public UserV2Dto getUserV2(Long id) {
User user = userRepository.findById(id)
.orElseThrow(() -> new ResourceNotFoundException("User", "id", id));
return new UserV2Dto(
user.getId(),
user.getFirstName(),
user.getLastName(),
user.getEmail(),
user.getPhone(),
user.getDateOfBirth()
);
}
public List<UserV1Dto> getAllUsersV1() {
return userRepository.findAll().stream()
.map(u -> new UserV1Dto(u.getId(),
u.getFirstName() + " " + u.getLastName(),
u.getEmail()))
.toList();
}
public List<UserV2Dto> getAllUsersV2(int page, int size) {
return userRepository.findAll(PageRequest.of(page, size)).stream()
.map(u -> new UserV2Dto(u.getId(),
u.getFirstName(), u.getLastName(),
u.getEmail(), u.getPhone(), u.getDateOfBirth()))
.toList();
}
}Testing URI versioning:
# V1 — legacy clients
curl http://localhost:8080/api/v1/users/1
# {"id":1,"name":"John Doe","email":"john@example.com"}
# V2 — modern clients
curl http://localhost:8080/api/v2/users/1
# {"id":1,"firstName":"John","lastName":"Doe","email":"john@example.com","phone":"+1234567890","dateOfBirth":"1990-05-15"}3. Header-Based Versioning
The version is specified in a custom request header, keeping URLs clean.
Custom Header Approach
@RestController
@RequestMapping("/api/users")
public class UserHeaderVersionController {
private final UserService userService;
public UserHeaderVersionController(UserService userService) {
this.userService = userService;
}
@GetMapping(value = "/{id}", headers = "X-API-Version=1")
public ResponseEntity<UserV1Dto> getUserV1(@PathVariable Long id) {
return ResponseEntity.ok(userService.getUserV1(id));
}
@GetMapping(value = "/{id}", headers = "X-API-Version=2")
public ResponseEntity<UserV2Dto> getUserV2(@PathVariable Long id) {
return ResponseEntity.ok(userService.getUserV2(id));
}
}Testing:
# V1
curl -H "X-API-Version: 1" http://localhost:8080/api/users/1
# V2
curl -H "X-API-Version: 2" http://localhost:8080/api/users/1Default Version with Interceptor
Handle missing version headers gracefully by defaulting to the latest version:
@Component
public class ApiVersionInterceptor implements HandlerInterceptor {
private static final String VERSION_HEADER = "X-API-Version";
private static final String DEFAULT_VERSION = "2";
@Override
public boolean preHandle(HttpServletRequest request,
HttpServletResponse response,
Object handler) {
if (request.getHeader(VERSION_HEADER) == null) {
// Wrap request to inject default version header
MutableHttpServletRequest mutableRequest =
new MutableHttpServletRequest(request);
mutableRequest.putHeader(VERSION_HEADER, DEFAULT_VERSION);
}
return true;
}
}@Configuration
public class WebConfig implements WebMvcConfigurer {
private final ApiVersionInterceptor apiVersionInterceptor;
public WebConfig(ApiVersionInterceptor apiVersionInterceptor) {
this.apiVersionInterceptor = apiVersionInterceptor;
}
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(apiVersionInterceptor)
.addPathPatterns("/api/**");
}
}4. Content Negotiation Versioning
The most RESTful approach — version is encoded in the Accept header using custom media types.
Media Type Versioning
@RestController
@RequestMapping("/api/users")
public class UserContentNegotiationController {
private final UserService userService;
public UserContentNegotiationController(UserService userService) {
this.userService = userService;
}
@GetMapping(
value = "/{id}",
produces = "application/vnd.myapi.v1+json"
)
public ResponseEntity<UserV1Dto> getUserV1(@PathVariable Long id) {
return ResponseEntity.ok(userService.getUserV1(id));
}
@GetMapping(
value = "/{id}",
produces = "application/vnd.myapi.v2+json"
)
public ResponseEntity<UserV2Dto> getUserV2(@PathVariable Long id) {
return ResponseEntity.ok(userService.getUserV2(id));
}
}Testing:
# V1
curl -H "Accept: application/vnd.myapi.v1+json" http://localhost:8080/api/users/1
# V2
curl -H "Accept: application/vnd.myapi.v2+json" http://localhost:8080/api/users/1Custom Media Type Configuration
Register custom media types for proper content negotiation:
@Configuration
public class MediaTypeConfig implements WebMvcConfigurer {
@Override
public void configureContentNegotiation(ContentNegotiationConfigurer configurer) {
configurer
.defaultContentType(MediaType.APPLICATION_JSON)
.mediaType("v1", MediaType.valueOf("application/vnd.myapi.v1+json"))
.mediaType("v2", MediaType.valueOf("application/vnd.myapi.v2+json"));
}
}5. Comparing Versioning Strategies
| Strategy | URL Changes | Cacheability | Browser Testable | Complexity | Example |
|---|---|---|---|---|---|
| URI Path | ✅ Yes | ✅ Easy | ✅ Yes | Low | /api/v1/users |
| Custom Header | ❌ No | ⚠️ Varies by header | ❌ No | Medium | X-API-Version: 1 |
| Content Negotiation | ❌ No | ✅ By Accept header | ❌ No | High | Accept: application/vnd.api.v1+json |
| Query Parameter | ✅ Yes | ✅ Easy | ✅ Yes | Low | /api/users?version=1 |
When to Use Each
Recommendation: Use URI path versioning for most projects. It's the simplest, most visible, and easiest for consumers to adopt. GitHub, Twitter, Stripe, and Google all use it.
Best Practices for Any Strategy
- Support at most 2-3 active versions — maintaining more becomes expensive
- Deprecation policy — announce deprecation, give clients a migration timeline
- Version response headers — always include the version in responses:
@RestController
@RequestMapping("/api/v2/users")
public class UserV2Controller {
@GetMapping("/{id}")
public ResponseEntity<UserV2Dto> getUser(@PathVariable Long id) {
UserV2Dto user = userService.getUserV2(id);
return ResponseEntity.ok()
.header("X-API-Version", "2")
.header("X-API-Deprecated", "false")
.header("Sunset", "2027-06-01") // RFC 8594
.body(user);
}
}- Document all versions — integrate with OpenAPI (API Documentation with Swagger)
6. Understanding HATEOAS
What is HATEOAS?
HATEOAS (Hypermedia as the Engine of Application State) is a REST constraint where the server tells the client what actions are available by including links in responses.
Instead of the client hardcoding URLs like:
GET /api/users/1
PUT /api/users/1
GET /api/users/1/ordersThe server response itself provides the links:
{
"id": 1,
"firstName": "John",
"lastName": "Doe",
"email": "john@example.com",
"_links": {
"self": { "href": "http://localhost:8080/api/users/1" },
"update": { "href": "http://localhost:8080/api/users/1" },
"delete": { "href": "http://localhost:8080/api/users/1" },
"orders": { "href": "http://localhost:8080/api/users/1/orders" },
"all-users": { "href": "http://localhost:8080/api/users" }
}
}The client follows links rather than constructing URLs — just like browsing the web.
The Richardson Maturity Model
Most REST APIs are at Level 2. HATEOAS brings you to Level 3 — a truly RESTful API.
Why HATEOAS?
| Benefit | Without HATEOAS | With HATEOAS |
|---|---|---|
| Client coupling | Hardcoded URLs | Follows links dynamically |
| API evolution | Client breaks on URL changes | Client adapts automatically |
| Discoverability | Read docs to find endpoints | Navigate from any response |
| State transitions | Client manages workflow | Server guides valid actions |
| Documentation | External docs required | API is self-documenting |
7. Spring HATEOAS Setup
Dependencies
Spring HATEOAS is a dedicated Spring project for building hypermedia-driven APIs:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-hateoas</artifactId>
</dependency>This includes:
RepresentationModel— base class for HATEOAS resourcesEntityModel— wraps a single entity with linksCollectionModel— wraps a collection with linksWebMvcLinkBuilder— type-safe link generationRepresentationModelAssembler— reusable model conversion
Core Concepts
8. Building HATEOAS Responses
EntityModel for Single Resources
@RestController
@RequestMapping("/api/v2/users")
public class UserHateoasController {
private final UserService userService;
public UserHateoasController(UserService userService) {
this.userService = userService;
}
@GetMapping("/{id}")
public EntityModel<UserV2Dto> getUser(@PathVariable Long id) {
UserV2Dto user = userService.getUserV2(id);
return EntityModel.of(user,
// Self link
linkTo(methodOn(UserHateoasController.class).getUser(id))
.withSelfRel(),
// Related resources
linkTo(methodOn(UserHateoasController.class).getAllUsers(0, 20))
.withRel("all-users"),
linkTo(methodOn(OrderController.class).getOrdersByUser(id))
.withRel("orders")
);
}
}Response:
{
"id": 1,
"firstName": "John",
"lastName": "Doe",
"email": "john@example.com",
"phone": "+1234567890",
"dateOfBirth": "1990-05-15",
"_links": {
"self": {
"href": "http://localhost:8080/api/v2/users/1"
},
"all-users": {
"href": "http://localhost:8080/api/v2/users?page=0&size=20"
},
"orders": {
"href": "http://localhost:8080/api/v2/users/1/orders"
}
}
}CollectionModel for Lists
@GetMapping
public CollectionModel<EntityModel<UserV2Dto>> getAllUsers(
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "20") int size) {
List<EntityModel<UserV2Dto>> users = userService.getAllUsersV2(page, size)
.stream()
.map(user -> EntityModel.of(user,
linkTo(methodOn(UserHateoasController.class).getUser(user.getId()))
.withSelfRel()))
.toList();
return CollectionModel.of(users,
linkTo(methodOn(UserHateoasController.class).getAllUsers(page, size))
.withSelfRel(),
linkTo(methodOn(UserHateoasController.class).getAllUsers(page + 1, size))
.withRel("next"),
linkTo(methodOn(UserHateoasController.class).getAllUsers(0, size))
.withRel("first")
);
}Response:
{
"_embedded": {
"userV2DtoList": [
{
"id": 1,
"firstName": "John",
"lastName": "Doe",
"email": "john@example.com",
"_links": {
"self": { "href": "http://localhost:8080/api/v2/users/1" }
}
},
{
"id": 2,
"firstName": "Jane",
"lastName": "Smith",
"email": "jane@example.com",
"_links": {
"self": { "href": "http://localhost:8080/api/v2/users/2" }
}
}
]
},
"_links": {
"self": { "href": "http://localhost:8080/api/v2/users?page=0&size=20" },
"next": { "href": "http://localhost:8080/api/v2/users?page=1&size=20" },
"first": { "href": "http://localhost:8080/api/v2/users?page=0&size=20" }
}
}9. RepresentationModelAssembler
When you need the same entity-to-model conversion in multiple controllers, use a RepresentationModelAssembler:
Creating an Assembler
@Component
public class UserModelAssembler
implements RepresentationModelAssembler<User, EntityModel<UserV2Dto>> {
@Override
public EntityModel<UserV2Dto> toModel(User user) {
UserV2Dto dto = new UserV2Dto(
user.getId(),
user.getFirstName(),
user.getLastName(),
user.getEmail(),
user.getPhone(),
user.getDateOfBirth()
);
return EntityModel.of(dto,
linkTo(methodOn(UserHateoasController.class).getUser(user.getId()))
.withSelfRel(),
linkTo(methodOn(UserHateoasController.class).getAllUsers(0, 20))
.withRel("all-users"),
linkTo(methodOn(OrderController.class).getOrdersByUser(user.getId()))
.withRel("orders")
);
}
}Using the Assembler in Controllers
@RestController
@RequestMapping("/api/v2/users")
public class UserHateoasController {
private final UserService userService;
private final UserModelAssembler assembler;
public UserHateoasController(UserService userService,
UserModelAssembler assembler) {
this.userService = userService;
this.assembler = assembler;
}
@GetMapping("/{id}")
public EntityModel<UserV2Dto> getUser(@PathVariable Long id) {
User user = userService.getUserById(id);
return assembler.toModel(user);
}
@GetMapping
public CollectionModel<EntityModel<UserV2Dto>> getAllUsers(
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "20") int size) {
List<User> users = userService.getAllUsers(page, size);
return assembler.toCollectionModel(users)
.add(linkTo(methodOn(UserHateoasController.class)
.getAllUsers(page, size)).withSelfRel());
}
@PostMapping
public ResponseEntity<EntityModel<UserV2Dto>> createUser(
@Valid @RequestBody CreateUserV2Request request) {
User user = userService.createUser(request);
EntityModel<UserV2Dto> model = assembler.toModel(user);
return ResponseEntity
.created(model.getRequiredLink(IanaLinkRelations.SELF).toUri())
.body(model);
}
@PutMapping("/{id}")
public EntityModel<UserV2Dto> updateUser(
@PathVariable Long id,
@Valid @RequestBody UpdateUserRequest request) {
User user = userService.updateUser(id, request);
return assembler.toModel(user);
}
@DeleteMapping("/{id}")
public ResponseEntity<Void> deleteUser(@PathVariable Long id) {
userService.deleteUser(id);
return ResponseEntity.noContent().build();
}
}10. Conditional Links Based on State
One of HATEOAS's most powerful features is conditional links — showing only the actions that are valid for the current resource state.
Order State Machine Example
Order Entity
@Entity
@Table(name = "orders")
@Data
@NoArgsConstructor
public class Order {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@ManyToOne(fetch = FetchType.LAZY)
private User user;
private BigDecimal totalAmount;
@Enumerated(EnumType.STRING)
private OrderStatus status;
@CreationTimestamp
private LocalDateTime createdAt;
public enum OrderStatus {
CREATED, PAID, SHIPPED, DELIVERED, CANCELLED
}
}Order Assembler with Conditional Links
@Component
public class OrderModelAssembler
implements RepresentationModelAssembler<Order, EntityModel<OrderDto>> {
@Override
public EntityModel<OrderDto> toModel(Order order) {
OrderDto dto = new OrderDto(
order.getId(),
order.getTotalAmount(),
order.getStatus().name(),
order.getCreatedAt()
);
EntityModel<OrderDto> model = EntityModel.of(dto,
// Self link — always present
linkTo(methodOn(OrderController.class).getOrder(order.getId()))
.withSelfRel(),
// User link — always present
linkTo(methodOn(UserHateoasController.class)
.getUser(order.getUser().getId()))
.withRel("user")
);
// Conditional links based on order status
switch (order.getStatus()) {
case CREATED -> {
model.add(linkTo(methodOn(OrderController.class)
.payOrder(order.getId()))
.withRel("pay"));
model.add(linkTo(methodOn(OrderController.class)
.cancelOrder(order.getId()))
.withRel("cancel"));
}
case PAID -> {
model.add(linkTo(methodOn(OrderController.class)
.shipOrder(order.getId()))
.withRel("ship"));
model.add(linkTo(methodOn(OrderController.class)
.cancelOrder(order.getId()))
.withRel("cancel"));
}
case SHIPPED -> {
model.add(linkTo(methodOn(OrderController.class)
.deliverOrder(order.getId()))
.withRel("deliver"));
}
// DELIVERED and CANCELLED — no further actions
}
return model;
}
}Order Controller
@RestController
@RequestMapping("/api/v2/orders")
public class OrderController {
private final OrderService orderService;
private final OrderModelAssembler assembler;
public OrderController(OrderService orderService,
OrderModelAssembler assembler) {
this.orderService = orderService;
this.assembler = assembler;
}
@GetMapping("/{id}")
public EntityModel<OrderDto> getOrder(@PathVariable Long id) {
Order order = orderService.getOrderById(id);
return assembler.toModel(order);
}
@GetMapping("/user/{userId}")
public CollectionModel<EntityModel<OrderDto>> getOrdersByUser(
@PathVariable Long userId) {
List<Order> orders = orderService.getOrdersByUser(userId);
return assembler.toCollectionModel(orders);
}
@PostMapping("/{id}/pay")
public EntityModel<OrderDto> payOrder(@PathVariable Long id) {
Order order = orderService.payOrder(id);
return assembler.toModel(order);
}
@PostMapping("/{id}/ship")
public EntityModel<OrderDto> shipOrder(@PathVariable Long id) {
Order order = orderService.shipOrder(id);
return assembler.toModel(order);
}
@PostMapping("/{id}/deliver")
public EntityModel<OrderDto> deliverOrder(@PathVariable Long id) {
Order order = orderService.deliverOrder(id);
return assembler.toModel(order);
}
@PostMapping("/{id}/cancel")
public EntityModel<OrderDto> cancelOrder(@PathVariable Long id) {
Order order = orderService.cancelOrder(id);
return assembler.toModel(order);
}
}Response for a CREATED order:
{
"id": 42,
"totalAmount": 99.99,
"status": "CREATED",
"createdAt": "2026-03-09T10:30:00",
"_links": {
"self": { "href": "http://localhost:8080/api/v2/orders/42" },
"user": { "href": "http://localhost:8080/api/v2/users/1" },
"pay": { "href": "http://localhost:8080/api/v2/orders/42/pay" },
"cancel": { "href": "http://localhost:8080/api/v2/orders/42/cancel" }
}
}Response for a SHIPPED order:
{
"id": 42,
"totalAmount": 99.99,
"status": "SHIPPED",
"createdAt": "2026-03-09T10:30:00",
"_links": {
"self": { "href": "http://localhost:8080/api/v2/orders/42" },
"user": { "href": "http://localhost:8080/api/v2/users/1" },
"deliver": { "href": "http://localhost:8080/api/v2/orders/42/deliver" }
}
}Notice how the available actions change based on the order state — the client never needs to know the state machine rules.
11. HATEOAS with Pagination
Spring Data's Page integrates seamlessly with Spring HATEOAS through PagedResourcesAssembler:
Paginated HATEOAS Responses
@RestController
@RequestMapping("/api/v2/users")
public class UserPaginatedController {
private final UserRepository userRepository;
private final UserModelAssembler userAssembler;
private final PagedResourcesAssembler<User> pagedAssembler;
public UserPaginatedController(
UserRepository userRepository,
UserModelAssembler userAssembler,
PagedResourcesAssembler<User> pagedAssembler) {
this.userRepository = userRepository;
this.userAssembler = userAssembler;
this.pagedAssembler = pagedAssembler;
}
@GetMapping
public PagedModel<EntityModel<UserV2Dto>> getAllUsers(
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "20") int size,
@RequestParam(defaultValue = "id") String sortBy,
@RequestParam(defaultValue = "asc") String direction) {
Sort sort = direction.equalsIgnoreCase("desc")
? Sort.by(sortBy).descending()
: Sort.by(sortBy).ascending();
Page<User> userPage = userRepository.findAll(
PageRequest.of(page, size, sort));
return pagedAssembler.toModel(userPage, userAssembler);
}
}Response:
{
"_embedded": {
"userV2DtoList": [
{
"id": 1,
"firstName": "John",
"lastName": "Doe",
"_links": { "self": { "href": ".../users/1" } }
}
]
},
"_links": {
"self": { "href": ".../users?page=0&size=20&sortBy=id&direction=asc" },
"next": { "href": ".../users?page=1&size=20&sortBy=id&direction=asc" },
"last": { "href": ".../users?page=4&size=20&sortBy=id&direction=asc" }
},
"page": {
"size": 20,
"totalElements": 100,
"totalPages": 5,
"number": 0
}
}Spring HATEOAS automatically generates first, prev, self, next, and last links based on the current page position.
12. Combining Versioning with HATEOAS
In production, you often need both versioning and HATEOAS. Here's a clean architecture for combining them:
Architecture Overview
V1: Simple JSON (for legacy clients)
@RestController
@RequestMapping("/api/v1/orders")
public class OrderV1Controller {
private final OrderService orderService;
public OrderV1Controller(OrderService orderService) {
this.orderService = orderService;
}
@GetMapping("/{id}")
public ResponseEntity<OrderV1Dto> getOrder(@PathVariable Long id) {
Order order = orderService.getOrderById(id);
OrderV1Dto dto = new OrderV1Dto(
order.getId(),
order.getTotalAmount(),
order.getStatus().name()
);
return ResponseEntity.ok(dto);
}
}V2: HATEOAS-Enabled (for modern clients)
@RestController
@RequestMapping("/api/v2/orders")
public class OrderV2Controller {
private final OrderService orderService;
private final OrderModelAssembler assembler;
public OrderV2Controller(OrderService orderService,
OrderModelAssembler assembler) {
this.orderService = orderService;
this.assembler = assembler;
}
@GetMapping("/{id}")
public EntityModel<OrderDto> getOrder(@PathVariable Long id) {
Order order = orderService.getOrderById(id);
return assembler.toModel(order);
}
@PostMapping("/{id}/pay")
public EntityModel<OrderDto> payOrder(@PathVariable Long id) {
Order order = orderService.payOrder(id);
return assembler.toModel(order);
}
}API Root Resource for Discoverability
Provide a root endpoint that lists all available resources:
@RestController
@RequestMapping("/api/v2")
public class RootController {
@GetMapping
public RepresentationModel<?> root() {
RepresentationModel<?> root = new RepresentationModel<>();
root.add(linkTo(methodOn(UserHateoasController.class)
.getAllUsers(0, 20)).withRel("users"));
root.add(linkTo(methodOn(OrderController.class)
.getOrder(null)).withRel("orders"));
root.add(Link.of("/api/v2/docs").withRel("documentation"));
root.add(Link.of("/actuator/health").withRel("health"));
return root;
}
}Response:
{
"_links": {
"users": { "href": "http://localhost:8080/api/v2/users?page=0&size=20" },
"orders": { "href": "http://localhost:8080/api/v2/orders" },
"documentation": { "href": "/api/v2/docs" },
"health": { "href": "/actuator/health" }
}
}13. Testing HATEOAS APIs
Unit Testing with WebMvcTest
@WebMvcTest(UserHateoasController.class)
class UserHateoasControllerTest {
@Autowired
private MockMvc mockMvc;
@MockBean
private UserService userService;
@MockBean
private UserModelAssembler assembler;
@Test
void getUser_ShouldReturnHateoasResponse() throws Exception {
// Given
User user = new User(1L, "John", "Doe", "john@example.com",
"+1234567890", LocalDate.of(1990, 5, 15), null);
UserV2Dto dto = new UserV2Dto(1L, "John", "Doe",
"john@example.com", "+1234567890", LocalDate.of(1990, 5, 15));
EntityModel<UserV2Dto> model = EntityModel.of(dto,
Link.of("http://localhost/api/v2/users/1").withSelfRel(),
Link.of("http://localhost/api/v2/users").withRel("all-users"));
when(userService.getUserById(1L)).thenReturn(user);
when(assembler.toModel(user)).thenReturn(model);
// When & Then
mockMvc.perform(get("/api/v2/users/1")
.accept(MediaTypes.HAL_JSON))
.andExpect(status().isOk())
.andExpect(jsonPath("$.firstName").value("John"))
.andExpect(jsonPath("$.lastName").value("Doe"))
.andExpect(jsonPath("$._links.self.href").value("http://localhost/api/v2/users/1"))
.andExpect(jsonPath("$._links.all-users.href").value("http://localhost/api/v2/users"))
.andDo(print());
}
@Test
void getUser_ShouldReturn404_WhenNotFound() throws Exception {
when(userService.getUserById(99L))
.thenThrow(new ResourceNotFoundException("User", "id", 99L));
mockMvc.perform(get("/api/v2/users/99"))
.andExpect(status().isNotFound());
}
}Testing Conditional Links
@WebMvcTest(OrderController.class)
class OrderControllerTest {
@Autowired
private MockMvc mockMvc;
@MockBean
private OrderService orderService;
@MockBean
private OrderModelAssembler assembler;
@Test
void createdOrder_ShouldHavePayAndCancelLinks() throws Exception {
Order order = createOrder(OrderStatus.CREATED);
EntityModel<OrderDto> model = createModelWithLinks(order,
"pay", "cancel");
when(orderService.getOrderById(1L)).thenReturn(order);
when(assembler.toModel(order)).thenReturn(model);
mockMvc.perform(get("/api/v2/orders/1"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.status").value("CREATED"))
.andExpect(jsonPath("$._links.pay").exists())
.andExpect(jsonPath("$._links.cancel").exists())
.andExpect(jsonPath("$._links.ship").doesNotExist())
.andExpect(jsonPath("$._links.deliver").doesNotExist());
}
@Test
void shippedOrder_ShouldOnlyHaveDeliverLink() throws Exception {
Order order = createOrder(OrderStatus.SHIPPED);
EntityModel<OrderDto> model = createModelWithLinks(order,
"deliver");
when(orderService.getOrderById(1L)).thenReturn(order);
when(assembler.toModel(order)).thenReturn(model);
mockMvc.perform(get("/api/v2/orders/1"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.status").value("SHIPPED"))
.andExpect(jsonPath("$._links.deliver").exists())
.andExpect(jsonPath("$._links.pay").doesNotExist())
.andExpect(jsonPath("$._links.cancel").doesNotExist());
}
@Test
void deliveredOrder_ShouldHaveNoActionLinks() throws Exception {
Order order = createOrder(OrderStatus.DELIVERED);
EntityModel<OrderDto> model = createModelWithLinks(order);
when(orderService.getOrderById(1L)).thenReturn(order);
when(assembler.toModel(order)).thenReturn(model);
mockMvc.perform(get("/api/v2/orders/1"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.status").value("DELIVERED"))
.andExpect(jsonPath("$._links.self").exists())
.andExpect(jsonPath("$._links.pay").doesNotExist())
.andExpect(jsonPath("$._links.ship").doesNotExist())
.andExpect(jsonPath("$._links.cancel").doesNotExist());
}
}Integration Testing
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@AutoConfigureMockMvc
class UserApiIntegrationTest {
@Autowired
private MockMvc mockMvc;
@Autowired
private UserRepository userRepository;
@BeforeEach
void setUp() {
userRepository.deleteAll();
userRepository.save(new User(null, "John", "Doe",
"john@example.com", "+1234567890",
LocalDate.of(1990, 5, 15), null));
}
@Test
void fullWorkflow_CreateAndRetrieveUser() throws Exception {
// Create user via V2 API
String createRequest = """
{
"firstName": "Jane",
"lastName": "Smith",
"email": "jane@example.com",
"phone": "+0987654321",
"dateOfBirth": "1995-08-20"
}
""";
MvcResult result = mockMvc.perform(post("/api/v2/users")
.contentType(MediaType.APPLICATION_JSON)
.content(createRequest))
.andExpect(status().isCreated())
.andExpect(jsonPath("$._links.self").exists())
.andReturn();
// Extract self link and follow it
String selfLink = JsonPath.read(
result.getResponse().getContentAsString(),
"$._links.self.href");
mockMvc.perform(get(selfLink))
.andExpect(status().isOk())
.andExpect(jsonPath("$.firstName").value("Jane"))
.andExpect(jsonPath("$._links.self.href").value(selfLink));
// Verify V1 API returns combined name
mockMvc.perform(get("/api/v1/users"))
.andExpect(status().isOk())
.andExpect(jsonPath("$[1].name").value("Jane Smith"));
}
}14. Common Pitfalls and Best Practices
Pitfall 1: Too Many Versions
// ❌ Bad — maintaining 5+ versions is a nightmare
@RequestMapping("/api/v1/users") // 2020
@RequestMapping("/api/v2/users") // 2021
@RequestMapping("/api/v3/users") // 2022
@RequestMapping("/api/v4/users") // 2023
@RequestMapping("/api/v5/users") // 2024
// ✅ Good — max 2-3 active versions with deprecation policy
@RequestMapping("/api/v2/users") // Deprecated, sunset: 2026-12-01
@RequestMapping("/api/v3/users") // Current stablePitfall 2: Circular Links
// ❌ Bad — can cause infinite recursion
EntityModel<UserV2Dto> userModel = assembler.toModel(user);
// User links to orders, each order links back to user, etc.
// ✅ Good — use IDs for back-references, not full links
EntityModel<OrderDto> orderModel = EntityModel.of(dto,
linkTo(methodOn(OrderController.class).getOrder(order.getId()))
.withSelfRel(),
linkTo(methodOn(UserHateoasController.class)
.getUser(order.getUser().getId()))
.withRel("user") // Simple link back, not embedded
);Pitfall 3: Hardcoding Links
// ❌ Bad — hardcoded URLs break across environments
model.add(Link.of("http://localhost:8080/api/v2/users/1").withSelfRel());
// ✅ Good — use WebMvcLinkBuilder for type-safe, environment-aware links
model.add(linkTo(methodOn(UserHateoasController.class).getUser(1L))
.withSelfRel());Pitfall 4: HATEOAS Without Purpose
// ❌ Bad — adding links that don't help the client
model.add(Link.of("/api/health").withRel("health"));
model.add(Link.of("/api/metrics").withRel("metrics"));
model.add(Link.of("/api/v2/some-unrelated-endpoint").withRel("random"));
// ✅ Good — links represent meaningful state transitions
model.add(linkTo(methodOn(OrderController.class)
.payOrder(order.getId())).withRel("pay")); // Action the client can take
model.add(linkTo(methodOn(UserHateoasController.class)
.getUser(order.getUserId())).withRel("user")); // Related resourceBest Practices Checklist
| Practice | Description |
|---|---|
| Version early | Plan versioning from day one, even if you start with v1 |
| Deprecation headers | Include Sunset and X-API-Deprecated headers |
| Max 2-3 versions | Sunset old versions on a clear timeline |
| Self links always | Every resource must include a self link |
| Conditional links | Only show valid actions for current state |
| Use assemblers | Keep link logic out of controllers |
| Test links | Verify links exist (or don't) in every test |
| Document versions | Integrate versioning with OpenAPI docs |
| HAL format | Use HAL (Hypertext Application Language) as your hypermedia type |
Summary and Key Takeaways
✅ URI path versioning is the most practical strategy for most APIs
✅ Header-based versioning keeps URLs clean but is harder to test
✅ Content negotiation is the most RESTful but adds complexity
✅ HATEOAS makes APIs self-discoverable — clients follow links, not docs
✅ Spring HATEOAS provides EntityModel, CollectionModel, and WebMvcLinkBuilder
✅ RepresentationModelAssembler keeps link logic reusable and out of controllers
✅ Conditional links guide clients through valid state transitions
✅ PagedResourcesAssembler auto-generates pagination links
✅ Combine versioning + HATEOAS: V1 for legacy, V2 with hypermedia
✅ Test your links — verify presence, absence, and correctness
What's Next?
Now that your API is versioned and self-discoverable, explore these topics:
Continue the Spring Boot series:
- Structured Logging & Centralized Logging - Observability for your API
- Monitoring with Actuator, Prometheus & Grafana - Monitor API health
- Docker & Kubernetes Deployment - Deploy your versioned API
Related Spring Boot Posts:
- REST API Best Practices - API design standards
- REST API Advanced Patterns - Exception handling, validation, pagination
- API Documentation with OpenAPI/Swagger - Document your versioned API
- GraphQL with Spring for GraphQL - Alternative to REST
- Spring Boot Learning Roadmap - Complete learning path
REST API Fundamentals:
- What is REST API? - Complete guide to REST principles
- HTTP Protocol Complete Guide - HTTP methods, status codes, headers
📬 Subscribe to Newsletter
Get the latest blog posts delivered to your inbox every week. No spam, unsubscribe anytime.
We respect your privacy. Unsubscribe at any time.
💬 Comments
Sign in to leave a comment
We'll never post without your permission.