Back to blog

API Versioning & HATEOAS in Spring Boot

javaspring-bootrest-apihateoasapi-designbackend
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


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/1

Default 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/1

Custom 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

StrategyURL ChangesCacheabilityBrowser TestableComplexityExample
URI Path✅ Yes✅ Easy✅ YesLow/api/v1/users
Custom Header❌ No⚠️ Varies by header❌ NoMediumX-API-Version: 1
Content Negotiation❌ No✅ By Accept header❌ NoHighAccept: application/vnd.api.v1+json
Query Parameter✅ Yes✅ Easy✅ YesLow/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

  1. Support at most 2-3 active versions — maintaining more becomes expensive
  2. Deprecation policy — announce deprecation, give clients a migration timeline
  3. 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);
    }
}
  1. 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/orders

The 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?

BenefitWithout HATEOASWith HATEOAS
Client couplingHardcoded URLsFollows links dynamically
API evolutionClient breaks on URL changesClient adapts automatically
DiscoverabilityRead docs to find endpointsNavigate from any response
State transitionsClient manages workflowServer guides valid actions
DocumentationExternal docs requiredAPI 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 resources
  • EntityModel — wraps a single entity with links
  • CollectionModel — wraps a collection with links
  • WebMvcLinkBuilder — type-safe link generation
  • RepresentationModelAssembler — 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();
    }
}

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
    }
}
@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());
    }
}
@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 stable
// ❌ 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
);
// ❌ 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 resource

Best Practices Checklist

PracticeDescription
Version earlyPlan versioning from day one, even if you start with v1
Deprecation headersInclude Sunset and X-API-Deprecated headers
Max 2-3 versionsSunset old versions on a clear timeline
Self links alwaysEvery resource must include a self link
Conditional linksOnly show valid actions for current state
Use assemblersKeep link logic out of controllers
Test linksVerify links exist (or don't) in every test
Document versionsIntegrate versioning with OpenAPI docs
HAL formatUse 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:

Related Spring Boot Posts:

REST API Fundamentals:


📬 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.