Một public API thường sống lâu hơn code triển khai đầu tiên. Ứng dụng mobile có thể nằm trên thiết bị nhiều năm, đối tác deploy theo lịch riêng, proxy tự retry request, còn timeout có thể che mất một write đã thành công. Sửa handler thì dễ; bắt mọi caller thay đổi cùng một thời điểm gần như không thể.

Thiết kế API tốt bắt đầu từ hai lời hứa: gửi lại request phải có hiệu ứng dự đoán được, còn representation mới không được âm thầm làm hỏng consumer cũ. Idempotency điều khiển ý định bị lặp; versioning điều khiển thay đổi theo thời gian. Cả hai gặp nhau tại HTTP contract: method, validator, status code, header, schema và hành vi lỗi. Mục tiêu không phải exactly-once execution trên network, mà là public interface an toàn khi retry và tiến hóa từng bước.

Bắt đầu từ semantics của HTTP method

HTTP đã cung cấp vocabulary để diễn đạt ý định. GET, HEAD, PUT, DELETE, OPTIONSTRACE được định nghĩa là idempotent: gửi cùng request nhiều lần phải có intended effect giống như gửi một lần. POSTPATCH không mặc nhiên idempotent, dù API có thể bổ sung điều kiện để một operation cụ thể retry an toàn.

Safety và idempotency là hai khái niệm khác nhau. Safe method chỉ đọc theo góc nhìn của caller. Idempotent method có thể đổi state ở request đầu tiên; các lần lặp không tạo thêm intended change. Ghi log cho lần DELETE thứ hai, cập nhật metric hoặc refresh cache không phá vỡ lời hứa này vì trạng thái resource được yêu cầu vẫn hội tụ.

Method Contract thông thường Retry sau khi mất response Lỗi thiết kế phổ biến
GET /orders/42 Đọc representation An toàn, theo quy tắc cache/auth bình thường Kích hoạt business side effect từ query
PUT /profiles/42 Thay thế state tại URI client đã biết Tự nhiên idempotent với cùng representation Xử lý field bị bỏ qua không nhất quán
DELETE /sessions/42 Làm resource không còn tồn tại Tự nhiên idempotent; lần sau có thể trả 204 hoặc 404 Báo thất bại chỉ vì resource đã vắng mặt
POST /orders Tạo hoặc xử lý một ý định con Không an toàn nếu thiếu operation identity Tạo thêm order sau mỗi lần retry
PATCH /orders/42 Áp dụng thay đổi một phần Tùy patch format và precondition Retry incrementBy: 1 mà không có validator

Chọn method theo resource semantics. Khi client đặt tên resource và gửi full desired state, PUT /imports/{clientImportId} thường đơn giản hơn POST /imports; khi server cấp identity, dùng POST kèm idempotency contract. JSON Merge Patch đặt field thành desired value nên thường idempotent. Trong JSON Patch, replace idempotent, nhưng add vào array hoặc command increment có thể không.

Note

Idempotency nói về intended server state, không yêu cầu response byte giống hệt nhau. Lần DELETE đầu có thể trả 204, lần retry trả 404; contract phải nói client có nên xem cả hai là hội tụ thành công hay không.

Cấp retry contract cho operation không idempotent

Với creation và command, hãy nhận Idempotency-Key được tạo đúng một lần cho mỗi user intent và tái sử dụng qua mọi network attempt. Scope key theo authenticated principal và operation, bind nó với request đã validate, rồi giữ completed result trong khoảng thời gian đã công bố. Dùng lại key với payload khác là conflict, không phải command mới.

Handler nên thể hiện protocol behavior rõ ràng ngay cả khi storage được giao cho repository. Ví dụ sau validate boundary, tạo scope ổn định và ánh xạ outcome của repository sang HTTP response:

ts
type CreateOrder = Readonly<{
  customerId: string;
  currency: 'USD' | 'EUR' | 'VND';
  lines: ReadonlyArray<{ sku: string; quantity: number }>;
}>;

type HttpResponse = Readonly<{
  status: number;
  headers?: Readonly<Record<string, string>>;
  body?: unknown;
}>;

const createOrderSchema = {
  type: 'object',
  additionalProperties: false,
  required: ['customerId', 'currency', 'lines'],
  properties: {
    customerId: { type: 'string', minLength: 1 },
    currency: { enum: ['USD', 'EUR', 'VND'] },
    lines: {
      type: 'array',
      minItems: 1,
      items: {
        type: 'object',
        additionalProperties: false,
        required: ['sku', 'quantity'],
        properties: {
          sku: { type: 'string', minLength: 1 },
          quantity: { type: 'integer', minimum: 1 },
        },
      },
    },
  },
} as const;

