Back to blog

API Versioning & HATEOAS trong Spring Boot

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


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

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

Cấ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ượcURL thay đổiCacheabilityTest trên BrowserĐộ phức tạpVí dụ
URI Path✅ Có✅ Dễ✅ CóThấp/api/v1/users
Custom Header❌ Không⚠️ Tùy header❌ KhôngTrung bìnhX-API-Version: 1
Content Negotiation❌ Không✅ Theo Accept header❌ KhôngCaoAccept: 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

  1. Hỗ trợ tối đa 2-3 versions đang hoạt động — maintain nhiều hơn rất tốn kém
  2. Chính sách deprecation — thông báo deprecation, cho clients timeline để migration
  3. 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);
    }
}
  1. 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/orders

Response 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 íchKhông có HATEOASCó HATEOAS
Client couplingURLs hardcodedFollow links động
API evolutionClient vỡ khi URL thay đổiClient tự adapt
DiscoverabilityĐọc docs mới biết endpointsNavigate từ bất kỳ response
State transitionsClient tự quản lý workflowServer hướng dẫn actions hợp lệ
DocumentationCần docs bên ngoàiAPI 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 resources
  • EntityModel — wrap một entity đơn kèm links
  • CollectionModel — wrap một collection kèm links
  • WebMvcLinkBuilder — tạo link type-safe
  • RepresentationModelAssembler — 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();
    }
}

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
    }
}
@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());
    }
}
@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ại
// ❌ 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
);
// ❌ 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 quan

Checklist Best Practices

Thực hànhMô tả
Version sớmLên kế hoạch versioning từ đầu, dù bắt đầu với v1
Deprecation headersKèm headers Sunset và X-API-Deprecated
Tối đa 2-3 versionsSunset versions cũ theo timeline rõ ràng
Self links luôn cóMọi resource phải có link self
Conditional linksChỉ hiển thị actions hợp lệ cho state hiện tại
Dùng assemblersGiữ logic links ngoài controllers
Test linksXác nhận links tồn tại (hoặc không) trong mọi test
Document versionsTích hợp versioning với OpenAPI docs
HAL formatDù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:

Các bài Spring Boot liên quan:

REST API cơ bản:


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