9.2 - API Versioning
Tóm tắt: Câu hỏi thật sự của versioning không phải "đặt version ở đâu" mà là "thay đổi nào buộc phải lên version mới". Thêm một trường vào response là không breaking; xoá một trường, đổi kiểu, hay thêm một trường bắt buộc vào request thì là. Nhầm lẫn ranh giới này dẫn tới hai thái cực đều tệ: hoặc lên v2, v3, v4 cho những thay đổi vô hại, hoặc âm thầm làm hỏng client đang chạy. Và version mới chỉ là một nửa công việc — nửa còn lại là gỡ version cũ, với
Deprecated, headerSunsetvà một lịch trình thật.
Mục tiêu bài học
Sau bài này bạn có thể:
- Phân loại một thay đổi là breaking hay không.
- Chọn cách đặt version và nêu được cái giá của lựa chọn đó.
- Cấu hình
Asp.Versioningvới nhiều reader. - Đánh dấu deprecated và công bố lịch gỡ bỏ.
- Tổ chức code để hai version cùng tồn tại mà không nhân đôi nghiệp vụ.
Nội dung bài học
9.2.1 — Thay đổi nào là breaking
| Thay đổi | Breaking? |
|---|---|
| Thêm trường vào response | Không |
| Thêm endpoint mới | Không |
| Thêm tham số tuỳ chọn vào request | Không |
| Nới lỏng validation (cho phép nhiều giá trị hơn) | Không |
| Xoá hoặc đổi tên trường trong response | Có |
Đổi kiểu của một trường (int → string) | Có |
| Thêm trường bắt buộc vào request | Có |
| Siết chặt validation | Có |
| Đổi ý nghĩa của một trường | Có — và tệ nhất |
| Đổi mã trạng thái trả về | Có |
| Đổi thứ tự mặc định của danh sách | Có trên thực tế |
Hai dòng cuối cần giải thích.
Đổi ý nghĩa là loại breaking nguy hiểm nhất vì nó không gây lỗi: trường total từng là giá chưa thuế, nay thành đã thuế. Client vẫn chạy, vẫn parse được, và hiển thị sai số tiền cho tới khi ai đó phát hiện. Đổi ý nghĩa thì phải đổi tên trường, không chỉ đổi version.
Thứ tự mặc định về lý thuyết không phải hợp đồng, nhưng client luôn dựa vào nó. Đổi từ "mới nhất trước" sang "cũ nhất trước" sẽ làm hỏng mọi màn hình.
Nguyên tắc rút ra: nếu client cũ vẫn chạy đúng sau thay đổi thì không cần version mới. Version là thứ đắt — mỗi version còn sống là một tập code phải bảo trì, test và vá lỗi bảo mật.
9.2.2 — Ba cách đặt version
builder.Services.AddApiVersioning(options =>
{
options.DefaultApiVersion = new ApiVersion(1, 0);
options.AssumeDefaultVersionWhenUnspecified = true;
options.ReportApiVersions = true; // tra api-supported-versions
options.ApiVersionReader = ApiVersionReader.Combine(
new UrlSegmentApiVersionReader(), // /api/v1/customers
new HeaderApiVersionReader("X-Api-Version"), // header
new QueryStringApiVersionReader("api-version")); // ?api-version=1.0
})
.AddApiExplorer(options =>
{
options.GroupNameFormat = "'v'VVV";
options.SubstituteApiVersionInUrl = true; // Swagger hien URL that
});| Cách | Ưu | Nhược |
|---|---|---|
| URL segment | Nhìn là thấy, test bằng trình duyệt, hiện rõ trong log | URL của cùng một tài nguyên đổi theo version — vi phạm tinh thần REST |
| Header | URL ổn định, đúng REST hơn | Không test được bằng thanh địa chỉ, dễ quên, khó debug |
| Query string | Dễ dùng, dễ test | Lẫn với tham số nghiệp vụ, dễ bị cache sai |
Thực tế: URL segment thắng vì nó hiển nhiên. Khi một client báo lỗi và dán URL vào ticket, bạn biết ngay họ dùng version nào.
AssumeDefaultVersionWhenUnspecified = true là con dao hai lưỡi: nó giúp client cũ không gãy khi bạn thêm versioning vào API đang chạy, nhưng nó cũng nghĩa là client không bao giờ bị buộc phải khai báo version — và khi bạn gỡ v1, họ gãy mà không được báo trước. Bật nó lúc chuyển đổi, tắt nó khi mọi client đã khai báo rõ.
9.2.3 — Đánh dấu deprecated và lịch gỡ bỏ
[ApiController]
[Route("api/v{version:apiVersion}/customers")]
[ApiVersion("1.0", Deprecated = true)]
[ApiVersion("2.0")]
public class CustomersController : ControllerBase
{
[HttpGet, MapToApiVersion("1.0")]
public Task<IActionResult> GetAllV1(CancellationToken ct);
[HttpGet, MapToApiVersion("2.0")]
public Task<IActionResult> GetAllV2(CancellationToken ct);
}ReportApiVersions = true khiến mọi response mang theo:
api-supported-versions: 1.0, 2.0
api-deprecated-versions: 1.0Nhưng không client nào đọc header nếu bạn không nói cho họ. Deprecation thật cần ba thứ:
1. Header Sunset (RFC 8594) — ngày endpoint ngừng hoạt động:
app.Use(async (ctx, next) =>
{
ctx.Response.OnStarting(() =>
{
var requested = ctx.GetRequestedApiVersion();
if (requested?.MajorVersion == 1)
{
ctx.Response.Headers["Sunset"] = "Sat, 31 Dec 2026 23:59:59 GMT";
ctx.Response.Headers["Link"] =
"<https://docs.example.com/migrate-v2>; rel=\"deprecation\"";
}
return Task.CompletedTask;
});
await next(ctx);
});2. Đo lường. Log version của mỗi request và dựng một biểu đồ. Bạn không gỡ được v1 nếu không biết ai còn dùng nó:
logger.LogInformation("API {Version} {Path} từ {ClientId}",
ctx.GetRequestedApiVersion()?.ToString(), ctx.Request.Path, clientId);3. Liên hệ trực tiếp. Header và tài liệu không đủ. Lấy danh sách client còn dùng v1 từ log, gửi email, và cho họ một mốc thời gian.
Một quy trình hợp lý: công bố deprecation → 6 tháng với header Sunset → tắt v1 trong một ngày (brownout) để client phát hiện → tắt hẳn sau 2 tuần.
9.2.4 — Tổ chức code cho nhiều version
Cạm bẫy lớn nhất là nhân đôi cả tầng nghiệp vụ:
Controllers/V1/CustomersController.cs
Services/V1/CustomerService.cs <- nhân đôi
Repositories/V1/CustomerRepository.cs <- nhân đôiSửa một bug là phải sửa ở hai nơi, và chắc chắn có lúc bạn quên một nơi.
Nguyên tắc: chỉ nhân đôi lớp hợp đồng, tức controller và DTO. Nghiệp vụ dùng chung:
Controllers/V1/CustomersController.cs -> CustomerServiceChung
Controllers/V2/CustomersController.cs -> CustomerServiceChung
Contracts/V1/CustomerDto.cs
Contracts/V2/CustomerDto.cs
Services/CustomerService.cs <- MỘT bản duy nhất// V2 trả về nhiều trường hơn; V1 chỉ chiếu một tập con
public static CustomerDtoV1 ToV1(this Customer c) => new(c.Id, c.Name, c.Email);
public static CustomerDtoV2 ToV2(this Customer c) =>
new(c.Id, c.Name, c.Email, c.Phone, c.Tags, c.CreatedAt);Khi V2 cần hành vi khác chứ không chỉ hình dạng khác, đừng nhồi if (version == 2) vào service. Tách ra một phương thức riêng có tên nói rõ khác biệt — CalculateTotalWithTaxAsync thay vì CalculateTotalAsync(version).
Và giữ số version còn sống ở mức tối thiểu: hai là con số lành mạnh. Ba trở lên nghĩa là bạn chưa gỡ được cái cũ.
9.2.5 — Rà lại code của bạn
Danh sách rà soát versioning
- Đã phân loại rõ thay đổi nào là breaking trước khi quyết định lên version.
- Thay đổi ý nghĩa của một trường được xử lý bằng đổi tên trường, không chỉ lên version.
- Chiến lược version nhất quán trên toàn API.
- ReportApiVersions được bật.
- Version deprecated có header Sunset kèm ngày cụ thể.
- Version của mỗi request được log để biết ai còn dùng bản cũ.
- Tầng nghiệp vụ không bị nhân đôi theo version, chỉ controller và DTO.
- Không có if theo version nào nằm trong tầng nghiệp vụ.
- Số version còn sống không quá hai.
Bài tập áp dụng
Bài 1 — Phân loại thay đổi trong chính API của bạn
Lấy 10 thay đổi gần nhất trong API của bạn (đọc git log của thư mục DTO và controller). Phân loại từng cái là breaking hay không theo bảng ở mục 9.2.1. Đếm xem bao nhiêu cái đã lên version mới mà lẽ ra không cần, và bao nhiêu cái không lên version mà lẽ ra phải lên.
Tiêu chí hoàn thành: với mỗi thay đổi, bạn nêu được client cũ còn chạy đúng không — chứ không chỉ nói "có sửa DTO".
Gợi ý và lời giải — Bài 1
Gợi ý. Lọc lịch sử theo đúng những file tạo nên hợp đồng công khai:
git log --since="6 months ago" --oneline -p -- '*Dto.cs' '*Request.cs' '*Response.cs' \
| grep -E '^[-+]\s+public ' | sort | uniq -c | sort -rnDòng bắt đầu bằng - là trường mất đi — đó là nơi breaking change ẩn náu.
Lời giải — bảng phân loại điển hình sau khi rà 10 thay đổi thật:
| # | Thay đổi | Đã lên version? | Đúng ra | Vì sao |
|---|---|---|---|---|
| 1 | Thêm Tags vào CustomerDto | Có (v2) | Không cần | Client cũ bỏ qua trường lạ |
| 2 | Thêm endpoint GET /customers/search | Có (v2) | Không cần | Không đụng endpoint cũ |
| 3 | Thêm ?includeInactive (tuỳ chọn) | Không | Đúng | Mặc định giữ nguyên hành vi cũ |
| 4 | Đổi Total từ decimal sang string | Không | Phải lên | Client parse số sẽ hỏng |
| 5 | Bắt buộc TaxCode khi tạo khách doanh nghiệp | Không | Phải lên | Request cũ thành 400 |
| 6 | Đổi MaxLength(200) → MaxLength(100) cho Name | Không | Phải lên | Siết validation |
| 7 | Đổi thứ tự mặc định sang cũ nhất trước | Không | Phải lên | Mọi màn hình danh sách đảo lộn |
| 8 | Sửa lỗi chính tả trong message của lỗi 400 | Không | Đúng | Không ai nên parse chuỗi đó |
| 9 | Đổi 404 thành 204 khi không có dữ liệu | Không | Phải lên | Client kiểm tra mã trạng thái |
| 10 | Đổi Total từ chưa thuế sang đã thuế | Không | Tệ nhất | Không lỗi, chỉ sai tiền |
Hai con số rút ra: 2 lần lên version thừa, 6 lần thiếu.
Vì sao chiều "thiếu" nguy hiểm hơn hẳn. Lên version thừa chỉ tốn công bảo trì — khó chịu nhưng nhìn thấy được. Thiếu version thì client hỏng ở môi trường của người khác, thường vào lúc bạn không theo dõi, và log của bạn chỉ thấy tỉ lệ 4xx tăng mà không biết vì sao.
Riêng thay đổi số 10 không sửa được bằng version. Đổi ý nghĩa mà giữ nguyên tên trường thì client chuyển sang v2 vẫn hiểu sai, vì họ đọc tên Total và tin vào nghĩa cũ. Cách đúng là đổi tên trường:
// v1 — giữ nguyên, không đụng tới
public record InvoiceDtoV1(Guid Id, decimal Total); // chưa thuế
// v2 — tên mới nói rõ nghĩa, cả hai cùng tồn tại một thời gian
public record InvoiceDtoV2(Guid Id, decimal SubTotal, decimal TotalIncludingTax);Giờ client không thể "vô tình đúng cú pháp nhưng sai nghĩa" nữa — trình biên dịch của họ báo thiếu trường.
Phép thử một câu cho mọi thay đổi: client cũ, không sửa một dòng nào, gửi đúng request cũ — có nhận được kết quả có nghĩa như cũ không? Trả lời "không" ở bất kỳ vế nào là breaking.
Bài 2 — Đo xem ai còn dùng v1
Thêm log version cho mỗi request, chạy một tuần, rồi dựng bảng lưu lượng theo client. Viết danh sách client cần liên hệ trước khi tắt v1.
Tiêu chí hoàn thành: bạn trả lời được câu "tắt v1 ngày mai thì ai hỏng?" bằng tên cụ thể, không phải bằng phần trăm.
Gợi ý và lời giải — Bài 2
Gợi ý. Phần trăm lưu lượng là con số sai để ra quyết định. Một client chiếm 0,1% lưu lượng có thể là hệ thống kế toán chạy một lần mỗi đêm — tắt nó đi là hỏng sổ sách.
Lời giải — middleware ghi log:
app.Use(async (ctx, next) =>
{
await next(ctx);
var version = ctx.GetRequestedApiVersion()?.ToString() ?? "(không khai báo)";
var clientId = ctx.User.FindFirstValue("client_id")
?? ctx.Request.Headers.UserAgent.ToString();
logger.LogInformation(
"ApiVersion {Version} {Method} {Path} client={ClientId} status={Status}",
version, ctx.Request.Method, ctx.Request.Path, clientId, ctx.Response.StatusCode);
});Đặt nó sau UseRouting — trước đó GetRequestedApiVersion() chưa có giá trị, đúng như bài 8.2 giải thích.
Truy vấn tổng hợp sau một tuần:
SELECT ClientId,
COUNT(*) AS Requests,
COUNT(DISTINCT CAST(Timestamp AS date)) AS ActiveDays,
MAX(Timestamp) AS LastSeen
FROM ApiLogs
WHERE Version = '1.0' AND Timestamp > DATEADD(day, -7, SYSUTCDATETIME())
GROUP BY ClientId
ORDER BY Requests DESC;Kết quả điển hình:
| Client | Requests | Số ngày hoạt động | Lần cuối | Kết luận |
|---|---|---|---|---|
crm-web | 412.900 | 7 | vài giây trước | Đội mình — nâng cấp được ngay |
mobile-ios-3.2 | 88.410 | 7 | vài giây trước | Bản cũ còn trên máy người dùng — chờ |
partner-erp | 1.204 | 7 | 3 phút trước | Đối tác ngoài — phải liên hệ |
accounting-nightly | 7 | 7 | 02:00 hôm nay | 1 request/ngày — dễ bỏ sót nhất |
qa-postman | 63 | 2 | 4 ngày trước | Bỏ qua |
Ba điều bảng này cho thấy mà biểu đồ phần trăm không cho thấy:
accounting-nightlychiếm 0,001% lưu lượng nhưng là job đối soát cuối ngày. Nhìn biểu đồ tròn thì nó vô hình.- Cột
ActiveDaysquan trọng hơn cộtRequests. Client chạy đủ 7/7 ngày là đang sống; client chỉ xuất hiện 2 ngày rồi im có thể đã bị bỏ. mobile-ios-3.2không nâng cấp được bằng cách bảo họ sửa code — bản cũ đã nằm trên máy người dùng. Nó quyết định mốc thời gian thật của cả kế hoạch.
Danh sách liên hệ rút ra: partner-erp (email kỹ thuật), chủ sở hữu accounting-nightly (thường là đội nội bộ khác), và đội mobile để biết bao giờ tỉ lệ cài đặt bản cũ về gần 0.
Một chi tiết dễ quên: log cả request không khai version. Chúng rơi vào version mặc định, nên khi bạn đổi mặc định từ 1.0 sang 2.0 thì chúng hỏng — dù chủ nhân của chúng tin rằng mình "không dùng v1".
Bài 3 — Brownout: tắt v1 năm phút
Trên staging, tắt v1 trong 5 phút rồi bật lại. Ghi lại client nào báo lỗi và bao lâu sau khi tắt thì họ báo.
Tiêu chí hoàn thành: bạn đo được độ trễ phát hiện — khoảng cách giữa lúc tắt và lúc có người thật sự nhận ra.
Gợi ý và lời giải — Bài 3
Gợi ý. Trả 410 Gone chứ không phải 404. 404 lẫn với "sai đường dẫn"; 410 nói rõ "từng có, đã gỡ" và giúp người gỡ rối tìm đúng nguyên nhân trong vài giây.
Lời giải — công tắc brownout:
app.Use(async (ctx, next) =>
{
var brownout = config.GetValue<bool>("ApiV1Brownout");
if (brownout && ctx.GetRequestedApiVersion()?.MajorVersion == 1)
{
ctx.Response.StatusCode = StatusCodes.Status410Gone;
ctx.Response.Headers["Sunset"] = "Sat, 31 Dec 2026 23:59:59 GMT";
await ctx.Response.WriteAsJsonAsync(new ProblemDetails
{
Status = 410,
Title = "Phiên bản API này đang trong đợt brownout theo kế hoạch",
Detail = "v1 sẽ ngừng hoạt động vào 31/12/2026. Xem hướng dẫn chuyển sang v2.",
Type = "https://docs.example.com/migrate-v2"
});
return;
}
await next(ctx);
});Đặt cờ trong cấu hình đọc lại được (IOptionsMonitor, bài 7.7) để bật tắt không cần deploy.
Kết quả điển hình của một đợt brownout 5 phút:
| Client | Phản ứng | Độ trễ phát hiện |
|---|---|---|
crm-web | Màn hình báo lỗi ngay | ~15 giây — có người đang dùng |
mobile-ios-3.2 | Crash, không có thông báo | ~40 giây (qua Sentry) |
partner-erp | Retry 3 lần rồi bỏ, ghi log nội bộ | Không ai báo trong 5 phút |
accounting-nightly | Không chạy vào giờ đó | Không phát hiện được |
Đây mới là kết quả thật sự của bài tập. Brownout 5 phút giữa ban ngày không chạm tới hai client cuối. Nếu bạn kết luận "không ai kêu, tắt được rồi", bạn sẽ làm hỏng job đối soát vào 2 giờ sáng hôm sau — và không ai biết cho tới khi kế toán đóng sổ.
Ba điều chỉnh làm brownout có ý nghĩa:
Chạy đủ một chu kỳ 24 giờ, không phải 5 phút. Job chạy theo lịch đêm chỉ lộ ra ở mốc này.
Đo từ phía server, đừng đợi người kêu. Đếm số
410theo client — nó cho bạn danh sách nạn nhân ngay lập tức, kể cả những client im lặng:sqlSELECT ClientId, COUNT(*) AS Blocked, MIN(Timestamp) AS FirstHit FROM ApiLogs WHERE Status = 410 AND Timestamp > @brownoutStart GROUP BY ClientId;Lặp lại tăng dần. 1 giờ → 1 ngày → 1 tuần. Mỗi vòng lộ ra một lớp client có chu kỳ dài hơn.
Vì sao brownout hiệu quả hơn email. Email báo deprecation bị bỏ qua vì nó không gây hậu quả gì. Brownout tạo ra một sự cố nhỏ, có kiểm soát, vào lúc bạn chọn — thay cho một sự cố lớn, không kiểm soát, vào lúc bạn tắt hẳn. Đây chính là lý do Google và GitHub đều dùng brownout trước mỗi lần gỡ API.
Tự kiểm tra
Thay đổi nào là breaking?
Xoá hoặc đổi tên trường trong response, đổi kiểu của trường, thêm trường bắt buộc vào request, siết chặt validation, đổi mã trạng thái, và đổi thứ tự mặc định của danh sách. Ngược lại, thêm trường vào response, thêm endpoint mới, thêm tham số tuỳ chọn và nới lỏng validation đều không breaking.
Vì sao đổi ý nghĩa của một trường là loại breaking nguy hiểm nhất?
Vì nó không gây lỗi. Trường total từng là giá chưa thuế nay thành đã thuế thì client vẫn chạy, vẫn parse được, và hiển thị sai số tiền cho tới khi ai đó phát hiện. Cách xử lý đúng là đổi tên trường chứ không chỉ lên version.
Ba cách đặt version có gì khác nhau?
URL segment dễ thấy, test được bằng trình duyệt và hiện rõ trong log, nhưng URL của cùng một tài nguyên đổi theo version. Header giữ URL ổn định và đúng REST hơn nhưng không test được bằng thanh địa chỉ. Query string dễ dùng nhưng lẫn với tham số nghiệp vụ. Thực tế URL segment thắng vì nó hiển nhiên.
AssumeDefaultVersionWhenUnspecified có rủi ro gì?
Nó giúp client cũ không gãy khi bạn thêm versioning vào API đang chạy, nhưng cũng nghĩa là client không bao giờ bị buộc khai báo version. Khi bạn gỡ version mặc định, họ gãy mà không được báo trước. Nên bật lúc chuyển đổi và tắt khi mọi client đã khai báo rõ.
Deprecation cần những gì ngoài việc đánh dấu Deprecated?
Ba thứ. Header Sunset theo RFC 8594 với ngày cụ thể và link tới tài liệu migrate. Đo lường bằng cách log version của mỗi request, vì bạn không gỡ được version cũ nếu không biết ai còn dùng. Và liên hệ trực tiếp với các client đó, vì header và tài liệu không đủ.
Tổ chức code cho nhiều version thế nào?
Chỉ nhân đôi lớp hợp đồng, tức controller và DTO, còn tầng nghiệp vụ giữ một bản duy nhất. Nhân đôi cả service và repository nghĩa là sửa một bug phải sửa hai nơi và chắc chắn có lúc quên. Khi version mới cần hành vi khác thì tách thành phương thức riêng có tên nói rõ khác biệt, đừng nhồi if theo version vào service.
Kết luận
Ba điều đáng nhớ nhất:
- Câu hỏi đúng là "thay đổi này có breaking không", không phải "đặt version ở đâu".
- Version mới chỉ là nửa công việc. Nửa còn lại là gỡ version cũ, có
Sunsetvà có đo lường. - Chỉ nhân đôi hợp đồng, không nhân đôi nghiệp vụ.
Tham khảo
- ASP.NET API Versioning (GitHub)
- RFC 8594 — The Sunset HTTP Header Field
- RESTful web API design — versioning
- Routing in ASP.NET Core
Điều hướng
- Bài trước: 9.1 — REST API Design Principles
- Bài tiếp theo: 9.3 — Swagger / OpenAPI
- Về module: Trang mục lục