API versioning: đổi API mà không làm gãy partner

4 câu hỏi~20 phútTừ khóa: breaking change, URL versioning, header versioning, media type, Deprecation, Sunset, tolerant reader, expand-contract

Tiếp đi: versioning. Mình muốn tách customerName trong order thành firstName và lastName. Mobile bản cũ vẫn còn người dùng, partner thì không update được ngay. Làm sao?

1. Bài toán

v1 response:  { "id":"ord_1", "customerName":"Nguyễn An" }
muốn đổi:     { "id":"ord_1", "firstName":"An", "lastName":"Nguyễn" }

Mobile 2.3 (30% user, không ép update được)  ─┐
Partner A (release 6 tháng/lần)              ─┼─> cùng một API
Web (deploy mỗi ngày)                        ─┘

2. Vấn đề

Xóa customerName là mobile cũ hiện chỗ trống hoặc crash. Không giống web, ta không kiểm soát lúc nào client cũ biến mất.

3. Giải pháp

Bước đầu tiên luôn là: tránh tạo version mới. Phân loại thay đổi:

Tương thích ngược (không cần version)Breaking (cần version hoặc expand-contract)
Thêm field mới ở responseXóa / đổi tên field
Thêm endpoint mớiĐổi kiểu (number → string)
Thêm query param tùy chọnField tùy chọn thành bắt buộc
Nới validationSiết validation
Thêm giá trị enum (nếu client tolerant)Đổi URL, status code, ngữ nghĩa

Với ví dụ trên: expand-contract. Thêm firstName/lastName, giữ customerName, đánh dấu deprecated, chờ client chuyển, rồi mới xóa ở major version sau.

Khi buộc phải có version, ba cách:

URL:        GET /v2/orders/1
Header:     GET /orders/1      API-Version: 2025-06-01
Media type: GET /orders/1      Accept: application/vnd.shop.order.v2+json

4. Code từng bước

Bước 1: expand, response có cả hai:

{ "id":"ord_1", "customerName":"Nguyễn An", "firstName":"An", "lastName":"Nguyễn" }

Bước 2: báo deprecation trong OpenAPI (deprecated: true) và header chuẩn (RFC 9745 Deprecation, RFC 8594 Sunset):

Deprecation: @1748736000
Sunset: Wed, 31 Dec 2025 23:59:59 GMT
Link: <https://developer.shop.example/migrate/v2>; rel="deprecation"

Bước 3: Code Java, đo ai còn dùng cái cũ:

@Component
public class DeprecationFilter extends OncePerRequestFilter {
    private final MeterRegistry meters;
    public DeprecationFilter(MeterRegistry meters) { this.meters = meters; }

    @Override
    protected void doFilterInternal(HttpServletRequest req, HttpServletResponse res,
                                    FilterChain chain) throws ServletException, IOException {
        if (req.getRequestURI().startsWith("/v1/")) {
            res.setHeader("Deprecation", "@1748736000");
            res.setHeader("Sunset", "Wed, 31 Dec 2025 23:59:59 GMT");
            meters.counter("api.deprecated.calls", "client", clientId(req)).increment();
        }
        chain.doFilter(req, res);
    }
}

Spring Framework 7 / Boot 4 có hỗ trợ versioning sẵn (@GetMapping(version = "2"), ApiVersionConfigurer); Boot 3 thì làm bằng path hoặc filter như trên.

Bước 4: test

curl -sI https://api.shop.example/v1/orders/ord_1 -H "Authorization: Bearer $TOKEN" | grep -iE "deprecation|sunset"

5. Cách hoạt động: expand-contract theo thời gian

T0  thêm firstName/lastName (giữ customerName)       -> không ai gãy
T1  deprecated + Sunset header + email partner
T2  dashboard: còn client nào gọi customerName?        -> liên hệ từng partner
T3  sau sunset: xóa ở v2 (hoặc trả 410 Gone cho v1)

6. So sánh

URL /v2HeaderMedia type
Dễ thấy, dễ test (curl, browser)Rất dễTrung bìnhKhó
Cache / routing ở gatewayDễCần VaryCần Vary: Accept
"REST thuần" (URL = resource)KémTốtTốt nhất
Ai dùngPhổ biến nhấtStripe (theo ngày)GitHub (Accept)

7. Khi nào nên dùng / không nên dùng

