Team mobile báo app crash hàng loạt. Crash report: JSON parse error ở màn hình profile. Endpoint /api/users/:id vẫn trả 200, nhưng field full_name (string) đã bị đổi thành object { first: "...", last: "..." } trong PR merge hôm trước. Backend đổi để hỗ trợ tính năng mới trên web, nhưng mobile app đang parse full_name thành string. Không có version mới, không deprecation warning, không migration period. Một thay đổi type field ở backend crash 40% user trên iOS vì họ chưa update app.
API versioning tồn tại vì consumer không update cùng lúc với producer. Web app có thể deploy cùng ngày, nhưng mobile app phải chờ review store 2-3 ngày rồi user còn phải chịu update. IoT device có thể không bao giờ update firmware. Third-party integration thì không kiểm soát được lịch deploy. API là contract giữa hai bên — thay đổi contract mà không thông báo trước là phá vỡ contract.
Bài này đi qua cách phân biệt thay đổi an toàn và breaking change, ba chiến lược versioning phổ biến với ưu nhược từng cái, và quy trình deprecation để consumer có thời gian migrate.
Thay đổi nào break, thay đổi nào không
Trước khi nói về versioning strategy, cần phân biệt rõ hai loại thay đổi. Nếu mọi thay đổi đều backward compatible thì bạn gần như không cần versioning — chỉ cần evolve API liên tục. Versioning chỉ thực sự cần khi có breaking change.
Non-breaking changes — evolve tự do
Thêm field mới vào response là thay đổi an toàn nhất. Client đang parse JSON sẽ ignore field lạ — đây là hành vi mặc định của hầu hết JSON parser. Thêm avatar_url vào response user không ảnh hưởng client cũ đang chỉ đọc name và email.
Thêm endpoint mới cũng an toàn — client cũ không gọi endpoint mới nên không bị ảnh hưởng. Thêm GET /api/users/:id/preferences không break bất kỳ ai.
Thêm optional parameter vào request — client cũ không gửi parameter đó, server dùng giá trị mặc định. Thêm query param ?include_archived=true với default false không thay đổi hành vi cho client hiện tại.
Thêm giá trị mới vào enum response — ví dụ field status có thêm giá trị "on_hold" bên cạnh "active" và "inactive". Đây là vùng xám: về mặt kỹ thuật không break JSON parsing, nhưng client mà dùng switch/case exhaustive trên enum sẽ gặp giá trị không xử lý được. Mình coi đây là soft breaking change — cần document rõ và cảnh báo consumer rằng enum có thể mở rộng.
Quy tắc chung cho non-breaking: chỉ thêm, không sửa, không xoá. Robustness principle (Postel’s law) — “be conservative in what you send, be liberal in what you accept” — áp dụng cho cả hai phía: server thêm field nhưng không bỏ field cũ, client parse những gì nó hiểu và ignore phần còn lại.
Breaking changes — cần versioning
Xoá field khỏi response. Client đang đọc full_name mà server không trả nữa → null pointer, crash, hoặc UI trống. Đây là breaking change phổ biến nhất và nguy hiểm nhất vì nó im lặng — không có error code, chỉ có field biến mất.
Đổi kiểu dữ liệu của field. String thành object (như incident mở đầu), number thành string, array thành single object. JSON parser của client sẽ fail hoặc cho ra giá trị sai.
Đổi tên field. user_name thành username — về bản chất giống xoá field cũ và thêm field mới. Client cũ tìm user_name không thấy.
Đổi ý nghĩa field mà không đổi tên. price trước là VND, giờ là USD — kiểu dữ liệu vẫn là number, nhưng ý nghĩa khác hoàn toàn. Đây là breaking change ngấm ngầm nguy hiểm nhất vì không có lỗi kỹ thuật nào báo — client vẫn parse thành công, nhưng hiển thị sai giá trị.
Thay đổi validation rule cho request. Field trước optional giờ bắt buộc — client cũ không gửi field đó sẽ nhận 400. Giảm max length — request trước hợp lệ giờ bị reject. Bất kỳ thay đổi nào khiến request hợp lệ cũ trở thành không hợp lệ đều là breaking.
Đổi HTTP status code hoặc error format. Client check if (status === 200) mà bạn đổi sang trả 201 — logic client sai. Đổi error response từ { error: "..." } thành { errors: [...] } — error handling client vỡ.
Khi bạn phải làm breaking change (và sẽ có lúc phải), đó là lúc cần versioning strategy.
Ba chiến lược versioning
URL path versioning — /v1/, /v2/
Đây là cách phổ biến nhất, dễ hiểu nhất. Version nằm ngay trong URL:
GET /api/v1/users/42
GET /api/v2/users/42
Ưu điểm lớn nhất là tường minh — nhìn URL biết ngay version nào. Developer đọc code, đọc log, đọc curl command đều thấy version rõ ràng. Cache cũng đơn giản vì URL khác nhau cho version khác nhau — CDN, proxy, browser cache đều hoạt động đúng mà không cần config đặc biệt. Routing trong framework cũng dễ: mỗi version là một route group hoặc controller riêng.
# FastAPI
app.include_router(users_v1.router, prefix="/api/v1")
app.include_router(users_v2.router, prefix="/api/v2")
Nhược điểm là URL bị coi là resource identifier trong REST thuần — /v1/users/42 và /v2/users/42 trỏ về cùng một user nhưng có hai URL khác nhau, vi phạm nguyên tắc “mỗi resource một URI”. Đây là nhược điểm lý thuyết mà trong thực tế ít ai quan tâm — nhưng nếu bạn dùng HATEOAS hoặc có hệ thống link tự tham chiếu, nó gây rắc rối.
Nhược điểm thực tế hơn: khi tạo version mới, bạn phải quyết định copy toàn bộ API hay chỉ endpoint thay đổi. Copy toàn bộ thì duy trì nặng — 50 endpoint mà chỉ 3 cái thay đổi, bạn vẫn phải maintain 50 endpoint cho cả v1 và v2. Chỉ version endpoint thay đổi thì client phải gọi lẫn lộn /v1/orders và /v2/users — confusing.
Mình thấy đa số team dùng URL path versioning vì đơn giản và tooling hỗ trợ tốt. Stripe, GitHub, Google Maps API đều dùng cách này.
Header versioning — Accept-Version hoặc custom header
Version nằm trong HTTP header thay vì URL:
GET /api/users/42
Accept-Version: v2
# hoặc custom header
GET /api/users/42
X-API-Version: 2
URL giữ nguyên cho mọi version — /api/users/42 luôn là resource user 42, không vi phạm REST. Version là metadata của request, không phải phần của resource path.
Ưu điểm là URL sạch và RESTful hơn. Client có thể upgrade version từng bước bằng cách đổi header mà không đổi URL construction logic. Server dùng middleware đọc header rồi route đến handler đúng version.
Nhược điểm quan trọng: khó test và debug. Bạn không thể paste URL vào browser để test — phải dùng curl hoặc Postman để set header. Chia sẻ link API cho đồng nghiệp cũng phải kèm theo “nhớ set header version 2”. Log nếu không ghi header thì không biết request dùng version nào.
Cache phức tạp hơn: cùng URL nhưng version khác nhau trả response khác nhau. CDN và proxy cần config Vary: Accept-Version header để cache đúng — không phải CDN nào cũng hỗ trợ tốt, và quên set Vary header là serve response sai version cho client.
HTTP/1.1 200 OK
Vary: Accept-Version
Content-Type: application/json
Mình ít thấy team dùng header versioning cho public API vì barrier to entry cao — developer mới phải đọc document kỹ mới biết cần set header gì. Microsoft Azure API dùng cách này cho một số service.
Content negotiation — media type versioning
Version nằm trong Accept header dưới dạng custom media type:
GET /api/users/42
Accept: application/vnd.myapi.v2+json
Đây là cách “RESTful nhất” — HTTP spec thiết kế Accept header để client nói cho server biết format nào nó muốn nhận. Version là một phần của media type, server trả response đúng format.
HTTP/1.1 200 OK
Content-Type: application/vnd.myapi.v2+json
Ưu điểm về mặt lý thuyết rất đẹp — tuân thủ HTTP spec, URL sạch, có thể version từng resource type khác nhau (user v2, order v1). GitHub API dùng cách này cho một số endpoint.
Nhược điểm thực tế: phức tạp quá mức cho hầu hết team. Developer phải hiểu media type, content negotiation, Vary header. Tooling hỗ trợ kém hơn — Swagger/OpenAPI document phải mô tả nhiều media type. Client library phải set Accept header đúng format — gõ sai một ký tự là nhận 406 Not Acceptable.
Mình không recommend cách này trừ khi team có yêu cầu REST thuần hoặc đang xây API platform lớn cần version granular tới từng resource.
Vậy chọn cái nào?
Thực tế mà nói: URL path versioning là lựa chọn mặc định cho hầu hết team. Dễ hiểu, dễ implement, dễ test, dễ debug, tooling hỗ trợ tốt nhất. Nhược điểm lý thuyết về REST purity gần như không ảnh hưởng thực tế.
Header versioning hợp lý khi bạn đã có API framework xử lý sẵn và team quen với pattern này. Content negotiation chỉ dùng khi có lý do kỹ thuật cụ thể.
Quan trọng hơn cách versioning là versioning khi nào — đừng tạo v2 chỉ vì thêm field mới (non-breaking change). Version mới chỉ khi có breaking change thực sự.
Expand-contract pattern — evolve không cần version mới
Không phải breaking change nào cũng cần version mới. Nếu thay đổi có thể thực hiện qua nhiều bước backward compatible, bạn evolve API mà không tạo version — ít overhead hơn rất nhiều.
Pattern expand-contract hoạt động như database migration zero-downtime: expand (thêm mới), deploy, consumer migrate, rồi contract (xoá cũ).
Ví dụ cần đổi field full_name (string) thành first_name + last_name:
Bước 1 — Expand: thêm first_name và last_name vào response, giữ nguyên full_name. Server trả cả ba field. Client cũ vẫn đọc full_name, client mới bắt đầu đọc first_name + last_name.
{
"id": 42,
"full_name": "Nguyễn Văn A",
"first_name": "A",
"last_name": "Nguyễn Văn"
}
Bước 2 — Migrate consumer: thông báo cho tất cả consumer chuyển sang dùng first_name + last_name. Set deadline — ví dụ 3 tháng. Track usage metric để biết ai vẫn dùng full_name.
Bước 3 — Contract: sau khi mọi consumer đã migrate (hoặc sau deadline), xoá full_name khỏi response. Nếu vẫn còn consumer dùng full_name sau deadline, quyết định: gia hạn hay chấp nhận break.
Pattern này tránh hoàn toàn việc tạo version mới, giảm complexity maintain nhiều version song song. Nhược điểm là quá trình dài hơn (3 bước thay vì ship v2 một lần) và cần discipline theo dõi consumer migration.
Mình dùng expand-contract cho hầu hết thay đổi “breaking” đơn lẻ — đổi tên field, đổi type, restructure object. Chỉ tạo version mới khi có nhiều breaking change cùng lúc hoặc khi redesign cả resource.
Deprecation policy — thông báo trước khi break
Deprecation là giai đoạn chuyển tiếp giữa “API cũ hoạt động bình thường” và “API cũ bị xoá”. Không có giai đoạn này thì consumer bị bất ngờ — chính xác là scenario mở đầu bài.
Sunset header — chuẩn HTTP cho deprecation
RFC 8594 định nghĩa Sunset header — nói cho client biết endpoint này sẽ ngừng hoạt động khi nào:
HTTP/1.1 200 OK
Sunset: Sat, 01 Nov 2026 00:00:00 GMT
Deprecation: true
Link: <https://api.example.com/docs/migration-v2>; rel="successor-version"
Sunset cho biết ngày xoá. Deprecation: true đánh dấu endpoint đã deprecated. Link header chỉ đến tài liệu migration. Client library có thể tự động log warning khi nhận response có Deprecation header — nhiều SDK của Stripe và Twilio làm điều này.
Deprecation timeline thực tế
Mình thường áp dụng timeline theo loại consumer. Với internal API (team trong cùng công ty): thông báo 2-4 tuần, đủ cho 1-2 sprint migration. Với public API (third-party integration): thông báo 6-12 tháng tối thiểu — Stripe cho 12 tháng, GitHub cho 24 tháng. Mobile app: ít nhất 2 release cycle + thời gian user update — thực tế có thể 3-6 tháng.
Timeline dài nghe bất tiện nhưng nó bảo vệ trust. API mà thay đổi bất ngờ thì lần sau partner không dám integrate. Mình từng thấy team break public API không warning — hai khách hàng enterprise huỷ hợp đồng vì “không tin tưởng platform stability”.
Communication checklist
Deprecation không chỉ là set header — cần communication rõ ràng. Changelog entry mô tả thay đổi gì, tại sao, và consumer cần làm gì. Email hoặc notification cho consumer đã register (nếu có developer portal). Documentation cập nhật endpoint đánh dấu deprecated với hướng dẫn migration cụ thể, có code example.
Mình từng nhận deprecation notice chỉ ghi “endpoint X will be removed on date Y” mà không có hướng dẫn migrate sang đâu. Phải tự đọc document, tự tìm endpoint thay thế, tự map field cũ sang mới — tốn 2 ngày. Deprecation notice tốt phải có migration guide chi tiết: endpoint mới là gì, field mapping cũ → mới, code example cho từng ngôn ngữ phổ biến.
Migration path cho consumer
Deprecation thông báo “sẽ thay đổi”. Migration path chỉ cho consumer cách thay đổi cụ thể, từng bước.
Parallel run — chạy song song hai version
Khi ship version mới, giữ version cũ hoạt động song song trong suốt deprecation period. Cả v1 và v2 đều nhận request, trả response đúng format của mỗi version. Consumer migrate từ v1 sang v2 theo tốc độ của họ.
GET /api/v1/users/42 → { "full_name": "Nguyễn Văn A" }
GET /api/v2/users/42 → { "first_name": "A", "last_name": "Nguyễn Văn" }
Server xử lý cả hai version — internal logic dùng model mới (first_name + last_name), v1 handler transform ngược thành full_name cho response. Đây là adapter pattern — version cũ là adapter trên logic mới.
# v1 handler — adapter trên model mới
@app.get("/api/v1/users/{user_id}")
def get_user_v1(user_id: int):
user = get_user(user_id) # model mới có first_name, last_name
return {
"id": user.id,
"full_name": f"{user.last_name} {user.first_name}",
"email": user.email,
}
# v2 handler — dùng model trực tiếp
@app.get("/api/v2/users/{user_id}")
def get_user_v2(user_id: int):
user = get_user(user_id)
return {
"id": user.id,
"first_name": user.first_name,
"last_name": user.last_name,
"email": user.email,
}
Nhược điểm: maintain hai handler cho cùng resource. Nếu có bug fix ở logic get_user, cả hai version đều được fix (vì dùng chung internal logic). Nhưng nếu bug ở response transformation thì phải fix riêng từng version.
Track usage để biết khi nào xoá version cũ
Đừng xoá version cũ theo calendar — xoá theo usage metric. Log mỗi request với version nào, aggregate theo ngày. Khi traffic v1 về 0 (hoặc dưới ngưỡng chấp nhận được), xoá v1. Nếu sau deadline mà v1 vẫn có traffic đáng kể, bạn có data để quyết định: gia hạn, liên hệ consumer cụ thể, hay chấp nhận break.
# Middleware ghi version metric
@app.middleware("http")
async def track_api_version(request, call_next):
version = extract_version(request.url.path)
metrics.increment("api_requests", tags={"version": version})
response = await call_next(request)
return response
Stripe công khai dashboard version usage — developer thấy họ đang dùng version nào và nên upgrade lên version nào. Đây là best practice mà mình rất appreciate khi là consumer.
Deprecation warning trong response
Ngoài Sunset header, mình thường thêm warning trong response body cho version cũ — đặc biệt hữu ích vì nhiều developer không đọc response header:
{
"data": { "id": 42, "full_name": "Nguyễn Văn A" },
"_deprecation": {
"message": "v1 will be removed on 2026-11-01. Migrate to v2.",
"docs": "https://api.example.com/docs/v2-migration",
"sunset": "2026-11-01"
}
}
Client library có thể parse _deprecation và log warning — developer thấy warning trong console mỗi lần gọi API, nhắc nhở migrate mà không break functionality.
GraphQL — tiếp cận khác hoàn toàn
GraphQL giải quyết vấn đề versioning bằng cách… không cần versioning. Triết lý của GraphQL là continuous evolution — thêm field mới, deprecate field cũ, nhưng không bao giờ xoá ngay.
Field-level deprecation
Thay vì version cả API, GraphQL deprecate từng field:
type User {
id: ID!
firstName: String!
lastName: String!
fullName: String @deprecated(reason: "Use firstName + lastName")
email: String!
}
Client query fullName vẫn nhận giá trị — nhưng GraphQL tooling (GraphiQL, Apollo Studio) hiển thị warning, IDE gạch chân field deprecated. Client chỉ query field mà nó cần, nên thêm field mới không ảnh hưởng client cũ — đây là ưu thế tự nhiên của GraphQL so với REST.
Vì sao GraphQL ít cần versioning
Ở REST, server quyết định response shape — thêm field mới thì mọi client nhận field đó dù không cần. Ở GraphQL, client quyết định field nào muốn nhận — server thêm bao nhiêu field tuỳ ý, client cũ không bị ảnh hưởng vì query của nó không đổi.
Breaking change trong GraphQL vẫn tồn tại — xoá field, đổi type field, đổi argument bắt buộc — nhưng xảy ra ít hơn vì hầu hết thay đổi là additive. Và khi cần break, GraphQL schema stitching hoặc federation cho phép evolve từng phần schema độc lập.
Nhưng GraphQL không miễn phí
GraphQL có complexity riêng: query validation, authorization per-field, N+1 query, rate limiting phức tạp hơn REST. Nếu team chưa có GraphQL và chỉ cần giải quyết versioning, chuyển sang GraphQL là overkill. Dùng expand-contract hoặc URL versioning đơn giản hơn nhiều.
Mình chỉ recommend GraphQL khi team đã có lý do khác để adopt (client diversity, mobile bandwidth, complex data graph) — không chỉ vì versioning.
Design API để ít cần versioning
Versioning tốt nhất là versioning bạn không cần làm. Một số design decision từ đầu giảm đáng kể tần suất breaking change.
Response envelope ổn định
Wrap response trong envelope có cấu trúc cố định:
{
"data": { ... },
"meta": { "page": 1, "total": 42 },
"errors": null
}
Thêm field mới vào data là non-breaking. Thêm field vào meta cũng non-breaking. Client biết luôn parse response.data — cấu trúc ngoài không đổi.
Dùng string cho enum, không dùng integer
{ "status": "active" } // Tốt — thêm "on_hold" là non-breaking
{ "status": 1 } // Tệ — consumer phải map 1 → "active", thêm giá trị mới dễ conflict
String enum tự mô tả, client có thể handle giá trị lạ bằng default case. Integer enum thì client phải hardcode mapping — thêm giá trị mới phải update mapping ở mọi client.
Document rõ stability guarantee
Mỗi endpoint nên có stability level: stable (sẽ không break trong vòng N tháng), beta (có thể thay đổi), experimental (thay đổi bất cứ lúc nào). Consumer biết endpoint nào an toàn để depend on, endpoint nào cần cẩn thận.
Kubernetes API làm điều này rất tốt — v1 (stable), v1beta1 (beta), v1alpha1 (experimental). Consumer biết rõ rủi ro khi dùng beta hoặc alpha endpoint.
Anti-pattern hay gặp
Version mọi thay đổi. Thêm field mới → tạo v3. Thêm endpoint mới → v4. Sau 6 tháng có v9, mỗi version chỉ khác version trước một chút. Consumer không biết nên dùng version nào, documentation loãng ra 9 version. Chỉ tạo version mới khi có breaking change thực sự — non-breaking change thì evolve trong version hiện tại.
Không bao giờ deprecate. v1 tạo năm 2020, v5 tạo năm 2026, cả 5 version vẫn active. Mỗi bug fix phải patch 5 version. Mỗi security vulnerability phải fix 5 nơi. Chi phí maintain tỷ lệ thuận với số version active — đặt deprecation policy rõ ràng từ đầu và tuân thủ.
Break không thông báo. Đổi response format rồi deploy production mà không warning consumer. Đây là cách nhanh nhất để mất trust. Bất kỳ breaking change nào cũng phải qua deprecation period — dù ngắn (2 tuần cho internal API) hay dài (12 tháng cho public API).
Version trong query string. GET /api/users?version=2 — caching nightmare vì CDN có thể ignore query string, hoặc cache key phình to. URL path hoặc header đều tốt hơn query string cho versioning.
Copy-paste cả codebase cho version mới. Tạo v2 bằng cách duplicate toàn bộ controller, model, validation — giờ mọi thay đổi phải sửa hai chỗ. Dùng adapter pattern: internal logic dùng model mới nhất, mỗi version handler chỉ là adapter transform request/response.
Đánh version theo date. 2026-05-20 làm version — không biết nó có breaking change gì so với 2026-03-15. Stripe dùng date versioning nhưng kèm changelog cực kỳ chi tiết cho mỗi version — nếu bạn không có resource làm changelog chất lượng như Stripe, đừng dùng date versioning.
API contract testing — bảo vệ khỏi break vô tình
Versioning và deprecation là quy trình — nhưng quy trình chỉ tốt khi có automation bảo vệ. Contract testing đảm bảo API không vô tình break consumer.
Consumer-driven contract test: consumer viết test mô tả “tôi expect response có field X kiểu Y”. Server chạy test đó trong CI — nếu fail, nghĩa là đang break consumer. Pact là framework phổ biến nhất cho pattern này.
Schema validation trong CI: dùng OpenAPI spec, mỗi PR chạy diff giữa spec cũ và spec mới. Nếu phát hiện breaking change (xoá field, đổi type), CI fail và yêu cầu review. Tool như openapi-diff, oasdiff tự động phát hiện breaking change.
# CI step: check breaking changes
oasdiff breaking openapi-v1.yaml openapi-current.yaml
# Exit code != 0 nếu có breaking change
Mình thêm bước này vào CI pipeline cho mọi service có consumer bên ngoài team. Nó đã chặn ít nhất 3 breaking change vô tình trong 6 tháng qua — developer sửa model rồi quên rằng response field cũng thay đổi theo.
Tóm tắt
API versioning tồn tại vì consumer không update cùng lúc với producer — mobile app chờ store review, third-party integration có lịch deploy riêng, IoT device có thể không bao giờ update. Thay đổi API mà không thông báo trước là phá vỡ contract và trust.
Phân biệt rõ non-breaking (thêm field, thêm endpoint, thêm optional param) và breaking (xoá field, đổi type, đổi validation). Non-breaking change evolve tự do trong version hiện tại, không cần version mới. Breaking change cần versioning strategy hoặc expand-contract pattern để migrate dần.
URL path versioning (/v1/, /v2/) là lựa chọn mặc định — đơn giản, tường minh, tooling tốt. Header versioning và content negotiation có ưu điểm REST purity nhưng phức tạp hơn trong test, debug, và caching. GraphQL approach (field deprecation, no versioning) phù hợp khi đã có lý do khác để adopt GraphQL.
Deprecation policy với timeline rõ ràng, Sunset header, migration guide chi tiết, và usage metric để track consumer migration. Xoá version cũ theo data thực tế (traffic về 0), không chỉ theo calendar. Contract testing trong CI bảo vệ khỏi breaking change vô tình — automation rẻ hơn incident nhiều lần.