Mở đầu — vì sao bài này quan trọng
Hãy tưởng tượng bạn là kiến trúc sư của một hệ thống thanh toán ví điện tử. Ngày ra mắt, API của bạn phục vụ một app di động duy nhất do chính team bạn viết. Mọi thứ đơn giản: cần đổi gì thì đổi, cần thêm field thì thêm, không ai phàn nàn. Nhưng chỉ sáu tháng sau, API của bạn đang được gọi bởi app iOS, app Android, cổng web, ba đối tác tích hợp bên ngoài, và một hệ thống nội bộ của phòng kế toán. Lúc này bạn muốn đổi tên một field từ amount sang total_amount cho rõ nghĩa hơn. Chỉ một thay đổi nhỏ tưởng chừng vô hại — nhưng nó sẽ làm sập app của hàng trăm nghìn người dùng đang chạy phiên bản cũ trên điện thoại của họ, những người chưa (và có thể không bao giờ) cập nhật app.
Đây chính là bài toán trung tâm của API Versioning (đánh phiên bản API): bạn không thể ép tất cả client cập nhật cùng lúc, nhưng hệ thống của bạn vẫn phải tiến hóa. API Versioning là kỷ luật cho phép bạn thay đổi hợp đồng (contract) giữa server và client mà không phá vỡ những client đang phụ thuộc vào phiên bản cũ.
Với vai trò người ra quyết định kiến trúc, versioning không phải chuyện kỹ thuật thuần túy. Nó là quyết định về cam kết vận hành dài hạn: mỗi phiên bản bạn công bố là một lời hứa bạn phải nuôi dưỡng, đôi khi trong nhiều năm. Chọn sai chiến lược versioning ở giai đoạn đầu sẽ để lại nợ kỹ thuật đắt đỏ, hoặc tệ hơn, khiến bạn không dám thay đổi gì vì sợ làm hỏng ai đó. Bài này tập trung riêng vào chiến lược đánh phiên bản — cách bạn cấu trúc, ký hiệu và quản lý vòng đời các phiên bản API.
Khái niệm cốt lõi
Breaking change vs Non-breaking change
Nền tảng của versioning là phân biệt hai loại thay đổi.
Non-breaking change (thay đổi tương thích ngược) là thay đổi mà client cũ vẫn hoạt động bình thường. Ví dụ: thêm một field mới vào response, thêm một endpoint mới, thêm một tham số tùy chọn (optional). Client cũ đơn giản là bỏ qua thứ nó không biết. Loại này không cần version mới.
Breaking change (thay đổi phá vỡ) là thay đổi khiến client cũ bị hỏng nếu không sửa code. Ví dụ điển hình:
- Đổi tên hoặc xóa một field (
amount→total_amount). - Đổi kiểu dữ liệu (
pricetừ string"10000"sang number10000). - Thêm một tham số bắt buộc (required) vào request.
- Thay đổi ý nghĩa của một giá trị (status
1từng nghĩa là "đang chờ" nay đổi thành "đã hủy"). - Thay đổi mã lỗi hoặc cấu trúc error response.
v47 chỉ sau vài tháng, và mỗi phiên bản là một gánh nặng bảo trì vô nghĩa.Bốn chiến lược versioning phổ biến
1. URL / URI versioning — đưa version vào đường dẫn:
GET /v1/users/123
GET /v2/users/123
Đây là cách phổ biến nhất và dễ hiểu nhất. Nhìn vào URL là biết ngay đang gọi phiên bản nào. Rất thân thiện với việc test bằng trình duyệt, curl, hay Postman. Nhược điểm: về mặt lý thuyết REST thuần túy, URL nên định danh một tài nguyên (resource), và /v1/users/123 với /v2/users/123 cùng trỏ về một user nhưng lại là hai URL khác nhau — điều này làm một số người khó chịu về triết lý. Trong thực tế, đây vẫn là lựa chọn được dùng nhiều nhất bởi Stripe (một phần), GitHub, Twilio và hầu hết các API công khai vì tính rõ ràng.
2. Query parameter versioning — version nằm trong query string:
GET /users/123?version=2
GET /users/123?api-version=2024-01-01
Ưu điểm: URL cơ sở giữ nguyên, dễ đặt version mặc định khi client không truyền. Azure dùng cách này rộng rãi với api-version. Nhược điểm: dễ bị bỏ quên, lẫn với các query param nghiệp vụ khác, và khó thấy khi đọc log.
3. Header versioning — dùng một HTTP header tùy chỉnh:
GET /users/123
X-API-Version: 2
Ưu điểm: URL "sạch", tuân thủ triết lý REST vì URL luôn trỏ về cùng tài nguyên. Nhược điểm lớn: ẩn. Không thể chỉ dán một URL vào trình duyệt để test; developer mới dễ quên; khó debug hơn vì phải nhìn vào header.
4. Media type / Content negotiation versioning — nhúng version vào header Accept:
GET /users/123
Accept: application/vnd.mycompany.v2+json
Đây là cách "chuẩn REST" nhất về mặt học thuật, GitHub dùng cho một số endpoint. Nó cho phép version hóa ở mức từng tài nguyên. Nhưng độ phức tạp cao, khó giải thích cho đối tác tích hợp, và ít công cụ hỗ trợ tốt. Trong thực tế ít team chọn cách này trừ khi có lý do rất cụ thể.
Versioning theo số thứ tự vs theo ngày
Bên cạnh vị trí đặt version, còn có cách đánh số:
- Số nguyên đơn giản:
v1,v2,v3. Dễ hiểu, phù hợp với hầu hết API. Đây là lựa chọn mặc định nên dùng. - Semantic-like:
v1.2. Hiếm khi cần cho API bên ngoài vì client không quan tâm minor version; thường chỉ gây rối. - Date-based (theo ngày):
2024-01-15. Stripe nổi tiếng với cách này. Mỗi ngày bạn tạo breaking change, bạn "đóng băng" một snapshot của API và gắn nhãn bằng ngày đó. Client "ghim" (pin) vào ngày họ tích hợp và giữ nguyên hành vi mãi mãi cho đến khi họ chủ động nâng cấp. Cách này mạnh mẽ cho hệ thống có nhiều thay đổi nhỏ liên tục, nhưng đòi hỏi hạ tầng phức tạp để duy trì.
Vòng đời và deprecation
Versioning không kết thúc ở việc tạo version mới. Phần khó nhất là khai tử (deprecate) version cũ. Một chiến lược versioning tốt phải trả lời được: khi nào thông báo ngừng hỗ trợ, cho client bao nhiêu thời gian chuyển đổi (thường 6–12 tháng cho API công khai), thông báo qua kênh nào (header Sunset, email, changelog), và làm sao đo được còn ai đang dùng version cũ.
Tình huống thực tế
Ví dụ 1: Ví điện tử "PayNow" và cú đổi kiểu dữ liệu suýt gây thảm họa
Một startup fintech tại TP.HCM — gọi là PayNow — có API trả về số tiền dưới dạng number: "balance": 150000. Team backend, vì lo ngại lỗi làm tròn số thực (floating point) với các giao dịch lớn, quyết định đổi sang string: "balance": "150000". Về mặt kỹ thuật đây là cải tiến đúng đắn. Nhưng họ triển khai thẳng lên /v1 mà không tạo version mới.
Kết quả: app Android phiên bản cũ parse balance như một số nguyên. Khi nhận được string, code parse ném exception và màn hình số dư hiển thị "0đ" cho khoảng 40.000 người dùng chưa cập nhật app. Trong hai giờ, tổng đài nhận hơn 800 cuộc gọi hoảng loạn vì tưởng mất tiền. Team phải rollback khẩn cấp lúc 11 giờ đêm.
Bài học: đổi kiểu dữ liệu là breaking change kinh điển. Lẽ ra PayNow phải tạo /v2/balance trả về string, giữ /v1 trả về number, rồi mới lên kế hoạch di cư client dần dần. Sự cố này khiến họ áp dụng nguyên tắc bất di bất dịch: mọi thay đổi kiểu dữ liệu trên response đều phải qua version mới, không có ngoại lệ.
Ví dụ 2: Sàn TMĐT "ShopViet" chọn URL versioning cho hệ sinh thái đối tác
ShopViet, một nền tảng thương mại điện tử phục vụ hàng nghìn nhà bán (seller), có API cho đối tác tích hợp: đơn vị vận chuyển, phần mềm kế toán, công cụ quản lý kho. Khi thiết kế lại API, kiến trúc sư trưởng đứng trước lựa chọn giữa URL versioning và header versioning.
Anh chọn URL versioning (/v1/orders, /v2/orders) với lý do thực tế rất Việt Nam: phần lớn đối tác tích hợp là các công ty phần mềm nhỏ, developer trẻ, làm việc chủ yếu bằng Postman và curl. Header versioning "đẹp" về lý thuyết nhưng sẽ tạo ra vô số ticket hỗ trợ kiểu "sao tôi gọi API không ra dữ liệu mới" — vì họ quên set header. URL versioning giúp mọi thứ hiển nhiên: dán link vào trình duyệt là thấy ngay. Khi có breaking change lớn ở cấu trúc đơn hàng (tách địa chỉ giao và địa chỉ hóa đơn thành hai object riêng), họ ra /v2/orders, giữ /v1 chạy song song thêm 12 tháng, gắn header Sunset và gửi email hàng tháng cho các đối tác còn dùng /v1.
Bài học: chiến lược versioning tối ưu phụ thuộc vào ai là người tiêu thụ API của bạn. Với đối tác bên ngoài trình độ không đồng đều, sự rõ ràng thắng sự thanh lịch. URL versioning là lựa chọn an toàn khi ưu tiên khả năng tiếp cận.
Ví dụ 3: Stripe và mô hình date-based versioning
Stripe xử lý một vấn đề mà PayNow và ShopViet không có: họ tạo breaking change rất thường xuyên (nhiều lần mỗi năm) và phục vụ hàng triệu tích hợp không thể ép nâng cấp. Nếu dùng v1, v2, v3..., họ sẽ phải nhảy version liên tục và không client nào theo kịp.
Giải pháp của Stripe là date-based versioning. Khi bạn tạo tài khoản và bắt đầu tích hợp vào, ví dụ, ngày 2024-06-20, tài khoản của bạn được "ghim" vào phiên bản đó. Mọi breaking change Stripe làm sau ngày đó không ảnh hưởng đến bạn — server có một lớp "version transformer" chuyển đổi dữ liệu mới về đúng hình dạng của phiên bản bạn đã ghim. Bạn chỉ nâng cấp khi chủ động đổi ngày trong dashboard và test kỹ. Điều này cho phép Stripe tiến hóa nhanh mà không phá vỡ bất kỳ ai.
Bài học: date-based versioning là công cụ mạnh nhưng đắt. Nó đòi hỏi một tầng transform để "dịch ngược" mọi phiên bản, cùng kỷ luật kỹ thuật rất cao. Đừng bắt chước Stripe nếu bạn chỉ có vài breaking change mỗi năm — bạn sẽ gánh độ phức tạp khổng lồ để giải quyết vấn đề mình chưa có. Đây là ví dụ điển hình về việc chọn công cụ theo quy mô vấn đề, không theo độ "ngầu".
Hướng dẫn từng bước
Khi bạn cần đưa ra quyết định versioning cho một API, hãy đi theo trình tự sau:
Bước 1 — Xác định đối tượng tiêu thụ (consumers). API này phục vụ ai? Chỉ team nội bộ (bạn kiểm soát cả client lẫn server)? Hay đối tác bên ngoài, app di động đã phát hành, public API? Càng ít kiểm soát client, versioning càng phải nghiêm ngặt. API nội bộ có thể linh hoạt hơn nhiều.
Bước 2 — Ước lượng tần suất breaking change. Vài lần mỗi năm hay vài lần mỗi tháng? Tần suất thấp → số nguyên đơn giản (v1, v2). Tần suất rất cao và nhiều consumer không kiểm soát được → cân nhắc date-based.
Bước 3 — Chọn vị trí đặt version. Mặc định an toàn: URL versioning cho public API và API có đối tác ngoài. Header hoặc media type chỉ khi bạn có lý do REST cụ thể và đối tượng dùng đủ thành thạo. Query param khi cần version mặc định linh hoạt.
Bước 4 — Định nghĩa rõ "breaking change" trong tài liệu nội bộ. Viết một checklist để cả team thống nhất: đổi tên field, xóa field, đổi kiểu, thêm required param, đổi ý nghĩa giá trị đều là breaking. Đây là phần quan trọng nhất mà nhiều team bỏ qua.
Bước 5 — Thiết kế chiến lược chung sống (coexistence). Version mới ra đời không giết version cũ ngay. Cả hai phải chạy song song. Quyết định: tách route riêng, hay dùng một tầng transform để phiên bản cũ đọc từ logic mới.
Bước 6 — Thiết lập chính sách deprecation. Xác định trước: thời gian ân hạn (ví dụ 12 tháng), cách thông báo (header Sunset, Deprecation, email, changelog), và cơ chế đo lường lượng traffic còn lại trên version cũ.
Bước 7 — Đo lường và khai tử. Dùng metrics để biết còn bao nhiêu % request đi vào version cũ. Chỉ tắt khi con số đủ nhỏ và đã hết thời gian ân hạn. Ghi lại quyết định này (đây là lúc một ADR phát huy tác dụng).
Lỗi thường gặp & mẹo
Lỗi 1 — Version hóa quá sớm, quá nhiều. Nhiều team tạo /v1 ngay từ đầu rồi tăng lên /v2 cho cả những thay đổi non-breaking. Kết quả là một rừng version vô nghĩa. Mẹo: chỉ tăng version khi thực sự có breaking change. Thêm field mới thì cứ thêm vào version hiện tại.
Lỗi 2 — Trộn lẫn nhiều chiến lược trong cùng hệ thống. Endpoint này dùng URL, endpoint kia dùng header, cái khác lại dùng query param. Điều này khiến client phát điên. Mẹo: chọn một chiến lược và áp dụng nhất quán toàn API.
Lỗi 3 — Tạo version mới nhưng không bao giờ khai tử version cũ. Sau vài năm bạn có v1 đến v6 cùng chạy, mỗi cái là một nhánh code phải bảo trì và vá lỗi bảo mật. Đây là dạng nợ kỹ thuật nguy hiểm nhất. Mẹo: mọi version mới ra đời phải kèm kế hoạch khai tử version cũ. Không có deprecation policy thì đừng ra version mới.
Lỗi 4 — Coi việc thêm field là an toàn tuyệt đối. Thường thì đúng, nhưng nếu client của bạn dùng validation nghiêm ngặt (strict schema) từ chối field lạ, thì thêm field cũng thành breaking. Mẹo: hiểu rõ client parse response như thế nào trước khi cho rằng một thay đổi là non-breaking.
Lỗi 5 — Không có version mặc định. Nếu dùng header/query versioning mà client không truyền version, bạn xử lý sao? Mẹo: luôn định nghĩa hành vi mặc định rõ ràng — thường là version ổn định gần nhất, và log cảnh báo để phát hiện client "quên" truyền version.
Mẹo vàng — Ưu tiên tránh breaking change hơn là versioning giỏi. Chiến lược versioning tốt nhất là chiến lược bạn ít phải dùng đến. Thiết kế API rộng rãi ngay từ đầu: dùng object thay vì giá trị đơn lẻ để dễ mở rộng, tránh optional trở thành required, đặt tên field cẩn thận. Mỗi breaking change bạn tránh được là một version bạn không phải bảo trì.
Bài tập thực hành
Bài 1 — Phân loại thay đổi. Với API endpoint GET /v1/orders/{id} trả về { "id", "status", "amount", "items": [...] }, hãy phân loại từng thay đổi sau là breaking hay non-breaking, và giải thích:
- (a) Thêm field
created_at. - (b) Đổi
amounttừ number sang string. - (c) Đổi
statustừ số (1, 2, 3) sang chuỗi ("pending", "paid"). - (d) Thêm endpoint mới
GET /v1/orders/{id}/history. - (e) Tách
amountthànhsubtotalvàtotal, xóaamount.
Bài 3 — Viết deprecation plan. Bạn cần khai tử /v1/payments để chuyển mọi người sang /v2/payments. Viết một kế hoạch gồm: thời gian ân hạn, các mốc thông báo, header HTTP dùng để cảnh báo, và cách bạn đo lường còn bao nhiêu traffic trên /v1 trước khi tắt.
Bài 4 — Tình huống ra quyết định. Team bạn muốn đổi tên field user_name thành username cho nhất quán. Có 5 client nội bộ và 20 đối tác ngoài đang dùng. Đề xuất phương án ít gây tổn hại nhất — gợi ý: liệu có cách nào tránh được version mới hoàn toàn không?
Tóm tắt
- Versioning tồn tại vì breaking change là không thể tránh khi hệ thống tiến hóa, nhưng bạn không thể ép mọi client cập nhật cùng lúc.
- Phân biệt breaking (đổi tên/xóa/đổi kiểu field, thêm required param, đổi ý nghĩa giá trị) và non-breaking (thêm field, thêm endpoint, thêm optional param). Chỉ tăng version khi có breaking change.
- Bốn chiến lược chính: URL (rõ ràng, phổ biến nhất, an toàn cho public API), query param (linh hoạt mặc định), header (URL sạch nhưng ẩn), media type (chuẩn REST nhất nhưng phức tạp).
- Chọn chiến lược theo ai tiêu thụ API và tần suất breaking change, không theo độ thanh lịch lý thuyết. Với đối tác ngoài, sự rõ ràng của URL versioning thường thắng.
- Date-based versioning (kiểu Stripe) mạnh cho hệ thống thay đổi liên tục nhưng đắt về hạ tầng — đừng bắt chước nếu chưa có vấn đề đó.
- Phần khó nhất không phải tạo version mới mà là khai tử version cũ: luôn có deprecation policy, header
Sunset, thời gian ân hạn, và đo lường traffic trước khi tắt. - Nguyên tắc cuối cùng: thiết kế để tránh breaking change vẫn tốt hơn mọi chiến lược versioning tinh vi.