API Versioning & HATEOAS trong Spring Boot

Giới thiệu
API đã live, clients đang dùng, và giờ bạn cần thay đổi breaking changes. Làm sao để phát triển API mà không phá vỡ các consumers hiện tại? Và làm sao để API tự khám phá được, để clients không cần hardcode URLs?
Đây là hai challenge quan trọng trong API design: versioning (quản lý thay đổi) và HATEOAS (cho phép discoverability). Kết hợp lại, chúng tạo nền tảng cho REST API production-ready, trưởng thành.
Bạn sẽ học được gì
✅ Hiểu tại sao API versioning quan trọng và khi nào cần version
✅ Implement URI-based versioning (/api/v1/, /api/v2/)
✅ Implement header-based versioning (custom headers, Accept header)
✅ Implement content negotiation versioning (media types)
✅ So sánh các chiến lược versioning với trade-offs
✅ Hiểu HATEOAS và Richardson Maturity Model
✅ Xây dựng hypermedia-driven API với Spring HATEOAS
✅ Tạo RepresentationModel, EntityModel, và CollectionModel responses
✅ Sử dụng WebMvcLinkBuilder cho type-safe link generation
✅ Implement RepresentationModelAssembler cho reusable conversions
✅ Kết hợp versioning với HATEOAS cho production APIs
Yêu cầu
- Đã cài đặt Java 17+
- Kiến thức cơ bản Spring Boot (Hướng dẫn Bắt đầu)
- Nắm vững REST API (REST API Best Practices)
- Quen thuộc với Spring MVC controllers (REST API Advanced Patterns)
1. Tại sao API Versioning quan trọng
Vấn đề Breaking Change
Tưởng tượng bạn có endpoint User trả về:
{
"name": "John Doe",
"email": "[email protected]"
}Giờ bạn cần tách name thành firstName và lastName. Nếu bạn đổi response luôn, mọi client hiện tại sẽ vỡ. API versioning giải quyết vấn đề này bằng cách cho phép client cũ và mới cùng tồn tại.
Khi nào cần Version
Version khi bạn có breaking changes:
- Xóa hoặc đổi tên fields
- Thay đổi data types của fields
- Tái cấu trúc format response
- Thay đổi behavior của endpoint
Không cần version cho:
- Thêm fields optional mới (backward-compatible)
- Thêm endpoints mới
- Bug fixes
- Cải thiện performance
Tổng quan các chiến lược Versioning
2. URI Path Versioning
Cách tiếp cận phổ biến nhất. Số version nằm trong URL path.
Thiết lập Project
<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
Định nghĩa DTOs riêng cho từng API version:
// Entity dùng chung
@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 — format cũ với name gộp chung
@Data
@AllArgsConstructor
public class UserV1Dto {
private Long id;
private String name; // "John Doe" (gộp)
private String email;
}
// V2 DTO — format mới với fields tách riêng
@Data
@AllArgsConstructor
public class UserV2Dto {
private Long id;
private String firstName;
private String lastName;
private String email;
private String phone;
private LocalDate dateOfBirth;
}Controllers theo Version
@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 với Version Mapping
@Service
public class UserService {
private final UserRepository userRepository;
public UserService(UserRepository userRepository) {
this.userRepository = userRepository;
}
// V1: Format name gộp
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: Thông tin user đầy đủ
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();
}
}Test URI versioning:
# V1 — legacy clients
curl http://localhost:8080/api/v1/users/1
# {"id":1,"name":"John Doe","email":"[email protected]"}
# V2 — modern clients
curl http://localhost:8080/api/v2/users/1
# {"id":1,"firstName":"John","lastName":"Doe","email":"[email protected]","phone":"+1234567890","dateOfBirth":"1990-05-15"}3. Header-Based Versioning
Version được chỉ định trong custom request header, giữ URLs sạch sẽ.
Cách dùng Custom Header
@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));
}
}Test:
# 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 với Interceptor
Xử lý trường hợp thiếu version header bằng cách default về version mới nhất:
@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 để 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
Cách tiếp cận RESTful nhất — version được encode trong Accept header sử dụng 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));
}
}Test:
# 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/1Cấu hình Custom Media Type
Đăng ký custom media types cho 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. So sánh các chiến lược Versioning
| Chiến lược | URL thay đổi | Cacheability | Test trên Browser | Độ phức tạp | Ví dụ |
|---|---|---|---|---|---|
| URI Path | ✅ Có | ✅ Dễ | ✅ Có | Thấp | /api/v1/users |
| Custom Header | ❌ Không | ⚠️ Tùy header | ❌ Không | Trung bình | X-API-Version: 1 |
| Content Negotiation | ❌ Không | ✅ Theo Accept header | ❌ Không | Cao | Accept: application/vnd.api.v1+json |
| Query Parameter | ✅ Có | ✅ Dễ | ✅ Có | Thấp | /api/users?version=1 |
Khi nào dùng cách nào
Khuyến nghị: Dùng URI path versioning cho hầu hết projects. Đơn giản nhất, dễ thấy nhất, và dễ cho consumers adopt nhất. GitHub, Twitter, Stripe, và Google đều dùng cách này.
Best Practices cho mọi chiến lược
- Hỗ trợ tối đa 2-3 versions đang hoạt động — maintain nhiều hơn rất tốn kém
- Chính sách deprecation — thông báo deprecation, cho clients timeline để migration
- Version response headers — luôn kèm version trong 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 tất cả versions — tích hợp với OpenAPI (Tài liệu API với Swagger)
6. Hiểu về HATEOAS
HATEOAS là gì?
HATEOAS (Hypermedia as the Engine of Application State) là một ràng buộc REST, trong đó server cho client biết những actions nào khả dụng bằng cách kèm links trong responses.
Thay vì client hardcode URLs kiểu:
GET /api/users/1
PUT /api/users/1
GET /api/users/1/ordersResponse từ server tự cung cấp links:
{
"id": 1,
"firstName": "John",
"lastName": "Doe",
"email": "[email protected]",
"_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" }
}
}Client follow links thay vì tự xây dựng URLs — giống như lướt web vậy.
Richardson Maturity Model
Hầu hết REST APIs đang ở Level 2. HATEOAS đưa bạn lên Level 3 — một API thực sự RESTful.
Tại sao HATEOAS?
| Lợi ích | Không có HATEOAS | Có HATEOAS |
|---|---|---|
| Client coupling | URLs hardcoded | Follow links động |
| API evolution | Client vỡ khi URL thay đổi | Client tự adapt |
| Discoverability | Đọc docs mới biết endpoints | Navigate từ bất kỳ response |
| State transitions | Client tự quản lý workflow | Server hướng dẫn actions hợp lệ |
| Documentation | Cần docs bên ngoài | API tự document |
7. Thiết lập Spring HATEOAS
Dependencies
Spring HATEOAS là project Spring chuyên dụng để xây dựng hypermedia-driven APIs:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-hateoas</artifactId>
</dependency>Bao gồm:
RepresentationModel— base class cho HATEOAS resourcesEntityModel— wrap một entity đơn kèm linksCollectionModel— wrap một collection kèm linksWebMvcLinkBuilder— tạo link type-safeRepresentationModelAssembler— chuyển đổi model tái sử dụng
Các khái niệm cốt lõi
8. Xây dựng HATEOAS Responses
EntityModel cho Resource đơn
@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(),
// Resources liên quan
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": "[email protected]",
"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 cho danh sách
@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": "[email protected]",
"_links": {
"self": { "href": "http://localhost:8080/api/v2/users/1" }
}
},
{
"id": 2,
"firstName": "Jane",
"lastName": "Smith",
"email": "[email protected]",
"_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
Khi bạn cần cùng một logic chuyển đổi entity-to-model ở nhiều controllers, hãy dùng RepresentationModelAssembler:
Tạo 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")
);
}
}Sử dụng Assembler trong 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 dựa trên State
Một trong những tính năng mạnh nhất của HATEOAS là conditional links — chỉ hiển thị các actions hợp lệ cho state hiện tại của resource.
Ví dụ Order State Machine
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 với 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 — luôn có
linkTo(methodOn(OrderController.class).getOrder(order.getId()))
.withSelfRel(),
// User link — luôn có
linkTo(methodOn(UserHateoasController.class)
.getUser(order.getUser().getId()))
.withRel("user")
);
// Conditional links dựa trên 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 và CANCELLED — không còn action nào
}
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 cho order CREATED:
{
"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 cho order SHIPPED:
{
"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" }
}
}Để ý các actions khả dụng thay đổi theo order state — client không bao giờ cần biết luật state machine.
11. HATEOAS với Pagination
Spring Data's Page tích hợp liền mạch với Spring HATEOAS qua 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 tự động tạo links first, prev, self, next, và last dựa trên vị trí page hiện tại.
12. Kết hợp Versioning với HATEOAS
Trong production, bạn thường cần cả versioning lẫn HATEOAS. Đây là kiến trúc gọn gàng để kết hợp chúng:
Tổng quan Architecture
V1: JSON đơn giản (cho 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 (cho 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 cho Discoverability
Cung cấp root endpoint liệt kê tất cả resources khả dụng:
@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 với 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", "[email protected]",
"+1234567890", LocalDate.of(1990, 5, 15), null);
UserV2Dto dto = new UserV2Dto(1L, "John", "Doe",
"[email protected]", "+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",
"[email protected]", "+1234567890",
LocalDate.of(1990, 5, 15), null));
}
@Test
void fullWorkflow_CreateAndRetrieveUser() throws Exception {
// Tạo user qua V2 API
String createRequest = """
{
"firstName": "Jane",
"lastName": "Smith",
"email": "[email protected]",
"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();
// Lấy self link và follow nó
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));
// Xác nhận V1 API trả về name gộp
mockMvc.perform(get("/api/v1/users"))
.andExpect(status().isOk())
.andExpect(jsonPath("$[1].name").value("Jane Smith"));
}
}14. Các lỗi thường gặp và Best Practices
Lỗi 1: Quá nhiều Versions
// ❌ Sai — maintain 5+ versions là ác mộng
@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
// ✅ Đúng — tối đa 2-3 versions hoạt động với deprecation policy
@RequestMapping("/api/v2/users") // Deprecated, sunset: 2026-12-01
@RequestMapping("/api/v3/users") // Stable hiện tạiLỗi 2: Circular Links
// ❌ Sai — có thể gây infinite recursion
EntityModel<UserV2Dto> userModel = assembler.toModel(user);
// User links tới orders, mỗi order links ngược lại user, v.v.
// ✅ Đúng — dùng IDs cho back-references, không phải 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") // Link đơn giản ngược lại, không embed
);Lỗi 3: Hardcoding Links
// ❌ Sai — URLs hardcoded vỡ khi đổi environment
model.add(Link.of("http://localhost:8080/api/v2/users/1").withSelfRel());
// ✅ Đúng — dùng WebMvcLinkBuilder cho type-safe, environment-aware links
model.add(linkTo(methodOn(UserHateoasController.class).getUser(1L))
.withSelfRel());Lỗi 4: HATEOAS không có mục đích
// ❌ Sai — thêm links không giúp ích gì cho 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"));
// ✅ Đúng — links đại diện cho state transitions có ý nghĩa
model.add(linkTo(methodOn(OrderController.class)
.payOrder(order.getId())).withRel("pay")); // Action client có thể thực hiện
model.add(linkTo(methodOn(UserHateoasController.class)
.getUser(order.getUserId())).withRel("user")); // Resource liên quanChecklist Best Practices
| Thực hành | Mô tả |
|---|---|
| Version sớm | Lên kế hoạch versioning từ đầu, dù bắt đầu với v1 |
| Deprecation headers | Kèm headers Sunset và X-API-Deprecated |
| Tối đa 2-3 versions | Sunset versions cũ theo timeline rõ ràng |
| Self links luôn có | Mọi resource phải có link self |
| Conditional links | Chỉ hiển thị actions hợp lệ cho state hiện tại |
| Dùng assemblers | Giữ logic links ngoài controllers |
| Test links | Xác nhận links tồn tại (hoặc không) trong mọi test |
| Document versions | Tích hợp versioning với OpenAPI docs |
| HAL format | Dùng HAL (Hypertext Application Language) làm hypermedia type |
Tóm tắt và Key Takeaways
✅ URI path versioning là chiến lược thực tế nhất cho hầu hết APIs
✅ Header-based versioning giữ URLs sạch nhưng khó test hơn
✅ Content negotiation là RESTful nhất nhưng thêm complexity
✅ HATEOAS làm API tự khám phá — clients follow links, không cần docs
✅ Spring HATEOAS cung cấp EntityModel, CollectionModel, và WebMvcLinkBuilder
✅ RepresentationModelAssembler giữ logic links reusable và ngoài controllers
✅ Conditional links hướng dẫn clients qua các state transitions hợp lệ
✅ PagedResourcesAssembler tự động tạo pagination links
✅ Kết hợp versioning + HATEOAS: V1 cho legacy, V2 với hypermedia
✅ Test links — xác nhận sự có mặt, vắng mặt, và tính đúng đắn
Tiếp theo
Giờ API đã versioned và tự khám phá, khám phá thêm các chủ đề:
Tiếp tục series Spring Boot:
- Structured Logging & Centralized Logging - Observability cho API
- Monitoring với Actuator, Prometheus & Grafana - Giám sát API health
- Docker & Kubernetes Deployment - Deploy API đã versioned
Các bài Spring Boot liên quan:
- REST API Best Practices - Tiêu chuẩn API design
- REST API Advanced Patterns - Exception handling, validation, pagination
- Tài liệu API với OpenAPI/Swagger - Document API đã versioned
- GraphQL với Spring for GraphQL - Alternative cho REST
- Spring Boot Learning Roadmap - Lộ trình học đầy đủ
REST API cơ bản:
- REST API là gì? - Hướng dẫn đầy đủ về REST principles
- HTTP Protocol Complete Guide - HTTP methods, status codes, headers
📬 Đăng ký nhận bản tin
Nhận những bài viết mới nhất qua email mỗi tuần. Không spam, hủy đăng ký bất cứ lúc nào.
Chúng tôi tôn trọng quyền riêng tư của bạn. Hủy đăng ký bất cứ lúc nào.
💬 Comments
Đăng nhập để bình luận
Chúng tôi sẽ không đăng bất cứ điều gì mà không có sự cho phép của bạn.