export async function postOrder(request: RequestContext): Promise<HttpResponse> {
  const key = request.header('idempotency-key');
  if (!key || key.length > 128) {
    return problem(400, 'IDEMPOTENCY_KEY_REQUIRED', 'Supply a key up to 128 characters.');
  }

  const input = request.validate<CreateOrder>(createOrderSchema);
  const result = await request.services.operations.execute({
    scope: `${request.principal.accountId}:POST:/orders`,
    key,
    input,
    run: (transaction) => transaction.orders.create(input),
  });

  switch (result.kind) {
    case 'created':
    case 'replayed':
      return {
        status: 201,
        headers: {
          location: `/orders/${result.order.id}`,
          'idempotency-status': result.kind,
        },
        body: toOrderV2(result.order),
      };
    case 'payload-mismatch':
      return problem(409, 'IDEMPOTENCY_KEY_REUSED', 'The key belongs to another payload.');
    case 'in-progress':
      return {
        ...problem(409, 'OPERATION_IN_PROGRESS', 'The original request is still running.'),
        headers: { 'retry-after': '2' },
      };
  }
}

Validate trước canonical hashing để input tương đương có cùng identity; định nghĩa rõ field vắng mặt có tương đương explicit default không. Loại tracing ID khỏi fingerprint, nhưng luôn chạy authorization để replay không bypass quyền hiện tại. Khi replay, giữ status, selected header và representation ban đầu. Với operation dài, ưu tiên 202 Accepted kèm /operations/{id}.

Warning

Timeout nghĩa là “chưa biết outcome”, không phải “operation thất bại”. Client chỉ nên retry với cùng key; server không nên khuyên tạo key mới chỉ vì response trước bị mất.

Ngăn lost update bằng ETag và precondition

Idempotency key bảo vệ ý định lặp, nhưng không giải quyết hai writer khác nhau cùng sửa một resource. Hãy dùng optimistic concurrency: trả ETag cùng representation, yêu cầu If-Match khi mutate và reject validator cũ bằng 412 Precondition Failed.

Strong ETag có thể sinh từ row version bất biến. Không cần hash toàn bộ JSON response, và không nên chứa chi tiết format khác nhau giữa các encoder.

ts
function orderEtag(version: bigint): string {
  return `"order-${version.toString()}"`;
}

export async function getOrder(request: RequestContext): Promise<HttpResponse> {
  const order = await request.services.orders.find(request.params.orderId);
  if (!order) return problem(404, 'ORDER_NOT_FOUND', 'Order does not exist.');

  return {
    status: 200,
    headers: { etag: orderEtag(order.version) },
    body: toOrderV2(order),
  };
}

export async function patchOrder(request: RequestContext): Promise<HttpResponse> {
  const ifMatch = request.header('if-match');
  if (!ifMatch) {
    return problem(428, 'PRECONDITION_REQUIRED', 'Send the ETag in If-Match.');
  }

  const patch = request.validate<{
    shippingAddress?: AddressInput;
    note?: string | null;
  }>(updateOrderSchema);

  const currentVersion = parseOrderEtag(ifMatch);
  const updated = await request.services.orders.updateIfVersion(
    request.params.orderId,
    currentVersion,
    patch,
  );

  if (updated.kind === 'missing') {
    return problem(404, 'ORDER_NOT_FOUND', 'Order does not exist.');
  }
  if (updated.kind === 'stale') {
    return problem(412, 'STALE_ORDER_VERSION', 'Reload the order and reconcile your change.');
  }

  return {
    status: 200,
    headers: { etag: orderEtag(updated.order.version) },
    body: toOrderV2(updated.order),
  };
}

Repository phải làm comparison và update atomically, chẳng hạn UPDATE orders SET ..., version = version + 1 WHERE id = ? AND version = ?. Đọc version rồi update bằng hai statement rời không được bảo vệ sẽ đưa lost-update race trở lại.

Dùng If-None-Match: * với PUT khi client muốn create-only tại URI đã biết. Dùng If-Match: * khi mutation chỉ được tiếp tục nếu resource tồn tại. Hãy dành 409 Conflict cho domain conflict hoặc dùng sai idempotency key; HTTP validator thất bại có response chính xác hơn là 412.

Tiến hóa schema theo hướng additive trước khi thêm version

