Bỏ qua để đến nội dung
Knowledge Hub

Adapter pattern: tích hợp cổng thanh toán cũ mà không kéo API của nó vào checkout Cơ bản

Thời gian đọc ước tính: 7 phút

Checkout muốn gọi charge({ orderId, amountMinor }) và nhận paymentId cùng trạng thái. Nhưng thư viện thanh toán cũ nhận pay(invoice, amountInCents) và trả { transaction, ok }.

Nếu checkout gọi thẳng thư viện, tên invoice, cờ ok và mọi thay đổi riêng của nhà cung cấp len vào logic xử lý đơn. Đổi gateway nghĩa là sửa cả luồng checkout. Adapter đặt một lớp dịch nhỏ đúng ở ranh giới đó.

Before: gọi thẳng SDK After: port + adapter
Checkout import LegacyGateway PaymentPort (thuộc phía ứng dụng)
Tên dùng trong use case invoice, transaction, ok orderId, paymentId, status
Đổi gateway Sửa checkout Viết adapter mới, checkout giữ nguyên
Test checkout Phải mock SDK Dùng fake PaymentPort
Nơi dịch lỗi và đơn vị tiền Rải rác khắp use case Một chỗ, có thể review

Đang tải diagram…

Adapter có ba vai:

  • Client: checkout.
  • Target: interface client mong muốn, ở đây PaymentPort.charge.
  • Adaptee: API đã có nhưng không khớp, ở đây LegacyGateway.pay.

Adapter implement PaymentPort, giữ một LegacyGateway, gọi nó rồi đổi kết quả về kiểu checkout hiểu. Đây là object adapter (composition, không kế thừa).

Điểm đáng nhớ là chiều phụ thuộc: checkout phụ thuộc PaymentPort thuộc phía ứng dụng, còn gateway cụ thể chỉ xuất hiện trong adapter. PaymentPort phải mô tả hành động ứng dụng cần, không chép chữ ký của SDK; nếu không, ngôn ngữ của gateway vẫn thống trị code bên trong.

  • Hexagonal (ports and adapters): PaymentPort là một port (driven/outbound), LegacyGatewayAdapter là adapter của nó. Adapter pattern là cơ chế cấp class của ý tưởng kiến trúc này.
  • Anti-corruption layer (ACL): khi phía bên kia có model khác hẳn (legacy, hệ thống bên ngoài), adapter đóng vai ACL: giữ model nội bộ sạch. Theo Microsoft Learn, ACL chỉ nên làm phần dịch, không chứa business rule hay orchestration. Xem thêm context map.
  1. Checkout kiểm tra amountMinor là số nguyên dương an toàn, rồi gọi PaymentPort.charge.
  2. Adapter gọi legacy.pay(input.orderId, input.amountMinor), nhận { transaction, ok }, tạo ChargeResult.
  3. Checkout chỉ đọc paymentId và status theo contract nội bộ.

Adapter được tạo ở composition root (bootstrap hoặc module wiring), không phải trong hàm checkout. Nhờ đó test thay được bằng fake và thêm provider không phải mở use case.

Adapter dịch được tên field, kiểu, trạng thái, lỗi, nhưng phải giữ ngữ nghĩa. ok: true có thể chỉ là “đã nhận yêu cầu”, không phải “tiền đã thu”.

Đọc tài liệu provider để biết:

  • Đơn vị tiền nhỏ nhất.
  • Xử lý đồng bộ hay bất đồng bộ.
  • Mã lỗi.
  • Idempotency.
  • Lúc nào giao dịch là cuối cùng.

Nếu provider chỉ trả token để tra sau, ChargeResult phải có trạng thái pending, không ép vào accepted | declined.

Không bắt mọi exception rồi trả declined. Adapter là nơi phân loại tín hiệu từ provider thành các nhóm checkout xử lý khác nhau:

Tín hiệu từ provider Phân loại Checkout nên làm Retry?
Thẻ bị từ chối, không đủ tiền Declined (business) Báo người dùng, không retry Không
Request sai định dạng, auth sai Permanent error (lỗi của ta) Alert, sửa code/config Không
Provider báo lỗi tạm thời khi chưa xử lý (ví dụ 503 theo contract) Retryable Retry có backoff và jitter Có, với idempotency key
Timeout, mất kết nối sau khi gửi Unknown outcome Đặt trạng thái pending, tra cứu/reconcile trước khi làm gì tiếp Chỉ với idempotency key

