adapter pattern
Tung Bui Thanh (TungDaDev) (updated 6 months ago)11 mins read

Trong cuốn Design Patterns kinh điển của Gang of Four (GoF), Adapter Pattern (còn gọi là Wrapper) được định nghĩa khá ngắn: "Chuyển đổi giao diện (interface) của một lớp thành một giao diện khác mà client mong đợi, cho phép các lớp vốn không tương thích có thể làm việc cùng nhau".
Ở mức nhập môn, Adapter hay được minh họa bằng củ sạc chuyển từ cổng Type-C sang Lightning, hoặc ổ cắm 3 chấu sang 2 chấu.
Nhưng trong phần mềm doanh nghiệp quy mô lớn, Adapter không dừng ở chuyện bọc code cho khớp. Nó là nền của:
- Kiến trúc Lục Giác (Hexagonal Architecture / Ports and Adapters) do Alistair Cockburn đề xuất.
- Anti-Corruption Layer (ACL, lớp chống "nhiễm") trong Domain-Driven Design (DDD) của Eric Evans, làm lớp chắn khi tích hợp hệ thống legacy (Core Banking, SAP ERP, SOAP/XML) với microservice mới.
Mình sẽ đi từ bản chất hướng đối tượng của Adapter lên tới cách dùng nó ở tầng kiến trúc trong hệ thống phân tán.
# class Adapter vs object Adapter: vì sao nên chọn "composition over inheritance"
Trong Java, GoF mô tả hai cách làm Adapter:
# class Adapter (sử dụng kế thừa extends)
- GoF mô tả Class Adapter bằng đa kế thừa (ví dụ trong sách viết bằng C++). Java không cho đa kế thừa class, nên Class Adapter trong Java chỉ kế thừa được đúng một class cụ thể (
Adaptee) và implement interface mục tiêu (Target). NếuTargetcũng là class chứ không phải interface thì cách này bó tay. - Nhược điểm lớn: gắn chặt vào
Adapteengay lúc compile. Muốn adapt cả các lớp con củaAdapteethì mỗi lớp con phải có một adapter riêng. Tính đóng gói cũng bị phá vì Adapter thừa kế và phơi ra toàn bộ methodpubliccủa lớp cha, client gọi vòng qua interface mục tiêu được.
# object Adapter (sử dụng ủy quyền composition)
- Adapter giữ một reference tới instance của
Adaptee, thường được inject qua constructor. - Ưu điểm:
- Đúng nguyên lý "Favor object composition over class inheritance".
- Bọc được bất kỳ implementation nào của
Adapteehoặc subclass của nó. - Dễ viết Unit Test vì mock
Adapteelà xong. - Trong Java hiện đại, gần như lúc nào cũng dùng Object Adapter. Đổi lại là thêm một lớp gọi gián tiếp, và nếu cần override hành vi bên trong
Adapteethì phải tạo subclass rồi bọc subclass đó.
# kiến trúc lục giác: Ports & Adapters
Năm 2005, Alistair Cockburn công bố bài viết về Hexagonal Architecture, còn gọi là Ports and Adapters. Hình lục giác chẳng có ý nghĩa gì đặc biệt, chỉ là đủ cạnh để vẽ nhiều cổng ra vào.
Ý tưởng chính: code nghiệp vụ (core domain) nằm ở trung tâm và không phụ thuộc vào thế giới bên ngoài (database, HTTP, message broker, CLI). Chiều phụ thuộc luôn hướng vào trong: adapter biết domain, domain không biết adapter. Nhờ vậy domain test được mà không cần dựng database hay web server.
# các khái niệm chính
- Port (cổng giao tiếp): là interface do core domain sở hữu, mô tả những gì domain cung cấp hoặc cần:
- Inbound Port: "Đây là các Use Case tôi cung cấp cho thế giới bên ngoài gọi vào" (e.g.
PlaceOrderUseCase). - Outbound Port: "Đây là dịch vụ tôi cần thế giới bên ngoài cung cấp cho tôi" (e.g.
PaymentGatewayPort,OrderRepositoryPort).
- Inbound Port: "Đây là các Use Case tôi cung cấp cho thế giới bên ngoài gọi vào" (e.g.
- Adapter (bộ chuyển đổi): nằm ở tầng hạ tầng (infrastructure), bọc công nghệ cụ thể và chuyển nó về đúng hình dạng của Port:
- Inbound Adapter: nhận HTTP request JSON từ Spring Web, map sang command object của domain rồi gọi vào Inbound Port.
- Outbound Adapter: implement Outbound Port, bọc thư viện bên thứ ba (như Stripe SDK hoặc Hibernate/JPA) để đổi request của domain thành API call của vendor.
# anti-corruption layer (ACL): tấm khiên giữ domain sạch
Khi dựng microservice để thay dần một hệ thống Core Banking hoặc ERP cũ chạy trên mainframe, cái khó nhất thường không phải giao thức mạng mà là lệch pha mô hình dữ liệu (domain model mismatch):
- Hệ thống cũ nói chuyện bằng message ISO 8583, bản ghi độ dài cố định, hoặc XML SOAP với hàng trăm trường viết tắt khó hiểu (
CUST_STAT_CD_01). - Nếu để entity của hệ thống mới nhận thẳng các field này, code sạch của bạn sẽ bị "nhiễm" (corrupted) cách thiết kế của 20 năm trước.
ACL thường không chỉ là một adapter đơn lẻ: nó gộp adapter (đổi giao thức, kiểu gọi), translator (đổi mô hình dữ liệu và ngôn ngữ nghiệp vụ) và đôi khi facade (giấu bớt độ phức tạp của hệ cũ).
# ví dụ: multi-gateway payment ACL
Thử lấy ví dụ một hệ thống thanh toán, rút gọn từ kiểu code hay gặp trong production:
- Domain chỉ biết một interface sạch:
PaymentGatewayPort. - Hai adapter độc lập: một bọc Stripe Java SDK, một bọc hệ thống ngân hàng cũ nói chuyện qua socket.
# outbound Port của domain
package com.company.billing.domain.port.out;
import java.math.BigDecimal;
import java.util.Currency;
public interface PaymentGatewayPort {
PaymentResult executePayment(PaymentOrder order);
}
// Pure Domain Records (mỗi public type một file .java riêng, gom lại đây cho gọn)
// PaymentOrder.java
public record PaymentOrder(String transactionId, BigDecimal amount, Currency currency,
String customerToken, String paymentMethodToken) {}
// PaymentResult.java
public record PaymentResult(boolean isSuccess, String gatewayReference, String errorCode, String rawMessage) {}# outbound Adapter 1: Stripe modern API
@Component("stripePaymentAdapter")
public class StripePaymentAdapter implements PaymentGatewayPort {
private final StripeClient stripeClient;
public StripePaymentAdapter(StripeClient stripeClient) {
this.stripeClient = stripeClient;
}
@Override
public PaymentResult executePayment(PaymentOrder order) {
try {
// Chuyển đổi Domain Model sang Stripe SDK Parameter Map
// Stripe nhận số tiền ở đơn vị nhỏ nhất: USD tính bằng cent (x100),
// còn VND, JPY là zero-decimal (không nhân). Currency biết số chữ số lẻ.
long minorUnits = order.amount()
.movePointRight(order.currency().getDefaultFractionDigits())
.longValueExact(); // ném lỗi thay vì lặng lẽ cắt số lẻ
PaymentIntentCreateParams params = PaymentIntentCreateParams.builder()
.setAmount(minorUnits)
.setCurrency(order.currency().getCurrencyCode().toLowerCase())
.setCustomer(order.customerToken())
.setPaymentMethod(order.paymentMethodToken())
.setConfirm(true) // không confirm thì intent chỉ ở trạng thái chờ, không bao giờ "succeeded"
.setOffSession(true) // charge thẻ đã lưu, khách không có mặt
.putMetadata("txId", order.transactionId())
.build();
// Idempotency key: retry do timeout cũng không trừ tiền hai lần
RequestOptions options = RequestOptions.builder()
.setIdempotencyKey(order.transactionId())
.build();
PaymentIntent intent = stripeClient.paymentIntents().create(params, options);
// Map ngược từ Vendor Response sang Domain Result
return new PaymentResult(
"succeeded".equalsIgnoreCase(intent.getStatus()),
intent.getId(),
null,
intent.getStatus()
);
} catch (StripeException e) {
// Chặn đứng Exception của Vendor không cho rò rỉ vào Domain!
return new PaymentResult(false, null, e.getCode(), e.getMessage());
}
}
}# outbound Adapter 2: ACL bọc socket legacy bank
@Component("legacyBankAdapter")
public class LegacyBankAdapter implements PaymentGatewayPort {
private final LegacySocketClient socketClient;
public LegacyBankAdapter(LegacySocketClient socketClient) {
this.socketClient = socketClient;
}
@Override
public PaymentResult executePayment(PaymentOrder order) {
try {
// 1. Dịch sang bản ghi độ dài cố định của Core Banking
// (format tự chế cho ví dụ; ISO 8583 thật có MTI + bitmap, thường dùng thư viện như jPOS)
String fixedLengthPayload = String.format("TX%012d%s%08d",
order.amount().movePointRight(order.currency().getDefaultFractionDigits()).longValueExact(),
order.currency().getCurrencyCode(),
Long.parseLong(order.customerToken())
);
// 2. Gửi qua kết nối TCP Socket thô
byte[] responseBytes = socketClient.sendAndReceive(fixedLengthPayload.getBytes(StandardCharsets.US_ASCII));
String responseStr = new String(responseBytes, StandardCharsets.US_ASCII);
// 3. Phân tích chuỗi phản hồi thô của hệ thống cũ
if (responseStr.length() < 8) {
return new PaymentResult(false, null, "BAD_RESPONSE", "Phản hồi Core Banking sai định dạng");
}
String responseCode = responseStr.substring(0, 2);
String approvalCode = responseStr.substring(2, 8);
boolean isSuccess = "00".equals(responseCode);
return new PaymentResult(isSuccess, approvalCode, responseCode, isSuccess ? "Approved" : "Declined");
} catch (IOException e) {
return new PaymentResult(false, null, "NET_ERR", "Lỗi kết nối Socket Core Banking");
}
}
}CAUTION
Anti-Pattern: Leaky Adapter (vendor rò rỉ qua Adapter) Đừng để exception (StripeException, SQLException) hay DTO của nhà cung cấp (PaymentIntent) lọt qua Adapter vào Domain. Domain mà phải catch StripeException thì coi như kiến trúc đã mất tính độc lập.
Vài chỗ trong ví dụ đáng để ý khi làm thật. Với Stripe, PaymentIntent còn nhiều trạng thái khác ngoài succeeded: processing (chưa có kết quả, chờ webhook), requires_action (cần khách xác thực 3DS). Gộp hết thành isSuccess = false như trên là quá thô, domain nên có thêm trạng thái "đang xử lý". Còn với socket legacy, timeout mà chưa đọc được phản hồi thì không biết giao dịch đã trừ tiền hay chưa; trả về "thất bại" rồi cho retry có thể trừ tiền hai lần. Chỗ này cần một bước tra soát (inquiry) với core banking trước khi kết luận.
# phân biệt 4 structural patterns: Adapter, Facade, Proxy, Decorator
Nhiều bạn hay nhầm 4 pattern này vì cả bốn đều "bọc một đối tượng khác" (wrapper). Cách phân biệt nhanh nhất là nhìn vào ý định: có đổi interface không, và bọc để làm gì.
| Mẫu thiết kế | Thay đổi Interface? | Mục đích chính | Mối quan hệ đối tượng |
|---|---|---|---|
| Adapter | CÓ | Chuyển một giao diện không tương thích thành giao diện mà Client cần. | 1 Adaptee → 1 Adapter. |
| Facade | CÓ (Tạo mới cấp cao) | Đơn giản hóa một subsystem phức tạp bằng một giao diện tiện lợi duy nhất. | Nhiều Subsystems → 1 Facade. |
| Proxy | KHÔNG (Giữ nguyên) | Kiểm soát quyền truy cập, lazy loading hoặc định tuyến tới đối tượng thật. | Cùng implement chung 1 Interface. |
| Decorator | KHÔNG (Giữ nguyên) | Thêm trách nhiệm, tính năng bổ sung linh hoạt tại runtime. | Lồng nhau vô hạn (Recursive wrapping). |
# tổng kết & production checklist
Dùng đúng chỗ, Adapter giúp hệ thống không bị trói vào một công nghệ cụ thể:
- Giảm vendor lock-in: thêm cổng thanh toán PayPal bên cạnh Stripe chủ yếu là viết thêm một
PayPalPaymentAdapter, phần nghiệp vụ gần như không đổi. Gần như thôi, vì khác biệt về luồng (redirect, webhook, hoàn tiền) đôi khi vẫn buộc Port phải mở rộng. - Giữ domain ổn định: core domain gọn gàng, unit test không cần database hay mạng nên chạy rất nhanh.
- Dễ đổi hạ tầng: đổi database chủ yếu diễn ra bên trong outbound persistence adapter. Đổi giữa hai database quan hệ thì khá êm; từ Oracle sang MongoDB thì khác biệt về transaction và cách query thường vẫn lọt lên tới thiết kế Port.
Đừng lạm dụng: service CRUD nhỏ mà cũng chia port, adapter, mapper cho từng bảng thì số file tăng gấp ba mà chẳng được gì. Port đáng tạo nhất là ở ranh giới với thứ dễ thay đổi hoặc nằm ngoài tầm kiểm soát của bạn: vendor bên ngoài, hệ thống legacy.
Chỉ là những ghi chép cá nhân với hy vọng mang lại chút giá trị. Nếu thấy hữu ích, đừng ngại chia sẻ cho bạn bè & đồng nghiệp nhé!
Happy coding 😎 👍🏻 🚀 🔥.