9.11 - Mini case study
Tóm tắt: Một thay đổi hai ký tự — đổi kiểu trường
customerIdtừintsangstring— làm ứng dụng mobile của 12.000 người dùng không dùng được, và không thể sửa nhanh vì ứng dụng mobile phải qua kiểm duyệt cửa hàng mất 3 ngày. Bài học cốt lõi: API là hợp đồng, và với client bạn không kiểm soát được thời điểm cập nhật — web thì refresh là xong, mobile thì không. Bài này phân loại rõ thay đổi nào an toàn và thay đổi nào phá hợp đồng, trình bày ba chiến lược versioning, và cách phát hiện breaking change tự động trong CI trước khi nó tới tay người dùng.
Mục tiêu bài học
Sau bài này bạn có thể:
- Phân loại thay đổi an toàn và thay đổi phá hợp đồng.
- Chọn chiến lược versioning phù hợp.
- Hỗ trợ nhiều phiên bản mà không nhân đôi code.
- Phát hiện breaking change tự động trong CI.
Nội dung bài học
9.11.1 — Sự cố
// Trước
public record LeadDto(int CustomerId, string Name, decimal Value);
// Sau — đổi sang Guid cho nhất quán với phần còn lại
public record LeadDto(Guid CustomerId, string Name, decimal Value);// Client mobile mong doi
{ "customerId": 4821, "name": "Công ty ABC", "value": 5000000 }
// Nhận được
{ "customerId": "8f3a1b2c-...", "name": "Công ty ABC", "value": 5000000 }14:30 Deploy
14:34 Crash report tang vot: JsonParseException tren Android va iOS
14:40 12.000 người dùng không mở được app
14:45 Rollback backend — nhưng những người đã mở app trong 15 phút đó
vẫn có dữ liệu hỏng trong cache cục bộWeb app không bị ảnh hưởng vì nó dùng JavaScript (không kiểu chặt) và người dùng chỉ cần refresh. Mobile thì không: bản vá phải qua kiểm duyệt cửa hàng, và kể cả sau đó người dùng phải chủ động cập nhật.
9.11.2 — Phân loại thay đổi
An toàn — không phá client cũ:
// Thêm trường MỚI (client cũ bỏ qua trường không biết)
public record LeadDto(Guid Id, string Name, decimal Value, string? Source = null);
// Them endpoint moi
app.MapGet("/api/leads/{id}/history", ...);
// Thêm giá trị enum mới — CHỈ khi client xử lý được giá trị lạ
// Noi long rang buoc: bat buoc -> tuy chonPhá hợp đồng — client cũ gãy:
| Thay đổi | Vì sao gãy |
|---|---|
| Xoá trường | Client đọc null hoặc ném lỗi |
| Đổi kiểu trường | Deserialize thất bại — ca này |
| Đổi tên trường | Tương đương xoá rồi thêm |
| Đổi nghĩa trường | Tệ nhất — không lỗi, chỉ sai |
| Thêm trường bắt buộc vào request | Client cũ gửi thiếu → 400 |
| Đổi mã trạng thái HTTP | Client xử lý nhánh sai |
Hàng đổi nghĩa đáng sợ nhất: đổi Value từ "chưa thuế" sang "đã gồm thuế" không gây lỗi nào, chỉ khiến mọi con số hiển thị sai — và có thể vài tuần sau mới có người phát hiện.
9.11.3 — Ba chiến lược versioning
builder.Services.AddApiVersioning(options =>
{
options.DefaultApiVersion = new ApiVersion(1, 0);
options.AssumeDefaultVersionWhenUnspecified = true;
options.ReportApiVersions = true; // tra header api-supported-versions
options.ApiVersionReader = ApiVersionReader.Combine(
new UrlSegmentApiVersionReader(),
new HeaderApiVersionReader("X-Api-Version"));
});| Chiến lược | Ví dụ | Ưu | Nhược |
|---|---|---|---|
| URL | /api/v1/leads | Rõ ràng, dễ test, dễ cache | URL đổi khi lên phiên bản |
| Header | X-Api-Version: 2 | URL ổn định | Khó thử bằng trình duyệt |
| Media type | Accept: application/json;v=2 | Đúng chuẩn REST | Khó dùng nhất |
Khuyến nghị: dùng URL. Nó rõ ràng nhất khi đọc log, dễ thử bằng curl, và dễ định tuyến ở gateway (bài 18.3).
[ApiController]
[ApiVersion("1.0")]
[ApiVersion("2.0")]
[Route("api/v{version:apiVersion}/leads")]
public class LeadsController : ControllerBase
{
[HttpGet("{id:guid}")]
[MapToApiVersion("1.0")]
public async Task<ActionResult<LeadDtoV1>> GetV1(Guid id, CancellationToken ct)
{
var lead = await _sender.Send(new GetLeadQuery(new LeadId(id)), ct);
return Ok(LeadDtoV1.From(lead.Value)); // CustomerId la int
}
[HttpGet("{id:guid}")]
[MapToApiVersion("2.0")]
public async Task<ActionResult<LeadDtoV2>> GetV2(Guid id, CancellationToken ct)
{
var lead = await _sender.Send(new GetLeadQuery(new LeadId(id)), ct);
return Ok(LeadDtoV2.From(lead.Value)); // CustomerId la Guid
}
}9.11.4 — Không nhân đôi code
Sai lầm phổ biến khi versioning: chép toàn bộ tầng service cho mỗi phiên bản.
SAI:
LeadServiceV1.cs (500 dong)
LeadServiceV2.cs (500 dong, khac 20 dong)
-> sửa bug phải sửa hai chỗCách đúng: chỉ tầng DTO có phiên bản, logic nghiệp vụ dùng chung.
// Một service duy nhất, trả về model NGHIỆP VỤ
public async Task<Lead> GetAsync(LeadId id, CancellationToken ct);
// Hai DTO — chỉ khác cách TRÌNH BÀY
public record LeadDtoV1(int CustomerId, string Name, decimal Value)
{
public static LeadDtoV1 From(Lead lead) => new(
CustomerId: lead.LegacyCustomerId, // giữ cột cũ để tương thích
Name: lead.Name,
Value: lead.Value.Amount);
}
public record LeadDtoV2(Guid CustomerId, string Name, MoneyDto Value)
{
public static LeadDtoV2 From(Lead lead) => new(
CustomerId: lead.CustomerId.Value,
Name: lead.Name,
Value: MoneyDto.From(lead.Value));
}Quy tắc: phiên bản hoá ranh giới, không phiên bản hoá logic. Nếu bạn thấy mình chép một service, ranh giới đang bị đặt sai chỗ (bài 16.3).
9.11.5 — Phát hiện breaking change trong CI
Đây là phần có giá trị cao nhất: chặn sự cố trước khi nó tới production.
- name: Sinh tai lieu OpenAPI
run: dotnet run --project src/Crm.Api -- --generate-openapi > openapi-new.json
- name: So sánh với bản đã phát hành
run: |
npx @redocly/cli diff openapi-baseline.json openapi-new.json \
--fail-on breakingCông cụ so sánh hai tài liệu OpenAPI và làm CI thất bại nếu phát hiện thay đổi phá hợp đồng — đúng loại thay đổi trong ca này.
Bổ sung một test đọc được cho từng hợp đồng quan trọng:
[Fact]
public async Task LeadDtoV1_GiuNguyenHopDong()
{
var response = await _client.GetAsync("/api/v1/leads/8f3a...");
var json = await response.Content.ReadFromJsonAsync<JsonElement>();
// Khẳng định KIỂU, không chỉ sự tồn tại
Assert.Equal(JsonValueKind.Number, json.GetProperty("customerId").ValueKind);
Assert.Equal(JsonValueKind.String, json.GetProperty("name").ValueKind);
}Khẳng định kiểu chứ không chỉ sự tồn tại — vì đó chính xác là thứ đã gãy ở ca này.
9.11.6 — Vòng đời một phiên bản
1. Phat hanh v2, GIU v1 chay song song
2. Trả header cảnh báo trên v1:
Deprecation: true
Sunset: Sat, 31 Dec 2026 23:59:59 GMT
Link: </api/v2/leads>; rel="successor-version"
3. Theo dõi lưu lượng v1 theo từng client
4. Liên hệ trực tiếp những client còn dùng
5. Tắt v1 khi lưu lượng về gần 0 — VÀ đã báo trước đủ lâuThời gian giữ phiên bản cũ phụ thuộc loại client:
| Loại client | Thời gian tối thiểu |
|---|---|
| Web do bạn kiểm soát | Vài tuần |
| Mobile | 6–12 tháng |
| Đối tác bên thứ ba | 12 tháng trở lên, có thông báo bằng văn bản |
Hàng mobile là bài học của ca này: kể cả khi bạn phát hành bản vá ngay, một phần người dùng không bao giờ cập nhật. Giám sát lưu lượng theo phiên bản là cách duy nhất biết khi nào tắt được:
_meter.CreateCounter<long>("api.requests")
.Add(1, new KeyValuePair<string, object?>("version", apiVersion),
new KeyValuePair<string, object?>("client", clientId));9.11.7 — Rà lại code của bạn
Danh sách rà soát versioning API
- API có versioning ngay từ phiên bản đầu tiên.
- Phân biệt được thay đổi an toàn và thay đổi phá hợp đồng.
- Chỉ tầng DTO có phiên bản, logic nghiệp vụ dùng chung.
- Có kiểm tra breaking change tự động trong CI.
- Test khẳng định kiểu của trường, không chỉ sự tồn tại.
- Phiên bản cũ trả header Deprecation và Sunset.
- Có giám sát lưu lượng theo phiên bản và theo client.
- Thời gian giữ phiên bản cũ phù hợp với loại client.
Bài tập áp dụng
Bài 1 — Xếp loại năm thay đổi gần nhất của chính bạn
Lấy 5 thay đổi gần nhất trên API của bạn và xếp chúng vào an toàn hay phá hợp đồng. Với mỗi cái phá hợp đồng, viết ra client nào sẽ hỏng và hỏng như thế nào.
Tiêu chí hoàn thành: với mỗi thay đổi, bạn nêu được loại client nào chịu hậu quả nặng nhất — vì đó mới là thứ quyết định kế hoạch.
Gợi ý và lời giải — Bài 1
Gợi ý. Sự cố trong bài này cho thấy cùng một thay đổi gây hậu quả rất khác nhau tuỳ client. Hãy xếp thêm một cột: client này có cập nhật được trong ngày không?
Lời giải — bảng xếp loại có thêm cột hậu quả:
| Thay đổi | Phá hợp đồng? | Web (JS) | Mobile đã phát hành | Đối tác B2B |
|---|---|---|---|---|
int → Guid cho CustomerId | Có | Refresh là xong | Crash, chờ kiểm duyệt | Hỏng job đêm |
Thêm Tags vào response | Không | — | — | — |
Thêm ?status= tuỳ chọn | Không | — | — | — |
Name từ MaxLength(200) → (100) | Có | Lỗi 400 khi nhập dài | Lỗi 400, không rõ lý do | Bản ghi bị từ chối |
Bỏ trường LegacyCode | Có | Cột trống | Crash nếu parse chặt | Hỏng ánh xạ |
Ba điều bảng này nói ra mà một danh sách "breaking / không breaking" đơn thuần không nói:
Mức độ nghiêm trọng không nằm ở thay đổi, mà ở client. Cùng một lần đổi kiểu: web chỉ cần refresh, mobile thì crash và phải chờ cửa hàng duyệt. Chính khoảng cách này quyết định bạn được phép đi nhanh tới đâu.
Client bạn không kiểm soát đặt ra mốc thời gian. Bạn không rollback được bản cài trên máy người dùng. Với ứng dụng mobile, "gỡ v1" nghĩa là chờ tới khi tỉ lệ cài đặt bản cũ về gần 0 — thường là hàng tháng.
Rollback backend không hoàn tác được thiệt hại. Trong sự cố của bài, 15 phút đủ để dữ liệu hỏng đi vào cache cục bộ trên máy người dùng. Backend trở lại bình thường nhưng app vẫn hỏng cho tới khi người dùng xoá dữ liệu ứng dụng.
Hệ quả cho cách làm việc: có client mobile đã phát hành thì mọi thay đổi phá hợp đồng bắt buộc đi qua version mới, kể cả khi bạn chắc chắn "chỉ mình đội mình gọi API này". Cái giá của việc nhầm là một bản vá phải qua kiểm duyệt cửa hàng.
Một mẹo rẻ để biết trước. Ghi log User-Agent và phiên bản app cho mỗi request. Khi cần đánh giá một thay đổi, bạn trả lời được ngay "bao nhiêu phần trăm lưu lượng đến từ bản không cập nhật được?" thay vì phải đoán.
Bài 2 — Bắt CI thất bại đúng lúc cần
Thêm bước so sánh OpenAPI vào pipeline và cố tình đổi kiểu một trường để xác nhận CI thất bại.
Tiêu chí hoàn thành: CI đỏ khi đổi kiểu, và xanh khi chỉ thêm trường — chứng minh bước kiểm tra không phải là cái chuông kêu suốt ngày.
Gợi ý và lời giải — Bài 2
Gợi ý. Một bước kiểm tra kêu cả với thay đổi an toàn sẽ bị người ta thêm continue-on-error: true trong vòng một tuần. Hãy kiểm chứng cả hai chiều trước khi bật nó cho cả đội.
Lời giải — bước CI đầy đủ:
- name: Sinh hợp đồng hiện tại
run: |
dotnet build -c Release
dotnet tool install --global Swashbuckle.AspNetCore.Cli
swagger tofile --output current.json bin/Release/net9.0/CrmApi.dll v1
- name: Lấy hợp đồng của bản đã phát hành
run: |
gh release download latest --pattern swagger.json --output baseline.json
- name: So sánh với bản đã phát hành
run: npx @redocly/cli diff baseline.json current.json --format=stylishKiểm chứng chiều thứ nhất — đổi kiểu, CI phải đỏ:
public record LeadDto(Guid CustomerId, string Name, decimal Value); // đổi từ int✗ BREAKING components.schemas.LeadDto.properties.customerId.type
Type changed from 'integer' to 'string'.
1 breaking change found.
Error: Process completed with exit code 1.Kiểm chứng chiều thứ hai — thêm trường, CI phải xanh:
public record LeadDto(int CustomerId, string Name, decimal Value, string[]? Tags = null);✓ 1 non-breaking change: components.schemas.LeadDto.properties.tags added.
No breaking changes found.Hai chiều đều đúng, nên bước này dùng được thật.
Ba chi tiết làm bước kiểm tra này sống sót lâu dài:
File cơ sở phải là bản đã phát hành, không phải nhánh chính. Lấy từ nhánh chính thì một breaking change đã merge sẽ trở thành "bình thường mới", và mọi pull request sau đó không báo gì nữa.
Cho phép vượt qua một cách tường minh, có dấu vết. Đôi khi bạn cố ý phá hợp đồng vì đang phát hành v2. Đừng tắt bước kiểm tra — hãy bắt người ta khai báo:
yaml- name: So sánh hợp đồng if: "!contains(github.event.pull_request.labels.*.name, 'breaking-change-approved')" run: npx @redocly/cli diff baseline.json current.jsonGiờ việc phá hợp đồng cần một nhãn trên pull request — nhìn thấy được trong lịch sử, và có người phải chủ động gắn.
Gắn thêm một dòng nhắc trong thông báo lỗi. Người gặp lỗi lần đầu thường không biết phải làm gì:
yamlrun: | npx @redocly/cli diff baseline.json current.json || { echo "::error::Thay đổi này phá hợp đồng API. Hoặc sửa lại cho tương thích ngược," echo "::error::hoặc mở v2 theo hướng dẫn ở bài 9.3, rồi gắn nhãn breaking-change-approved." exit 1 }
Giới hạn phải nhớ. Bước này không bắt được đổi ý nghĩa: Value chuyển từ "chưa thuế" sang "đã thuế" giữ nguyên tên, kiểu và ràng buộc — hợp đồng OpenAPI không đổi một ký tự. Máy không thấy gì, chỉ người review thấy.
Bài 3 — Đo phân bố lưu lượng theo phiên bản
Thêm chỉ số đếm request theo phiên bản và xem phân bố thực tế.
Tiêu chí hoàn thành: bạn có một biểu đồ trả lời được "còn bao lâu nữa thì tắt được v1?" — bằng xu hướng, không phải bằng một con số tại một thời điểm.
Gợi ý và lời giải — Bài 3
Gợi ý. Một con số "v1 chiếm 12%" không nói được gì. Con số đó đang giảm nhanh hay đứng yên mới là thứ quyết định.
Lời giải — chỉ số:
public sealed class ApiMetrics
{
private readonly Counter<long> _requests;
public ApiMetrics(IMeterFactory factory)
{
var meter = factory.Create("Crm.Api");
_requests = meter.CreateCounter<long>("crm.api.requests");
}
public void Record(string version, string clientKind, int status) =>
_requests.Add(1,
new KeyValuePair<string, object?>("api.version", version),
new KeyValuePair<string, object?>("client.kind", clientKind),
new KeyValuePair<string, object?>("http.status_class", $"{status / 100}xx"));
}app.Use(async (ctx, next) =>
{
await next(ctx);
metrics.Record(
ctx.GetRequestedApiVersion()?.ToString() ?? "unset",
ClassifyClient(ctx.Request.Headers.UserAgent),
ctx.Response.StatusCode);
});Ba nhãn, và vì sao đúng ba nhãn này:
| Nhãn | Trả lời câu hỏi |
|---|---|
api.version | Còn ai dùng v1? |
client.kind | Ai trong số đó cập nhật được? |
http.status_class | v2 có thật sự hoạt động tốt không? |
Tuyệt đối không gắn customer_id hay user_id làm nhãn. Mỗi tổ hợp nhãn là một chuỗi thời gian riêng; thêm một nhãn có hàng chục nghìn giá trị là làm nổ hệ thống metric — đúng cảnh báo ở bài 18.5. Câu hỏi "ai còn dùng" thuộc về log, không thuộc về metric.
Phân bố điển hình sau 8 tuần theo dõi:
Tuần v1 v2 Ghi chú
1 94% 6% vừa phát hành v2
2 81% 19% web chuyển xong
3 64% 36% mobile phát hành bản mới
4 41% 59% người dùng cập nhật dần
5 23% 77%
6 16% 84%
7 13% 87% <- bắt đầu phẳng
8 12% 88% <- vẫn phẳngĐường cong phẳng ở tuần 7 mới là thông tin thật. 12% còn lại không tự biến mất: đó là những người không bao giờ cập nhật app, cộng với vài đối tác chưa đụng tới. Chờ thêm sáu tuần nữa cũng vẫn là 12%.
Đó là lúc chuyển từ chờ sang hành động:
- Tách 12% đó theo
client.kindđể biết là mobile cũ hay đối tác. - Lấy danh sách cụ thể từ log và liên hệ trực tiếp.
- Công bố ngày tắt, brownout một ngày, rồi tắt.
Một chỉ số nữa đáng theo dõi song song: tỉ lệ 4xx/5xx của riêng v2. Nếu v2 có tỉ lệ lỗi cao hơn v1, việc người ta chưa chuyển có thể không phải vì lười — mà vì v2 đang hỏng ở một trường hợp nào đó. Không có nhãn http.status_class, bạn sẽ thúc mọi người chuyển sang một phiên bản tệ hơn.
Tự kiểm tra
Vì sao client mobile khiến sự cố nghiêm trọng hơn?
Vì bản vá phải qua kiểm duyệt cửa hàng mất nhiều ngày, và kể cả sau đó người dùng phải chủ động cập nhật. Web app thì chỉ cần refresh là xong.
Thay đổi nào là nguy hiểm nhất?
Đổi nghĩa của một trường, ví dụ từ giá chưa thuế sang giá đã gồm thuế. Nó không gây lỗi nào, chỉ khiến mọi con số hiển thị sai, và có thể vài tuần sau mới có người phát hiện.
Nên chọn chiến lược versioning nào?
Versioning theo URL. Nó rõ ràng nhất khi đọc log, dễ thử bằng curl, và dễ định tuyến ở gateway. Header và media type giữ URL ổn định hơn nhưng khó thử và khó chẩn đoán hơn.
Quy tắc để không nhân đôi code khi versioning là gì?
Phiên bản hoá ranh giới, không phiên bản hoá logic. Chỉ tầng DTO có phiên bản, còn service và domain dùng chung. Nếu thấy mình chép một service thì ranh giới đang bị đặt sai chỗ.
Vì sao test nên khẳng định kiểu chứ không chỉ sự tồn tại của trường?
Vì đổi kiểu là loại breaking change gây sự cố trong ca này. Một test chỉ kiểm tra trường có tồn tại vẫn xanh khi kiểu đổi từ số sang chuỗi.
Giữ phiên bản cũ bao lâu?
Tuỳ loại client: vài tuần với web do bạn kiểm soát, sáu tới mười hai tháng với mobile, và từ mười hai tháng trở lên kèm thông báo bằng văn bản với đối tác bên thứ ba. Giám sát lưu lượng theo phiên bản là cách duy nhất biết khi nào tắt được.
Kết luận
Ba điều đáng nhớ nhất:
- API là hợp đồng, và với mobile bạn không kiểm soát được thời điểm client cập nhật.
- Phiên bản hoá DTO, không phiên bản hoá logic — nếu không bạn sẽ chép cả tầng service.
- Kiểm tra breaking change trong CI chặn được đúng lớp sự cố này.
Tham khảo
- Web API trong ASP.NET Core
- Xử lý lỗi trong Web API
- RFC 7807 — Problem Details
- API design cho microservices
Điều hướng
- Bài trước: 9.10 — Mở rộng và đào sâu
- Bài tiếp theo: 9.12 — Ví dụ thực tế nhanh
- Về module: Trang mục lục