Cột “Phân loại” phải theo contract thật của từng provider; bảng chỉ là khung. Hành vi retry đúng cần backoff và jitter, deadline tổng và idempotency key. Adapter chỉ phân loại; workflow thanh toán lớn hơn mới quyết định pending, reconciliation và retry.

Kết quả port nên trung thực với các trạng thái có thể xảy ra, ví dụ (minh họa, không nằm trong file demo):

type ChargeResult =
| { status: 'accepted'; paymentId: string }
| { status: 'declined'; reason: string }
| { status: 'pending'; paymentId?: string }; // unknown outcome: cần reconcile

Nếu hai provider khác đơn vị tiền, adapter phải đổi có quy tắc và test rõ, không âm thầm nhân chia 100. Currency là thuộc tính nghiệp vụ nên nằm trong port, không hard-code trong adapter.

Terminal window
npx --prefix examples ts-node examples/src/adapter-pattern/payment-adapter-demo.ts

Output thật:

{ paymentId: 'tx-order-42-12500', status: 'accepted' }

LegacyGateway trong demo là class giả lập, không gọi mạng. Đổi transaction thành transactionId ở gateway: TypeScript chỉ yêu cầu sửa adapter. Đó là kiểm chứng nhỏ về vị trí của coupling.

Production cần ba lớp test với mục đích khác nhau:

  1. Unit test adapter với fake gateway: mapping field và phân loại lỗi.
  2. Contract/integration test với sandbox của provider: phát hiện API thật khác giả định.
  3. Test use case checkout với fake PaymentPort: xử lý accepted, declined, pending.
Adapter Strategy ACL
Giải quyết Interface không khớp Chọn giữa nhiều thuật toán cùng contract Bảo vệ model khỏi hệ thống ngoài
Cấp độ Pattern ở cấp class Pattern ở cấp class Cấp kiến trúc, thường hiện thực bằng adapter/facade
Có thể dùng chung Mỗi gateway một adapter Chọn gateway theo merchant Adapter là một cách làm ACL
  • Dùng khi có khác biệt cụ thể giữa interface ứng dụng muốn và interface bên ngoài, và chi phí đổi là thật (SDK có thể đổi, nhiều provider).
  • Không dùng cho helper nội bộ ổn định chỉ vì hai hàm khác tên, hoặc khi chỉ có một gateway và contract đã khớp.

Giá trị

  • Tên gọi và trạng thái riêng của provider ở một chỗ, không lan vào checkout.
  • Dễ thay SDK/provider và test use case bằng fake port.
  • Mapping là điểm review rõ cho tiền, lỗi và trạng thái.

Chi phí

  • Thêm lớp và contract cần duy trì; nhiều adapter mỏng cho code nội bộ gây nhiễu.
  • Contract chung có thể che khác biệt quan trọng giữa provider nếu quá đơn giản.
  • Không tự giải quyết retry, idempotency, timeout hay giao dịch bất định.
  1. Port dùng ngôn ngữ của ứng dụng (orderId, paymentId, status), không chép chữ ký SDK.
  2. Use case chỉ import port; SDK chỉ xuất hiện trong adapter, và adapter được tạo ở composition root.
  3. Mapping giữ ngữ nghĩa: trạng thái chưa chắc chắn có pending riêng, không ép vào accepted | declined.
  4. Lỗi được phân loại thành declined, permanent, retryable, unknown outcome theo contract thật của provider; không bắt mọi exception rồi trả declined.
  5. Đổi đơn vị tiền có quy tắc và test rõ; currency nằm trong port, không hard-code trong adapter.
  6. Có đủ ba lớp test: unit test adapter với fake gateway, contract test với sandbox, test use case với fake port.

Tự kiểm tra:

  1. SDK đổi tên transaction thì file nào cần sửa?
  2. Vì sao timeout không thể luôn map thành declined?
  3. Khi nào một adapter mỏng chỉ làm code khó đọc hơn?