Bạn nói thêm field là tương thích. Nhưng hôm trước mình thêm field loyaltyPoints, app Android cũ crash luôn. Sao vậy?

Server đúng, client chưa phải tolerant reader. App Android có lẽ dùng parser strict, ví dụ Jackson mặc định FAIL_ON_UNKNOWN_PROPERTIES = true, gặp field lạ là ném exception.

Tolerant reader (Postel's law cho client):

@JsonIgnoreProperties(ignoreUnknown = true)
public record OrderView(String id, OrderStatus status) {}

public enum OrderStatus {
    PENDING, PAID, SHIPPED, CANCELLED,
    @JsonEnumDefaultValue UNKNOWN
}
// + mapper.enable(DeserializationFeature.READ_UNKNOWN_ENUM_VALUES_USING_DEFAULT_VALUE)

Spring Boot đã tắt FAIL_ON_UNKNOWN_PROPERTIES sẵn cho ObjectMapper của nó. Hãy ghi rõ trong contract: "client phải bỏ qua field và giá trị enum chưa biết". SDK mình phát cho partner phải tuân thủ điều này.

Nếu buộc phải có /v2, mình có phải copy toàn bộ controller và service ra hai bản không?

Không. Version là chuyện của lớp ngoài cùng (DTO + mapping). Domain và service chỉ có một.

/v1/orders ─> OrderControllerV1 ─> OrderV1Mapper ─┐
                                                 ├─> OrderService ─> DB (1 bản)
/v2/orders ─> OrderControllerV2 ─> OrderV2Mapper ─┘

Hoặc đặt adapter ở gateway chuyển v1 sang v2. Giữ tối đa 2 major version cùng lúc, có ngày sunset rõ ràng, nếu không chi phí bảo trì tăng mãi.

Phỏng vấn hỏi về API versioning và backward compatibility thì trả lời sao?

Q1. Các cách version API, bạn chọn cách nào?

🇻🇳 Tiếng Việt:

Có ba cách: version trong URL, trong header riêng, hoặc trong media type của Accept. URL dễ thấy, dễ route và cache, nên em chọn cho public API với major version. Nhưng ưu tiên số một là tránh tạo version mới bằng thay đổi tương thích ngược và expand-contract.

🇬🇧 English:

There are three options: version in the URL, in a custom header, or in the media type of the Accept header. URL versioning is visible and easy to route and cache, so I use it for major versions of public APIs. But the first priority is to avoid new versions at all by making backward-compatible changes with expand and contract.

Q2. Thay đổi nào là breaking?

🇻🇳 Tiếng Việt:

Xóa hoặc đổi tên field, đổi kiểu dữ liệu, biến field tùy chọn thành bắt buộc, siết validation, đổi status code hoặc ngữ nghĩa. Thêm field, thêm endpoint, thêm param tùy chọn là tương thích, với điều kiện client là tolerant reader.

🇬🇧 English:

Removing or renaming fields, changing types, making optional fields required, tightening validation, or changing status codes or semantics are breaking. Adding fields, endpoints or optional parameters is compatible, as long as clients are tolerant readers.

Q3. Deprecate một API thế nào?

🇻🇳 Tiếng Việt:

Đánh dấu deprecated trong OpenAPI, trả header Deprecation và Sunset kèm link hướng dẫn migrate, đo metric ai còn gọi, chủ động liên hệ partner. Sau ngày sunset mới gỡ hoặc trả 410 Gone.

🇬🇧 English:

Mark it deprecated in the OpenAPI spec, return Deprecation and Sunset headers with a link to the migration guide, track metrics on who still calls it and reach out to those partners. Only after the sunset date do we remove it or return 410 Gone.

Cách nhớ ngắn gọn


Tự kiểm tra

Thêm field bắt buộc vào request body có breaking không?

Có. Client cũ không gửi field đó sẽ bị 400/422.

Header Sunset báo gì?

Thời điểm API dự kiến ngừng hoạt động (RFC 8594).

Header versioning cần chú ý gì với cache?

Thêm Vary theo header version để cache không trả nhầm bản.

Tolerant reader là gì?

Client bỏ qua field/enum không biết, chỉ đọc cái cần, nên server thêm dữ liệu không làm gãy client.

Có v1 và v2 thì nên tách ở lớp nào?

Controller + DTO mapper. Service và domain dùng chung.