Versioning không thay thế compatibility discipline. Phần lớn thay đổi API nên diễn ra trong cùng version: thêm optional response field, nhận cả input cũ và mới trong giai đoạn migration, giữ nguyên ý nghĩa field và yêu cầu consumer bỏ qua unknown field. Xóa field, đổi type, diễn giải lại giá trị, đổi đơn vị hoặc biến optional input thành required đều là breaking change.

Thay đổi Thường tương thích? Cách an toàn hơn
Thêm optional response field Có, với tolerant client Contract-test SDK đại diện và strict decoder
Thêm optional request field Định nghĩa default và hành vi server khi field vắng mặt
Thêm enum member Có rủi ro Xem enum là open ở client hoặc thêm nhánh unknown
Đổi total thành totalMinor Không Phục vụ cả hai, migrate consumer, rồi xóa ở version mới
Đổi giây thành mili giây Không Thêm field có đơn vị rõ trong tên
Siết validation Thường không Đo traffic hiện tại và enforce theo từng giai đoạn
Đổi thứ tự array Có thể không Công bố ordering ổn định hoặc nói rõ không đảm bảo

Hãy giữ một canonical domain model và đặt version adapter ở boundary. Đừng fork toàn bộ business service cho mỗi public representation.

ts
type OrderV1 = Readonly<{
  id: string;
  total: number;
  status: 'open' | 'paid';
}>;

type OrderV2 = Readonly<{
  id: string;
  amount: { currency: string; minor: number };
  status: 'pending' | 'paid' | 'cancelled';
  links: { self: string };
}>;

export function toOrderV1(order: Order): OrderV1 {
  return {
    id: order.id,
    total: order.totalMinor / 100,
    status: order.status === 'paid' ? 'paid' : 'open',
  };
}

export function toOrderV2(order: Order): OrderV2 {
  return {
    id: order.id,
    amount: { currency: order.currency, minor: order.totalMinor },
    status: order.status,
    links: { self: `/orders/${order.id}` },
  };
}

Storage change cũng cần expand-migrate-contract: thêm column, deploy reader tương thích và dual-write, backfill theo batch, chuyển read, kiểm tra parity, rồi mới xóa column cũ.

sql
ALTER TABLE orders ADD COLUMN total_minor BIGINT;

UPDATE orders
SET total_minor = ROUND(total_amount * 100)
WHERE id > :after_id
  AND id <= :batch_end
  AND total_minor IS NULL;

ALTER TABLE orders ALTER COLUMN total_minor SET NOT NULL;
-- Chỉ drop total_amount sau khi mọi reader đã dùng total_minor.

Chọn một chiến lược version negotiation rõ ràng

Chỉ thêm major version khi một contract hữu ích không thể giữ tương thích. URI, header và media-type versioning đều có thể hoạt động; tính nhất quán và khả năng quan sát khi vận hành quan trọng hơn tranh luận về phong cách.

Chiến lược Ví dụ Điểm mạnh Chi phí
URI /v2/orders/42 Rõ trong log, docs, link và cache Resource identity có vẻ thay đổi; số route tăng
Custom header Api-Version: 2025-08-04 URI ổn định và negotiation tường minh Khó thử bằng browser; cache cần Vary
Media type Accept: application/vnd.acme.order.v2+json Representation semantics chính xác Tooling và docs dài dòng hơn
Date/revision Api-Version: 2025-08-04 Hỗ trợ snapshot platform theo lịch Cần change ledger và test matrix chặt chẽ

Version-selection middleware phải reject giá trị không hỗ trợ thay vì âm thầm fallback. Fallback có thể parse request theo sai schema và tạo ra state hợp lệ nhưng trái ý định.

ts
type ApiVersion = '1' | '2';

export function selectApiVersion(request: RequestContext): ApiVersion {
  const fromPath = request.params.apiVersion;
  const fromHeader = request.header('api-version');

  if (fromPath && fromHeader && fromPath !== fromHeader) {
    throw new HttpProblem(400, 'VERSION_CONFLICT', 'Path and header versions differ.');
  }

  const selected = fromHeader ?? fromPath ?? '2';
  if (selected !== '1' && selected !== '2') {
    throw new HttpProblem(406, 'VERSION_UNSUPPORTED', `Version ${selected} is unavailable.`);
  }

  return selected;
}

export async function orderHandler(request: RequestContext): Promise<HttpResponse> {
  const version = selectApiVersion(request);
  const order = await request.services.orders.require(request.params.orderId);
  return {
    status: 200,
    headers: { vary: 'Api-Version' },
    body: version === '1' ? toOrderV1(order) : toOrderV2(order),
  };
}

