Hợp đồng giao tiếp
Thiết kế một API nhỏ giữa catalog và cart: dữ liệu có nghĩa gì, kết quả được xử lý ra sao và đổi phiên bản mà không làm hỏng bên dùng.
Bạn cần biết gì trước khi đọc
Bạn chỉ cần đọc được function, object và Promise trong TypeScript. Ví dụ dùng shop giả định gồm shell (khung ứng dụng), catalog (sản phẩm) và cart (giỏ hàng). Code là mẫu contract để học, chưa phải implementation gọi backend. Các function truyền trực tiếp trong bài dành cho remote chạy cùng môi trường JavaScript của trang; iframe cần message protocol riêng.
Mục lục
- Vấn đề bắt đầu từ một con số
- Contract là thỏa thuận để hai phần dùng được nhau
- Thiết kế command và kết quả
- Event thông báo gì cho header
- Kiểm tra dữ liệu trước khi dùng
- Shell truyền chức năng cart cho catalog thế nào
- Đổi contract mà không làm hỏng phiên bản cũ
- Kiểm thử điều đã cam kết
- Đóng gói và xử lý khi contract không khớp
- Bài tập tự kiểm tra
- Đọc tiếp
- Tài liệu tham khảo
Vấn đề bắt đầu từ một con số
Giỏ hàng có hai chiếc áo và một chiếc cốc. Header nên hiện số 3 hay 2?
Cả hai đều có thể hợp lý: 3 là tổng số đơn vị sản phẩm, còn 2 là số dòng trong giỏ. Nhưng nếu cart trả itemCount: 2 theo cách đếm dòng, còn shell hiểu đó là tổng quantity, badge sẽ sai mà không có lỗi TypeScript nào.
Một lỗi khác xuất hiện khi cart deploy trước: cart bắt đầu yêu cầu field mới, nhưng catalog đang mở trong browser vẫn gửi input cũ. Hai bên không nâng cấp cùng lúc, nên không thể dựa vào việc source mới nhất của chúng đang khớp nhau.
Bài này đi từ lời gọi thêm hàng nhỏ nhất đến cách giữ nó dùng được qua nhiều phiên bản. Mình dùng tổng quantity cho itemCount: hai chiếc áo cộng một chiếc cốc luôn cho ra 3.
Contract là thỏa thuận để hai phần dùng được nhau
Catalog cần nhờ cart thêm hàng mà không biết cart dùng Redux, Zustand hay thư viện nào. Cart công bố một hàm addItem; catalog gọi hàm đó và xử lý kết quả. Thỏa thuận về cách gọi và kết quả nhận lại chính là contract.
Trong quan hệ này, cart là provider, tức bên cung cấp chức năng. Catalog là consumer, tức bên dùng chức năng. Cùng một module có thể cung cấp API này nhưng lại dùng API khác; đây không phải hai loại micro-frontend cố định.
Interface TypeScript mô tả một phần contract. Phần còn lại là những quy tắc mà type chưa nói hết:
| Điều cần thống nhất | Quyết định trong ví dụ |
|---|---|
| Ai chịu trách nhiệm? | Cart team sở hữu API giỏ ở frontend; backend xác nhận dữ liệu nghiệp vụ |
| Catalog được làm gì? | Yêu cầu thêm sản phẩm, không sửa trực tiếp store cart |
| Số lượng có nghĩa gì? | quantity là lượng muốn thêm; itemCount là tổng quantity sau cập nhật |
| Khi nào coi là thành công? | Khi backend đã xác nhận thao tác |
| Khi chưa biết server đã xử lý chưa? | Trả kết quả unknown, không coi như bị từ chối |
| Header mở muộn thì sao? | Đọc snapshot hiện tại, không chờ event cũ được phát lại |
Bạn có thể nghĩ contract như phần hướng dẫn đi kèm một ổ cắm: không chỉ cần cắm vừa mà còn phải dùng đúng điện áp. Với phần mềm, “cắm vừa” là field đúng type; “dùng đúng” là hai bên hiểu cùng ý nghĩa và hành vi. So sánh này chỉ để nhấn mạnh rằng đúng cấu trúc chưa đủ.
Mình sẽ giữ API công khai nhỏ. Nếu catalog chỉ cần thêm hàng, đừng đưa cho nó cả store, reducer hoặc cache key nội bộ của cart. Khi cart đổi implementation, catalog không nên phải sửa theo.
Thiết kế command và kết quả
Bắt đầu từ việc catalog cần làm
Giả sử người dùng muốn thêm một chiếc cốc. Catalog cần gửi ba dữ liệu: giỏ nào, sản phẩm nào và thêm bao nhiêu. Nó cũng cần chờ kết quả để cập nhật nút bấm.
const result = await cart.addItem(
{ cartId: 'cart-demo', productId: 'mug', quantity: 1 },
{ operationId: 'operation-demo-001' },
);Đây là một command: lời yêu cầu thay đổi dữ liệu. Object đầu là input nghiệp vụ. Object thứ hai chứa tùy chọn của lần gọi; operationId nhận diện một ý định thêm hàng để theo dõi và xử lý retry. Ta sẽ quay lại ID này sau khi hiểu các kết quả.
cart ở đây là API của cart service trong frontend, không phải component màn hình cart. API này phải dùng được khi catalog đang mở và người dùng chưa truy cập /cart.
Đọc interface theo từng phần
Contract đầy đủ cho lời gọi trên như sau. Bạn không cần đọc hết một lượt: đọc AddItemInputV1, rồi CartSnapshotV1, cuối cùng AddItemResultV1 và hàm addItem.
export type CartSnapshotV1 = Readonly<{
cartId: string;
revision: number;
itemCount: number;
}>;
export type AddItemInputV1 = Readonly<{
cartId: string;
productId: string;
quantity: number;
}>;
export type AddItemResultV1 =
| Readonly<{
status: 'committed';
snapshot: CartSnapshotV1;
}>
| Readonly<{
status: 'rejected';
code: 'INVALID_QUANTITY' | 'OUT_OF_STOCK' | 'FORBIDDEN';
}>
| Readonly<{
status: 'unknown';
operationId: string;
}>;
export interface CartCommandsV1 {
addItem(
input: AddItemInputV1,
options: Readonly<{
operationId: string;
signal?: AbortSignal;
}>,
): Promise<AddItemResultV1>;
}Có bốn điểm cần hiểu trong đoạn này:
- Input:
quantitylà số nguyên dương. Giá trị 2 nghĩa là thêm hai đơn vị, không phải đặt tổng số lượng trong giỏ thành 2. Xóa sản phẩm cần thao tác khác, không gửi quantity âm. - Snapshot: là bản dữ liệu tại một thời điểm đã được xác nhận.
itemCountlà tổng quantity của đúngcartId. - Revision: là số phiên bản dữ liệu tăng khi backend xác nhận thay đổi trong giỏ đó. Nó giúp nhận ra snapshot cũ; chỉ so revision trong cùng một cart, không so hai giỏ khác nhau.
- Result: có ba trạng thái.
statusgiúp consumer chọn cách xử lý bằngswitch; không phải lần gọi nào cũng trả snapshot.
Dấu | trong AddItemResultV1 nghĩa là kết quả thuộc một trong ba dạng được liệt kê. Readonly yêu cầu code TypeScript không gán lại các field qua type này; nó không tự khóa object lúc chạy.
Các ID trong ví dụ là chuỗi không rỗng. revision và itemCount là số nguyên không âm trong giới hạn safe integer của JavaScript, tức khoảng số nguyên JavaScript biểu diễn chính xác. Đây là các điều kiện phải kiểm tra khi chạy, không chỉ ghi bằng type number.
V1 là version của cam kết
Hậu tố V1 giúp gọi tên phiên bản contract trong code. Nó không tự kiểm tra remote nào đang chạy, cũng không bảo đảm bên kia thực sự hỗ trợ v1. Phần phiên bản bên dưới sẽ giải thích cách kiểm tra việc đó.
Phân biệt bị từ chối và chưa biết kết quả
Giả sử backend báo cốc hết hàng: catalog biết thao tác không được áp dụng. Đó là rejected.
Nhưng nếu request timeout thì khác. Backend có thể đã thêm cốc, chỉ có response không tới browser. Khi đó provider chưa biết kết quả cuối cùng; contract trả unknown.
status | Điều provider biết | Catalog nên làm gì |
|---|---|---|
committed | Backend đã xác nhận thêm hàng; snapshot thuộc cart và phiên hiện tại | Bỏ pending, hiển thị thành công |
rejected | Thao tác không được áp dụng, có code giải thích | Hiển thị lỗi tương ứng, chẳng hạn hết hàng |
unknown | Chưa xác định thao tác đã được áp dụng hay chưa | Báo đang kiểm tra và nhờ cart owner xác định lại kết quả |
API thật còn cần cách kiểm tra một operation đang unknown, chẳng hạn endpoint tra cứu operation nếu backend hỗ trợ. Đọc lại giỏ giúp biết state hiện tại, nhưng không phải lúc nào cũng chứng minh được chính operation nào đã thêm hàng. Interface trên cố ý chỉ mô tả thao tác thêm; nó chưa cung cấp toàn bộ quy trình khôi phục.
Promise vẫn có thể reject do lỗi ngoài ba kết quả này, như adapter hỏng hoặc lỗi lập trình. Consumer cần catch, nhưng không được suy ra mọi exception đều có nghĩa “server chưa thêm”. Nếu chưa xác định được, đừng tự phát thêm một command mới.
Khi đã commit, cart service cập nhật snapshot rồi mới thông báo thay đổi. Một listener của badge bị lỗi không được biến thao tác đã thành công ở backend thành kết quả thêm hàng thất bại.
Retry không được vô tình thêm hàng hai lần
Người dùng bấm thêm một chiếc cốc là một ý định. Nếu gửi lại đúng ý định đó sau timeout, hãy giữ cùng operationId và cùng input. Nếu người dùng thực sự muốn thêm một chiếc nữa, đó là ý định mới và cần ID mới.
Cách xử lý nhiều request của cùng một ý định mà không áp dụng thao tác lặp gọi là idempotency. Nó cần backend hỗ trợ: backend phải ghi nhận ID, kiểm tra input và công bố thời hạn chống trùng. Chỉ thêm field operationId vào frontend không tạo ra khả năng này.
signal?: AbortSignal cho phép yêu cầu hủy chờ hoặc hủy request nếu adapter hỗ trợ. Nó không rút lại thao tác server đã xử lý. Theo quy ước của ví dụ, nếu request đã gửi và việc hủy khiến provider không biết kết quả, provider trả unknown.
Nói ngắn gọn: timeout hoặc abort không phải bằng chứng rằng giỏ chưa đổi. Chỉ retry mutation theo chính sách chống trùng đã được backend hỗ trợ.
Event thông báo gì cho header
Phân biệt dữ liệu và thông tin truy vết
Catalog cần kết quả command. Header lại cần biết cart vừa thay đổi để lấy số mới. Cart có thể phát event sau khi backend xác nhận:
{
"type": "shop.cart.updated.v1",
"eventId": "event-demo-001",
"source": "cart",
"occurredAt": "2026-03-01T08:00:00.000Z",
"correlationId": "operation-demo-001",
"payload": {
"cartId": "cart-demo",
"revision": 42,
"itemCount": 3
}
}Hãy đọc payload trước: giỏ cart-demo, tại revision 42, có tổng quantity bằng 3. Đây là số lượng hiện tại, không phải yêu cầu cộng thêm 3. Event này dùng cùng tên và ý nghĩa với bài Events và message bus.
Các field bên ngoài payload thường gọi là envelope, tức phần mô tả thông báo:
| Field | Dùng để làm gì |
|---|---|
type | Chọn loại event và phiên bản cần xử lý |
eventId | Nhận diện một event; giữ nguyên nếu chuyển tiếp hoặc phát lại chính event đó |
source | Ghi bên khai báo phát thông báo; không phải bằng chứng danh tính |
occurredAt | Hỗ trợ debug thời điểm phát; không thay revision để xếp thứ tự dữ liệu |
correlationId | Nối event với thao tác liên quan, ở đây là operationId |
Không cần đoán ý nghĩa từ tên field. Ghi những quy tắc này cùng schema để team làm header biết chính xác thứ nó đang dùng.
Event không thay thế dữ liệu hiện tại
Contract event trong ví dụ dùng bus trong memory: không lưu lịch sử cho nơi đến muộn, không tự retry và không có ACK — phản hồi xác nhận đã nhận. Đây là lựa chọn của ví dụ, không phải thuộc tính của mọi event bus.
Nếu header mount sau khi event được phát, nó phải đọc snapshot từ cart owner. Nếu đã mount, nó có thể dùng thông báo để cập nhật hoặc yêu cầu đọc lại theo chính sách của owner. Phần command ở trên chưa định nghĩa API đọc/subscription; xem State ownership cho phần đó.
Contract phải ghi cả hành vi chuyển thông báo. Đổi bus sang cách có lưu lịch sử hoặc phát lại event có thể làm consumer gặp event lặp; giữ nguyên JSON chưa đủ để bảo đảm hành vi giữ nguyên.
Kiểm tra dữ liệu trước khi dùng
TypeScript không kiểm tra dữ liệu lúc chạy
Giả sử một remote gửi itemCount: "3" dưới dạng string. Đoạn này không phát hiện lỗi:
const snapshot = JSON.parse(text) as CartSnapshotV1;as CartSnapshotV1 chỉ yêu cầu compiler coi dữ liệu là type đó. Khi code chạy, không có bước kiểm tra nào được thêm vào. Với dữ liệu từ phiên bản khác hoặc message bên ngoài, hãy nhận là unknown rồi kiểm tra trước khi dùng.
Runtime validation là bước kiểm tra dữ liệu lúc chương trình đang chạy. Nó trả lời “object này có đúng field và giá trị được phép không?”, chưa trả lời “người gửi có quyền sửa giỏ không?”. Backend vẫn phải kiểm tra quyền.
Decoder kiểm tra một snapshot
Một decoder kiểm tra input rồi chuyển nó thành dữ liệu consumer có thể dùng. Decoder nhỏ dưới đây kiểm tra riêng snapshot, chưa kiểm tra toàn bộ event envelope:
import type { CartSnapshotV1 } from './cart-commands';
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object'
&& value !== null
&& !Array.isArray(value);
}
function isId(value: unknown): value is string {
return typeof value === 'string'
&& value.length <= 200
&& value.trim().length > 0;
}
function isCount(value: unknown): value is number {
return typeof value === 'number'
&& Number.isSafeInteger(value)
&& value >= 0;
}
export function decodeCartSnapshotV1(value: unknown): CartSnapshotV1 {
if (!isRecord(value)
|| !isId(value.cartId)
|| !isCount(value.revision)
|| !isCount(value.itemCount)) {
throw new TypeError('Invalid cart snapshot v1');
}
// Copy các field public: không giữ reference tới object của publisher.
return Object.freeze({
cartId: value.cartId,
revision: value.revision,
itemCount: value.itemCount,
});
}isId không chấp nhận chuỗi rỗng hoặc toàn khoảng trắng; giới hạn 200 ký tự là policy minh họa cần thống nhất với backend. isCount loại số âm, số thập phân và số ngoài giới hạn safe integer.
Hàm không sửa "3" thành 3, cũng không đổi dữ liệu lỗi thành số 0. Làm vậy sẽ giấu lỗi của bên gửi và có thể hiển thị “giỏ rỗng” khi thật ra dữ liệu không hợp lệ.
Decoder trả một object mới chỉ chứa ba field v1 và khóa object bằng Object.freeze. Nhờ vậy, bên gửi sửa object gốc cũng không làm snapshot đã đọc đổi theo. Vì ba field đều là giá trị đơn giản, cách copy này đủ cho ví dụ; object lồng nhau cần chính sách copy riêng.
Schema kiểm tra ở runtime
Khi nhiều consumer cần kiểm tra cùng một cấu trúc, có thể mô tả nó bằng schema — bộ quy tắc về dữ liệu hợp lệ — rồi dùng validator thực thi. JSON Schema là một định dạng làm việc đó; nó không tự chạy chỉ vì có file .json.
Schema đầy đủ bên dưới kiểm tra cả envelope và payload của event. Bạn có thể đọc ba quy tắc trước rồi mở chi tiết khi cần triển khai:
propertiesmô tả field hợp lệ;requiredmới buộc field phải có.additionalProperties: truecho phép nhận thêm field chưa biết, miễn field v1 vẫn hợp lệ.minimum,maximumvàtype: integerràng buộc số lượng, thay vì chỉ nói chung lànumber.
Schema đầy đủ cho shop.cart.updated.v1
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"title": "Shop cart updated v1",
"type": "object",
"required": [
"type", "eventId", "source", "occurredAt", "correlationId", "payload"
],
"properties": {
"type": { "const": "shop.cart.updated.v1" },
"eventId": { "$ref": "#/$defs/id" },
"source": { "const": "cart" },
"occurredAt": { "type": "string", "format": "date-time" },
"correlationId": { "$ref": "#/$defs/id" },
"payload": {
"type": "object",
"required": ["cartId", "revision", "itemCount"],
"properties": {
"cartId": { "$ref": "#/$defs/id" },
"revision": { "$ref": "#/$defs/count" },
"itemCount": { "$ref": "#/$defs/count" }
},
"additionalProperties": true
}
},
"additionalProperties": true,
"$defs": {
"id": {
"type": "string",
"minLength": 1,
"maxLength": 200,
"pattern": "\\S"
},
"count": {
"type": "integer",
"minimum": 0,
"maximum": 9007199254740991
}
}
}Cho phép field bổ sung giúp một header v1 vẫn đọc được event có thêm lineCount. Decoder v1 chỉ lấy phần nó hiểu. Tuy nhiên, không áp dụng quy tắc bỏ qua field mới một cách máy móc cho command: nếu một tùy chọn mới thay đổi nghiệp vụ, provider cũ bỏ qua nó có thể thực hiện sai ý định. Khi đó hãy kiểm tra khả năng hỗ trợ trước hoặc từ chối rõ ràng.
Với Draft 2020-12 trong ví dụ, validator phải hỗ trợ đúng draft và được cấu hình kiểm tra format: date-time nếu timestamp là điều bắt buộc. Một số cấu hình coi format chỉ là thông tin mô tả. Hãy test timestamp sai bằng đúng validator/cấu hình dùng ở runtime.
Nếu giao tiếp qua iframe, kiểm tra event.origin và event.source trước khi decode; bên gửi dùng targetOrigin chính xác. Field source: "cart" trong JSON không thay được các kiểm tra đó. Function và DOM element cũng không truyền trực tiếp được qua postMessage như context trong phần tiếp theo.
Shell truyền chức năng cart cho catalog thế nào
Đọc mount contract
Đến đây đã có API cart. Còn một câu hỏi: catalog lấy object cart ở đâu?
Trong thiết kế này, shell khởi tạo hoặc tải cart service rồi truyền API cần thiết vào lúc gắn catalog lên trang. API được truyền vào là một capability: chức năng catalog được cấp để dùng. Nó chỉ có addItem, không có quyền sửa mọi state của shell.
Sơ đồ này mô tả cách nối module, không phải thứ tự các request HTTP:
Contract mount có thể độc lập với React hoặc Vue. Shell cần biết nơi gắn UI, cấu hình ban đầu và cách dọn UI, không cần import component nội bộ:
import type { CartCommandsV1 } from './cart-commands';
export type CatalogPropsV1 = Readonly<{
locale: string;
currency: string;
theme: 'light' | 'dark';
}>;
export type CatalogMountContextV1 = Readonly<{
contract: 'shop.catalog.mount.v1';
container: HTMLElement;
props: CatalogPropsV1;
capabilities: Readonly<{
cart: CartCommandsV1;
}>;
}>;
export type CatalogHandleV1 = Readonly<{
updateProps(next: CatalogPropsV1): void;
unmount(): void;
}>;
export interface CatalogRemoteV1 {
mount(context: CatalogMountContextV1): CatalogHandleV1;
}Đọc từ ngoài vào trong:
| Phần | Ý nghĩa |
|---|---|
container | DOM element nơi catalog được phép render |
props | Cấu hình hiển thị: ngôn ngữ, tiền tệ, theme |
capabilities.cart | API cart được truyền vào; catalog gọi addItem qua đây |
mount(...) | Gắn UI và trả handle, tức object dùng điều khiển instance vừa gắn |
updateProps(...) | Đổi cấu hình hiển thị cho instance đang tồn tại |
unmount() | Tháo instance và dọn tài nguyên của nó |
locale và currency có type string, nhưng ứng dụng vẫn phải công bố tập giá trị hỗ trợ hoặc fallback khi không hỗ trợ. currency ở đây chỉ cấu hình hiển thị; nó không cho UI tự đổi tiền tệ nghiệp vụ của cart.
Shell chỉ nối các module. Cart team vẫn chịu trách nhiệm cho ý nghĩa và implementation của addItem.
Quy tắc update và cleanup
Giả sử shell đổi theme từ sáng sang tối. updateProps nên đổi theme, không làm mất số lượng đang chọn của người dùng. Interface không tự bảo đảm điều này, nên contract của ví dụ ghi rõ:
mountchỉ gọi khi container chưa có instance catalog đang hoạt động. Catalog chỉ sở hữu DOM bên trong container đó.updatePropsnhận toàn bộ props mới, không phải một phần cần ghép vào props cũ. Nó cập nhật hiển thị mà không reset draft không liên quan.- API cart giữ ổn định trong một lần mount. Với thiết kế này, khi đổi session hoặc thay API, shell unmount rồi mount context mới.
unmountgọi nhiều lần vẫn an toàn. Listener, timer, subscription và DOM do instance tạo phải được dọn.updatePropssau unmount là lỗi lập trình. Nếumountlỗi giữa chừng, remote dọn phần đã tạo trước khi throw để shell hiển thị fallback.
Đây là những lựa chọn cần test, không phải chuẩn bắt buộc cho mọi remote. Bạn có thể thiết kế update một phần hoặc mount bất đồng bộ, nhưng phải viết rõ hành vi tương ứng.
Cleanup UI cũng không có nghĩa request backend đã bị thu hồi. Đừng để response đến muộn cập nhật instance đã unmount hoặc phiên user mới.
Mở các file contract để đối chiếuĐổi contract mà không làm hỏng phiên bản cũ
Thêm field khác với đổi nghĩa field
Shell mới muốn biết cả tổng quantity lẫn số dòng. Đừng đổi itemCount từ 3 thành 2. Thay vào đó, nếu contract v1 cho phép field bổ sung, provider có thể trả:
{
"cartId": "cart-demo",
"revision": 42,
"itemCount": 3,
"lineCount": 2
}Header cũ tiếp tục đọc itemCount. Header mới dùng lineCount nếu có; nếu provider cũ chưa trả field đó thì ẩn phần hiển thị số dòng. Không tự gán lineCount = itemCount, vì hai con số không cùng nghĩa.
“Tương thích” nghĩa là bên đang dùng vẫn hoạt động theo cam kết cũ. Nó bao gồm ý nghĩa và hành vi, không chỉ việc JSON còn parse được.
Kiểm tra cả hai chiều phiên bản
Để rollout và rollback không làm lỗi trang, cần nghĩ tới cả hai cách ghép:
| Tổ hợp | Câu hỏi cần test với ví dụ trên |
|---|---|
| Header cũ + cart mới | Header cũ có bỏ qua lineCount và vẫn đọc itemCount: 3 không? |
| Header mới + cart cũ | Khi không có lineCount, header mới có ẩn thông tin đó mà vẫn dùng được không? |
Thêm field output có thể an toàn nhờ quy tắc này. Thêm field input bắt buộc lại khác: catalog cũ không gửi field, nên cart mới sẽ từ chối request cũ. Khi đó phải giữ cách gọi cũ hoặc công bố API mới.
Tương tự, thêm một giá trị status mới có thể làm consumer cũ không biết phải render gì. Nếu status v1 được công bố chỉ có ba trạng thái, đừng tự bổ sung trạng thái thứ tư rồi gọi là thay đổi không breaking. Breaking change là thay đổi làm bên dùng theo contract cũ không còn hoạt động đúng.
Field thiếu và field bằng null cũng cần phân biệt khi có nghiệp vụ liên quan. Chẳng hạn, thiếu couponCode có thể nghĩa là giữ nguyên coupon, còn couponCode: null có thể nghĩa là xóa. Type và tài liệu phải diễn đạt đúng lựa chọn đó.
Khi nào cần version mới
Nếu muốn đổi tên hoặc đổi nghĩa field công khai, hãy công bố contract mới. Ví dụ event shop.cart.updated.v2 có thể dùng tên rõ hơn:
{
"cartId": "cart-demo",
"revision": 42,
"unitCount": 3,
"lineCount": 2
}unitCount là tổng quantity; lineCount là số dòng theo cách backend gộp sản phẩm/variant. Không suy ra số dòng chỉ từ tổng quantity. Provider phải có dữ liệu thật để tạo cả hai.
Cần phân biệt ba số phiên bản thường gặp:
| Loại | Ví dụ | Cho biết điều gì |
|---|---|---|
| Version package contract | @shop/contracts@1.4.0 | Bản package chứa type, schema và tài liệu |
| Version API/event | shop.cart.updated.v1 | Thỏa thuận mà consumer phải hiểu khi chạy |
| Version build remote | cart-build-abc123 | Bản implementation đang được tải để debug/rollback |
Với API ổn định từ 1.0.0, SemVer dùng major cho thay đổi không tương thích, minor cho chức năng tương thích và patch cho sửa lỗi tương thích. Tuy nhiên, nâng version package không kiểm soát được remote mà browser tải từ URL. Consumer build với types v1 vẫn có thể gặp implementation mới; runtime cần chọn đúng API được hỗ trợ.
Một cách là provider công bố các capability có ID rõ như shop.cart.add-item.v1. Shell kiểm tra ID cần thiết trước mount. Việc hai bên kiểm tra chức năng/version có thể dùng với nhau gọi là capability negotiation.
Nếu thiếu capability bắt buộc, không mount và hiển thị fallback. Nếu thiếu chức năng tùy chọn, như áp coupon, có thể ẩn tính năng đó. Đừng dùng một cờ chung supportsCart: true: nó không nói provider hỗ trợ cách thêm hàng nào.
Descriptor khai báo capability vẫn cần được kiểm tra cấu trúc và đối chiếu export thật. Khai báo hỗ trợ không tự chứng minh implementation đúng; contract tests mới kiểm tra hành vi đó. Với iframe, phần trao đổi ban đầu này còn cần timeout và kiểm tra origin/source.
Triển khai từng bước và giữ đường rollback
Với thay đổi từ event v1 sang v2, một trình tự dễ kiểm soát là:
- Công bố ý nghĩa v2 và các fixture — dữ liệu mẫu cho test — nhưng giữ nguyên v1.
- Nâng cart để hỗ trợ cả hai version, tạo output đúng cho mỗi version từ cùng dữ liệu backend.
- Nâng header để ưu tiên v2 khi có, vẫn dùng v1 khi thiếu v2. Với v1 thì không hiển thị thông tin số dòng chưa biết.
- Test rollback cart về bản cũ. Header mới vẫn phải dùng được v1, hoặc rollback phải bị giới hạn ở build còn hỗ trợ v2.
- Chỉ bỏ v1 khi các consumer còn được hỗ trợ và các bản trong cửa sổ rollback đều không cần nó nữa.
Nếu cart phát cả event v1 và v2 cho một lần thay đổi, header nên chọn một version làm đường cập nhật chính. Hai thông báo không phải hai lần thêm hàng. Nếu cần nghe cả hai trong giai đoạn chuyển tiếp, phải nhận diện cùng thay đổi theo cart/revision hoặc quy tắc identity đã thống nhất.
Dashboard deploy toàn bản mới chưa chứng minh không còn consumer cũ. Tab browser đang mở hoặc bundle được cache vẫn có thể dùng v1. Quyết định gỡ v1 cần dựa vào danh sách consumer, chính sách cache, tuổi thọ phiên, dữ liệu quan sát và kế hoạch reload an toàn; không có số ngày cố định đúng cho mọi ứng dụng.
Kiểm thử điều đã cam kết
Bắt đầu bằng test decoder: chấp nhận snapshot hợp lệ, bỏ qua field bổ sung được cho phép và từ chối dữ liệu sai. File dưới đây dùng Node assert; chạy sau khi compile TypeScript hoặc qua runner hỗ trợ TypeScript.
import assert from 'node:assert/strict';
import { decodeCartSnapshotV1 } from './decode-cart-snapshot';
const oldFixture = {
cartId: 'cart-demo',
revision: 42,
itemCount: 3,
};
assert.deepEqual(decodeCartSnapshotV1(oldFixture), oldFixture);
// Output mới bổ sung field không phá consumer chỉ cần projection v1.
assert.deepEqual(
decodeCartSnapshotV1({ ...oldFixture, lineCount: 2 }),
oldFixture,
);
for (const invalid of [
{ ...oldFixture, itemCount: '3' },
{ ...oldFixture, itemCount: -1 },
{ ...oldFixture, revision: Number.MAX_SAFE_INTEGER + 1 },
{ ...oldFixture, cartId: ' ' },
{ cartId: 'cart-demo', revision: 42 },
null,
[],
]) {
assert.throws(() => decodeCartSnapshotV1(invalid), TypeError);
}
const raw = { ...oldFixture };
const decoded = decodeCartSnapshotV1(raw);
raw.itemCount = 99;
assert.equal(decoded.itemCount, 3);
assert.equal(Object.isFrozen(decoded), true);Test này chứng minh decoder hoạt động theo các quy tắc đã viết. Nó chưa chứng minh provider tính itemCount đúng. Muốn kiểm tra ý nghĩa, hãy cho provider xử lý giỏ có hai chiếc áo và một chiếc cốc, rồi assert tổng quantity là 3, không phải 2.
Những hành vi sau cần test riêng với implementation provider và adapter thật:
| Điều cam kết | Tình huống test |
|---|---|
| Không coi timeout là chắc chắn thất bại | Backend commit nhưng response mất; consumer đi vào nhánh chưa biết và không tự tạo operation mới |
| Chống trùng theo chính sách backend | Retry cùng operation/input không thêm lần hai trong phạm vi đã hứa |
| Mount/update/unmount rõ ràng | Đổi theme giữ draft; unmount lặp không để listener cũ sống |
| Snapshot cho nơi đến muộn | Header mount sau lần cập nhật vẫn đọc được số 3 |
| Phiên bản dùng được với nhau | Consumer cũ/provider mới; consumer mới/provider cũ; hai bản mới |
| Version không hỗ trợ bị từ chối | Thiếu capability bắt buộc thì không mount/mutate nhầm |
Với event schema, thêm test sai type, thiếu correlationId, timestamp lỗi và field bổ sung, dùng đúng cấu hình validator production. Chỉ dùng fixture đúng không thể phát hiện validator chưa bật kiểm tra timestamp.
Test mong đợi do consumer cung cấp thường gọi là consumer-driven contract test. Provider chạy các test đó để biết mình còn đáp ứng cách consumer thật sử dụng API không. Nếu mock luôn trả fixture đúng mà không chạy provider, test chỉ chứng minh mock khớp kỳ vọng, không chứng minh hai bên tích hợp được.
Đóng gói và xử lý khi contract không khớp
Type, schema, fixture và tài liệu hành vi có thể nằm trong package @shop/contracts. Không cần đưa Redux store, React Context hoặc cart service đang giữ state vào package đó. Package contract chia sẻ thỏa thuận, không mặc nhiên tạo một instance dùng chung.
Mình ưu tiên một nguồn định nghĩa schema/type có thể sinh lẫn nhau khi toolchain cho phép. Nếu viết riêng cả hai, cần test để chúng không trôi khỏi nhau. Consumer có thể nâng package khi cần, còn provider vẫn giữ API cũ trong thời hạn đã cam kết.
Khi dữ liệu không khớp, cách xử lý phụ thuộc nơi phát hiện:
| Chỗ phát hiện | Xử lý mặc định |
|---|---|
| Thiếu capability bắt buộc lúc mount | Không mount; shell hiển thị fallback có thể hiểu được |
| Event không hợp lệ | Bỏ event, ghi nhận lỗi và đọc lại snapshot nếu cần; không đổi badge về 0 |
| Command input sai trước khi gửi | Từ chối, không mutate; backend vẫn validate/authorize độc lập |
| Đã gửi command nhưng không biết kết quả | Đưa về quy trình xác định lại, không retry mù bằng operation mới |
Ghi đủ metadata để debug: build shell/cart, contract ID, loại lỗi validation và operation/event ID phù hợp. Không log token hoặc nguyên payload theo mặc định. Metadata giúp tìm đúng hai phiên bản đang lệch, không biến frontend validation thành kiểm tra bảo mật.
Bài tập tự kiểm tra
Thử viết contract cho badge trong ứng dụng của bạn bằng năm câu:
- Con số đếm tổng quantity hay số dòng?
- Ai cung cấp số đó và ai chỉ được đọc?
- Lúc chưa tải hoặc dữ liệu lỗi, UI hiển thị gì?
- Header mount muộn lấy số hiện tại bằng cách nào?
- Nếu ngày mai đổi cách đếm, header cũ còn hoạt động ra sao?
Sau đó thử thay implementation cart mà không sửa catalog/header. Nếu bên dùng vẫn phải import store hoặc query key nội bộ để hoạt động, hãy thu nhỏ API công khai trước khi thêm cơ chế giao tiếp mới.
Đọc tiếp
Events và message bus
Cài đặt đường thông báo, kiểm tra payload và cleanup listener.
State ownership
Thiết kế API đọc hiện tại và quyền thay đổi state.
Contract tests
Kiểm tra provider và consumer qua nhiều phiên bản.
Tài liệu tham khảo
- TypeScript — Type Assertions: type assertion không tạo kiểm tra runtime.
- JSON Schema — Object:
properties,required,additionalPropertiesvà sự khác nhau giữa field thiếu vớinull. - JSON Schema — Format: cấu hình kiểm tra format.
- Semantic Versioning 2.0.0: version dựa trên tính tương thích của public API.
- MDN — Window.postMessage: message giữa context và kiểm tra origin/source.