Skip to content

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, header Sunset và 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.Versioning vớ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 đổiBreaking?
Thêm trường vào responseKhông
Thêm endpoint mớiKhông
Thêm tham số tuỳ chọn vào requestKhô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 responseCó
Đổi kiểu của một trường (int → string)Có
Thêm trường bắt buộc vào requestCó
Siết chặt validationCó
Đổi ý nghĩa của một trườngCó — và tệ nhất
Đổi mã trạng thái trả vềCó
Đổi thứ tự mặc định của danh sáchCó 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 ​

csharp
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ƯuNhược
URL segmentNhìn là thấy, test bằng trình duyệt, hiện rõ trong logURL của cùng một tài nguyên đổi theo version — vi phạm tinh thần REST
HeaderURL ổn định, đúng REST hơnKhông test được bằng thanh địa chỉ, dễ quên, khó debug
Query stringDễ dùng, dễ testLẫ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ỏ ​

csharp
[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:

http
api-supported-versions: 1.0, 2.0
api-deprecated-versions: 1.0

Như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:

csharp
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ó:

csharp
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 đôi

Sử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
csharp
// 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:

bash
git log --since="6 months ago" --oneline -p -- '*Dto.cs' '*Request.cs' '*Response.cs' \
  | grep -E '^[-+]\s+public ' | sort | uniq -c | sort -rn

Dò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 raVì sao
1Thêm Tags vào CustomerDtoCó (v2)Không cầnClient cũ bỏ qua trường lạ
2Thêm endpoint GET /customers/searchCó (v2)Không cầnKhông đụng endpoint cũ
3Thêm ?includeInactive (tuỳ chọn)KhôngĐúngMặc định giữ nguyên hành vi cũ
4Đổi Total từ decimal sang stringKhôngPhải lênClient parse số sẽ hỏng
5Bắt buộc TaxCode khi tạo khách doanh nghiệpKhôngPhải lênRequest cũ thành 400
6Đổi MaxLength(200) → MaxLength(100) cho NameKhôngPhải lênSiết validation
7Đổi thứ tự mặc định sang cũ nhất trướcKhôngPhải lênMọi màn hình danh sách đảo lộn
8Sửa lỗi chính tả trong message của lỗi 400KhôngĐúngKhông ai nên parse chuỗi đó
9Đổi 404 thành 204 khi không có dữ liệuKhôngPhải lênClient kiểm tra mã trạng thái
10Đổi Total từ chưa thuế sang đã thuếKhôngTệ nhấtKhô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:

csharp
// 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:

csharp
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:

sql
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:

ClientRequestsSố ngày hoạt độngLần cuốiKết luận
crm-web412.9007vài giây trướcĐội mình — nâng cấp được ngay
mobile-ios-3.288.4107vài giây trướcBản cũ còn trên máy người dùng — chờ
partner-erp1.20473 phút trướcĐối tác ngoài — phải liên hệ
accounting-nightly7702:00 hôm nay1 request/ngày — dễ bỏ sót nhất
qa-postman6324 ngày trướcBỏ qua

Ba điều bảng này cho thấy mà biểu đồ phần trăm không cho thấy:

  1. accounting-nightly chiế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.
  2. Cột ActiveDays quan trọng hơn cột Requests. 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ỏ.
  3. mobile-ios-3.2 khô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:

csharp
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:

ClientPhản ứngĐộ trễ phát hiện
crm-webMàn hình báo lỗi ngay~15 giây — có người đang dùng
mobile-ios-3.2Crash, không có thông báo~40 giây (qua Sentry)
partner-erpRetry 3 lần rồi bỏ, ghi log nội bộKhông ai báo trong 5 phút
accounting-nightlyKhô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:

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

  2. Đo từ phía server, đừng đợi người kêu. Đếm số 410 theo 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:

    sql
    SELECT ClientId, COUNT(*) AS Blocked, MIN(Timestamp) AS FirstHit
    FROM   ApiLogs
    WHERE  Status = 410 AND Timestamp > @brownoutStart
    GROUP  BY ClientId;
  3. 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:

  1. Câu hỏi đúng là "thay đổi này có breaking không", không phải "đặt version ở đâu".
  2. Version mới chỉ là nửa công việc. Nửa còn lại là gỡ version cũ, có Sunset và có đo lường.
  3. Chỉ nhân đôi hợp đồng, không nhân đôi nghiệp vụ.

Tham khảo ​

Điều hướng ​

Phát hành theo giấy phép MIT.