Version request và response schema cùng nhau. Idempotency record nên nhớ negotiated version; khi caller đổi major version, yêu cầu key mới hoặc công bố rõ replay dùng representation đã lưu hay render lại state hiện tại.

flowchart TD C[Client gửi request] --> N[Negotiation API version] N -->|không hỗ trợ| E[406 version problem] N --> S[Validate schema của version] S -->|không hợp lệ| B[400 validation problem] S --> I{Mutation có retry identity?} I -->|replay| R[Trả status và versioned body đã lưu] I -->|mới| P{Có yêu cầu If-Match?} P -->|đã cũ| F[412 precondition failed] P -->|hợp lệ| D[Chạy canonical domain operation] D --> A[Adapt kết quả sang representation đã chọn] A --> O[Trả ETag, version và error contract ổn định]

Deprecate bằng dữ liệu và kiểm thử toàn bộ contract

Deprecation là chương trình migration: công bố replacement và ngày hỗ trợ cuối, emit header Deprecation/Sunset, link migration guide, nhận diện consumer theo authenticated client identity và đo traffic trước shutdown. Behavior phải deterministic; v1 không được lúc trả body v1, lúc trả v2 theo account flag. Shadow traffic phải chặn duplicate side effect. Telemetry nên ghi version, route, status, error code, retry/replay, ETag failure và client identity, nhưng không log secret hay toàn bộ idempotency key.

Contract test phải pin nhiều hơn happy-path JSON. Hãy kiểm tra required/optional field, unknown-field policy, content type, status code, Location, ETag, idempotent replay, payload mismatch, stale update và error. Trước deployment, chạy ví dụ consumer cũ với server mới.

ts
describe.each(['1', '2'] as const)('orders API v%s', (version) => {
  it('replays a timed-out create without creating a second order', async () => {
    const request = {
      method: 'POST',
      path: '/orders',
      headers: { 'api-version': version, 'idempotency-key': 'checkout-7f4' },
      body: validOrderInput,
    };

    const first = await api.inject(request);
    const retry = await api.inject(request);

    expect(first.statusCode).toBe(201);
    expect(retry.statusCode).toBe(201);
    expect(retry.json()).toEqual(first.json());
    expect(await orders.count()).toBe(1);
  });

  it('rejects a stale conditional update', async () => {
    const fetched = await api.inject({
      method: 'GET',
      path: '/orders/ord_42',
      headers: { 'api-version': version },
    });

    await changeOrderDirectly('ord_42');
    const response = await api.inject({
      method: 'PATCH',
      path: '/orders/ord_42',
      headers: { 'api-version': version, 'if-match': fetched.headers.etag! },
      body: { note: 'Leave at reception' },
    });

    expect(response.statusCode).toBe(412);
    expect(response.json()).toMatchObject({ code: 'STALE_ORDER_VERSION' });
  });
});

Hãy dùng problem shape ổn định như { code, message, details?, traceId }. Client phải branch theo code, không parse câu chữ. Phân loại retry rõ ràng: 400, 401, 403, 404, 409 do payload mismatch và 412 thường terminal cho đến khi request thay đổi; 408, 429, 502, 503, 504 có thể retry với exponential backoff có giới hạn, jitter, cùng idempotency key và tôn trọng Retry-After. 500 chung chung là ambiguous với mutation, nên cần retry cùng key hoặc query status, không được mù quáng tạo lại operation.

Takeaways

  • Model resource và chọn HTTP method theo semantics. PUTDELETE tự nhiên hội tụ; POST cùng một số PATCH cần retry contract tường minh.
  • Xem idempotency key là public operation identity: scope key, bind vào input đã validate, giữ response gốc và tái sử dụng sau ambiguous failure.
  • Dùng ETag và If-Match cho các writer cạnh tranh. Trả 428 khi bắt buộc precondition và 412 khi validator đã cũ.
  • Ưu tiên additive schema evolution cùng tolerant consumer. Đặt public-version adapter quanh canonical domain model và migrate storage theo expand-migrate-contract.
  • Chọn URI, header hoặc media type có chủ đích, reject negotiation không hỗ trợ, đồng thời đưa selected version vào cache, observability và replay rule.
  • Deprecate bằng date, header, migration guide, usage data theo client và contract test. Chỉ xóa version khi consumer thật đã chuyển đi.
  • Biến retry/error behavior thành một phần API contract. Error code ổn định, Retry-After, bounded backoff và outcome lookup giúp failure trở thành protocol thay vì phỏng đoán.