Một dự án thương mại điện tử Việt thường có cổng thanh toán cũ: thư viện .jar của nhà cung cấp, nhận số tiền theo đơn vị nhỏ nhất, trả về mã số nguyên thay vì kết quả có kiểu, và ném ngoại lệ checked. Bạn không sửa được thư viện đó, mà cũng không muốn toàn bộ code nghiệp vụ dính chặt vào nó. Adapter pattern là câu trả lời kinh điển: đặt một lớp trung gian biến giao diện cũ thành giao diện mà code của bạn muốn dùng.

Bài này trình bày Adapter pattern trong Java qua một ví dụ cổng thanh toán, so sánh object adapter với class adapter, chỉ ra cách Spring MVC và JDK dùng nó, và nêu khi nào không nên thêm adapter. Code nhắm tới Java 17 (biên dịch bằng javac --release 17), đã chạy thật và output được dán nguyên văn.

Bài thuộc nhóm structural trong series Java & Design Pattern, sau bài Prototype pattern: clone, copy constructor và sao chép sâu. Ví dụ của chúng ta tận dụng ý tưởng ở Builder pattern: từ Effective Java tới Lombok và record (dùng record làm kết quả) và nguyên lý Open/Closed, Dependency Inversion trong Nguyên lý SOLID trong Java qua ví dụ thực tế.

Adapter pattern giải quyết vấn đề gì

Theo mô tả Adapter của Refactoring.Guru, đây là mẫu structural cho phép các đối tượng có giao diện không tương thích làm việc cùng nhau. Adapter bọc một đối tượng để chuyển đổi giao diện hoặc định dạng dữ liệu, mà không cần sửa đối tượng gốc. Ví dụ đời thường trong tài liệu: ứng dụng nhận dữ liệu XML nhưng thư viện phân tích mới chỉ hiểu JSON; thay vì sửa thư viện, ta viết adapter chuyển XML sang JSON.

Thành phầnVai tròTrong ví dụ của bài
ClientLogic nghiệp vụ hiện cóDonHangService
Target (client interface)Giao diện client muốn dùngThanhToan
Adaptee (service)Lớp hữu ích nhưng không tương thíchCongCuLegacy
AdapterImplement Target, giữ Adaptee, dịch lời gọiObjectAdapter

Bài toán cho Adapter pattern: cổng thanh toán cũ không sửa được

Đây là Adaptee của chúng ta, giả lập một thư viện của nhà cung cấp. Số tiền là long tính theo đồng nhân 100, kết quả là mã số (0 là thành công, 51 là không đủ hạn mức, 96 là lỗi hệ thống), và lỗi đầu vào được báo bằng ngoại lệ checked.

public class CongCuLegacy {
    public static class LoiCong extends Exception {
        public LoiCong(String m) { super(m); }
    }
    private int dem = 1000;

    /** Trả 0 = OK, 51 = không đủ tiền, 96 = lỗi hệ thống. */
    public int charge(String orderRef, long amountMinorUnits) throws LoiCong {
        if (orderRef == null || orderRef.isBlank()) throw new LoiCong("orderRef rỗng");
        if (amountMinorUnits > 5_000_000_00L) return 51;
        if (amountMinorUnits <= 0) return 96;
        dem++;
        return 0;
    }
    public String lastTransactionId() { return "LEG-" + dem; }
}

Còn đây là giao diện mà code nghiệp vụ muốn: số tiền là BigDecimal theo đồng, kết quả là một record có kiểu, không ngoại lệ checked, không số ma thuật.

public interface ThanhToan {
    KetQua thanhToan(String maDon, BigDecimal soTienVnd);
    record KetQua(boolean thanhCong, String maGiaoDich, String lyDo) {}
}

Nếu không có Adapter pattern, mỗi nơi cần thanh toán phải tự đổi đơn vị, tự đọc mã lỗi, tự bắt ngoại lệ, và đổi nhà cung cấp nghĩa là sửa khắp nơi. Chỉ cần gọi thẳng là thấy mức độ lộ chi tiết (ở đây 250_000_00L nghĩa là 250.000 đồng):

--- 1. Gọi thẳng API cũ: lộ chi tiết ---
charge("D1", 25000000) = 0 (đơn vị: đồng x 100, 0 = OK)

Adapter pattern dạng object adapter: cài đặt bằng composition

Trong Adapter pattern, object adapter implement Target và giữ một tham chiếu tới Adaptee. Mọi việc chuyển đổi nằm trong các method gọi tới đối tượng được bọc: đổi đơn vị, dịch mã lỗi, bắt ngoại lệ checked rồi biến thành giá trị trả về.

public class ObjectAdapter implements ThanhToan {
    private final CongCuLegacy legacy;
    public ObjectAdapter(CongCuLegacy legacy) { this.legacy = legacy; }

    @Override
    public KetQua thanhToan(String maDon, BigDecimal soTienVnd) {
        try {
            long minor = soTienVnd.movePointRight(2).longValueExact();   // báo lỗi nếu mất độ chính xác
            int ma = legacy.charge(maDon, minor);
            return switch (ma) {
                case 0 -> new KetQua(true, legacy.lastTransactionId(), null);
                case 51 -> new KetQua(false, null, "Không đủ hạn mức");
                default -> new KetQua(false, null, "Lỗi cổng thanh toán, mã " + ma);
            };
        } catch (CongCuLegacy.LoiCong e) {
            return new KetQua(false, null, "Cổng báo lỗi: " + e.getMessage());
        } catch (ArithmeticException e) {
            return new KetQua(false, null, "Số tiền có phần lẻ không hỗ trợ: " + soTienVnd);
        }
    }
}

Hai chi tiết đáng chú ý của Adapter pattern này. Thứ nhất, longValueExact() ném ArithmeticException nếu số tiền có phần lẻ, thay vì âm thầm làm tròn: với tiền tệ, thà báo lỗi còn hơn sai một đồng. Thứ hai, adapter không chỉ "đổi tên method" mà còn chuyển đổi ngữ nghĩa: đơn vị, mã lỗi sang kiểu, ngoại lệ checked sang giá trị. Đây là phần giá trị của Adapter pattern. Client giờ chỉ biết ThanhToan:

DonHangService svc = new DonHangService(new ObjectAdapter(new CongCuLegacy()));
System.out.println(svc.datHang("D2", new BigDecimal("250000")));
System.out.println(svc.datHang("D3", new BigDecimal("9000000")));
System.out.println(svc.datHang("", new BigDecimal("1000")));
System.out.println(svc.datHang("D4", new BigDecimal("1000.123")));
--- 2. Object adapter ---
Đã thanh toán, giao dịch LEG-1001
Thất bại: Không đủ hạn mức
Thất bại: Cổng báo lỗi: orderRef rỗng
Thất bại: Số tiền có phần lẻ không hỗ trợ: 1000.123

Bốn kịch bản: thành công, vượt hạn mức (mã 51), ngoại lệ checked của thư viện, và số tiền lẻ. Cả bốn đều ra thông báo thống nhất, không có try/catch hay số ma thuật nào lọt vào code nghiệp vụ.

Adapter pattern dạng class adapter: kế thừa thay vì bọc

Mô tả của Refactoring.Guru phân biệt hai biến thể của Adapter pattern. Object adapter dùng composition và hầu hết ngôn ngữ đều làm được. Class adapter dùng kế thừa, vốn gốc là đa kế thừa như C++. Java chỉ cho kế thừa một lớp nhưng implement nhiều interface, nên ta có một dạng class adapter: kế thừa Adaptee và implement Target.

public class ClassAdapter extends CongCuLegacy implements ThanhToan {
    @Override
    public KetQua thanhToan(String maDon, BigDecimal soTienVnd) {
        try {
            int ma = charge(maDon, soTienVnd.movePointRight(2).longValueExact());
            return ma == 0 ? new KetQua(true, lastTransactionId(), null) : new KetQua(false, null, "Mã lỗi " + ma);
        } catch (LoiCong e) {
            return new KetQua(false, null, e.getMessage());
        }
    }
}
--- 3. Class adapter (kế thừa) ---
Đã thanh toán, giao dịch LEG-1001
Tiêu chíObject adapterClass adapter
Cơ chếCompositionKế thừa
Đổi Adaptee lúc chạyĐược (truyền vào constructor)Không, gắn chặt vào một lớp
Adaptee là final hoặc interfaceDùng đượcKhông dùng được nếu final
Truy cập method protectedKhôngCó
Lộ API cũ ra ngoàiKhôngCó, vì ClassAdapter vẫn là CongCuLegacy

Trong Java, Adapter pattern dạng object adapter gần như luôn là lựa chọn tốt hơn: linh hoạt hơn, kiểm thử dễ hơn, và theo nguyên tắc "ưu tiên composition hơn kế thừa". Class adapter có một nhược điểm dễ bị bỏ qua: vì kế thừa, ClassAdapter vẫn là một CongCuLegacy, nên code bên ngoài vẫn có thể gọi trực tiếp charge(...) và phá vỡ ý đồ che giấu API cũ.

Adapter pattern làm kiểm thử dễ hơn

Lợi ích thực tế của Adapter pattern thường bị đánh giá thấp: khi code nghiệp vụ chỉ phụ thuộc vào ThanhToan, bạn thay adapter bằng bản giả lập trong kiểm thử mà không cần cổng thật. Vì ThanhToan chỉ có một method trừu tượng, một lambda là đủ:

ThanhToan gia = (ma, tien) -> new ThanhToan.KetQua(true, "FAKE-1", null);
System.out.println(new DonHangService(gia).datHang("D6", BigDecimal.TEN));
--- 4. Lợi ích: thay bằng giả lập khi kiểm thử ---
Đã thanh toán, giao dịch FAKE-1

Cũng nhờ vậy, khi chuyển sang nhà cung cấp mới, bạn viết thêm một adapter mới cho cùng ThanhToan, không sửa DonHangService. Đây là Open/Closed Principle áp dụng trực tiếp, và là lý do Refactoring.Guru liệt kê "Single Responsibility" (tách code chuyển đổi khỏi nghiệp vụ) và "Open/Closed" làm ưu điểm chính của Adapter pattern.

Adapter pattern trong Spring MVC: HandlerAdapter

Ví dụ nổi tiếng nhất của Adapter pattern trong hệ sinh thái Java nằm ở Spring MVC. Theo Javadoc của HandlerAdapter, đây là SPI cho phép tham số hoá luồng xử lý MVC cốt lõi; DispatcherServlet truy cập mọi handler thông qua interface này nên không chứa mã riêng cho loại handler cụ thể nào. Handler có kiểu Object, nhờ đó tích hợp được controller kiểu cũ, servlet, HttpRequestHandler, handler function hay method gắn @RequestMapping. Hai method chính là supports(Object handler) (adapter này xử lý được loại handler đó không) và handle(request, response, handler). Spring có sẵn nhiều cài đặt cho interface này, chẳng hạn RequestMappingHandlerAdapter (cho method gắn @RequestMapping), HttpRequestHandlerAdapter và SimpleControllerHandlerAdapter (cho controller kiểu cũ). Javadoc ghi rõ interface này dành cho người viết framework, không phải cho lập trình viên ứng dụng, nên ta chỉ dùng nó để học cơ chế.

Để thấy rõ cơ chế mà không phải kéo cả Spring vào, đoạn code sau mô phỏng thu nhỏ đúng hai method đó: một "dispatcher" duyệt danh sách adapter, hỏi supports rồi gọi handle. Đoạn dưới đây lược phần định nghĩa Controller, Lambda và danh sách ADAPTERS; mã đầy đủ, chạy được nằm ở code/s6-06/src/vn/sudo/adapter/MiniHandlerAdapter.java.

interface Adapter { boolean supports(Object handler); String handle(Object handler, String req); }

static class ControllerAdapter implements Adapter {
    public boolean supports(Object h) { return h instanceof Controller; }
    public String handle(Object h, String req) { return ((Controller) h).xuLy(req); }
}
static class LambdaAdapter implements Adapter {
    public boolean supports(Object h) { return h instanceof Lambda; }
    public String handle(Object h, String req) { return ((Lambda) h).f.apply(req); }
}

static String dispatch(Object handler, String req) {
    for (Adapter a : ADAPTERS) if (a.supports(handler)) return a.handle(handler, req);
    throw new IllegalStateException("Không có adapter cho " + handler.getClass().getSimpleName());
}
--- 5. HandlerAdapter kiểu Spring (thu nhỏ) ---
Controller cũ nhận: GET /don
Hàm nhận: GET /don
Lỗi: Không có adapter cho String

Hai loại handler hoàn toàn khác nhau đi qua cùng một dispatch. Thêm loại thứ ba chỉ cần thêm một adapter, đúng điều Javadoc mô tả: mở rộng không cần sửa DispatcherServlet. Đây chỉ là mô phỏng để minh hoạ cơ chế, không phải mã của Spring.

Adapter pattern trong JDK

JDK có nhiều adapter đáng kể, tất cả đều là Adapter pattern. InputStreamReader là cầu nối từ byte stream sang character stream: đọc byte từ InputStream và giải mã theo charset. Arrays.asList là cầu nối giữa API dựa trên mảng và API dựa trên collection, trả về danh sách kích thước cố định bao ngoài chính mảng đó. Collections.enumeration và Collections.list chuyển qua lại giữa Enumeration cũ và Iterator/List mới. Kết quả chạy:

--- 6. Adapter trong JDK ---
InputStreamReader: 27 byte -> 21 ký tự: Đơn hàng #42: Ốp lưng
Arrays.asList ghi xuyên: mang[0]=X
Arrays.asList.add -> UnsupportedOperationException
Enumeration -> Iterator: xy
Arrays.asList(int[]).size() = 1  (cạm bẫy: List<int[]> chứ không phải List<Integer>)

Dòng đầu cho thấy bản chất "adapter" của InputStreamReader: 27 byte UTF-8 trở thành 21 ký tự vì tiếng Việt có dấu chiếm nhiều byte. Luôn chỉ định charset tường minh (StandardCharsets.UTF_8) thay vì dựa vào mặc định của nền tảng. Dòng hai và ba cho thấy hành vi write-through của Arrays.asList: ghi vào list thì mảng gốc đổi theo, và không thể add. Dòng cuối là cạm bẫy kinh điển: với mảng nguyên thủy, Arrays.asList(int[]) trả về List<int[]> chứa đúng một phần tử.

Phân biệt Adapter với Decorator, Facade và Proxy

Bốn mẫu này đều "bọc" một đối tượng khác, nên dễ nhầm. Điểm khác nằm ở ý định:

MẫuGiao diện sau khi bọcMục đích
AdapterĐổi sang giao diện khácCho hai giao diện không tương thích làm việc cùng nhau
DecoratorGiữ nguyên giao diệnThêm hành vi, có thể xếp chồng
FacadeGiao diện mới, đơn giản, cho cả hệ thống conChe sự phức tạp của nhiều lớp
ProxyGiữ nguyên giao diệnKiểm soát truy cập, lazy loading, ghi log

Quy tắc nhanh: nếu bạn bọc một đối tượng để đổi giao diện, đó là Adapter pattern. Nếu giữ giao diện và thêm hành vi, đó là Decorator, mẫu sẽ được trình bày ở bài sau. Nếu che cả một nhóm lớp sau một giao diện mới, đó là Facade.

Khi nào không nên dùng Adapter pattern

Refactoring.Guru nêu nhược điểm rõ ràng: tăng độ phức tạp vì thêm interface và lớp, và đôi khi sửa trực tiếp service cho khớp sẽ đơn giản hơn. Cụ thể, đừng dùng Adapter pattern khi:

  • Bạn sở hữu và sửa được Adaptee: đổi thẳng giao diện của nó thường gọn hơn thêm lớp trung gian.
  • Chỉ dùng đúng một lần, một chỗ: một hàm tiện ích tĩnh có thể đủ, chưa cần interface Target.
  • Hai giao diện quá khác nhau về ngữ nghĩa: ví dụ một bên đồng bộ, một bên bất đồng bộ với mô hình lỗi khác hẳn. Adapter che được tên method nhưng không che được khác biệt thời gian và lỗi; khi đó cần thiết kế lại ranh giới.
  • Adapter bắt đầu chứa nghiệp vụ: nếu nó quyết định, tính toán và nhớ trạng thái, đó là dấu hiệu bạn đang nhét logic nghiệp vụ vào lớp chuyển đổi.

Lỗi thường gặp

  1. Để lọt kiểu của Adaptee ra Target (lỗi phổ biến nhất của Adapter pattern): nếu ThanhToan trả CongCuLegacy.LoiCong, client lại phụ thuộc vào thư viện cũ. Target phải dùng kiểu của riêng bạn.
  2. Chuyển đổi đơn vị âm thầm mất chính xác: dùng longValueExact() hoặc kiểm tra tường minh, như ví dụ số tiền 1000.123.
  3. Nuốt ngoại lệ: trả false không kèm lý do khiến không thể chẩn đoán; ví dụ trên giữ lại thông điệp gốc.
  4. Dùng class adapter khi cần che API cũ: kế thừa làm lộ API gốc; ưu tiên object adapter.
  5. Adapter giữ trạng thái chia sẻ giữa các luồng: lastTransactionId() trong ví dụ giả lập là cách thiết kế kém trong môi trường đa luồng thật; với thư viện thật, cần đảm bảo cặp lời gọi charge và lấy mã giao dịch là nguyên tử hoặc dùng API trả mã giao dịch trực tiếp.
  6. Chuỗi adapter nối adapter: nhiều tầng Adapter pattern làm khó gỡ lỗi; nếu thấy mình viết adapter cho adapter, hãy xem lại Target.
  7. Quên chỉ định charset khi dùng InputStreamReader: kết quả khác nhau giữa các máy.

Checklist

  • Dùng Adapter pattern khi cần tích hợp lớp có giao diện không khớp mà bạn không sửa được.
  • Định nghĩa Target bằng kiểu và ngữ nghĩa của riêng bạn (BigDecimal, record kết quả, không ngoại lệ của thư viện cũ).
  • Ưu tiên object adapter (composition); chỉ dùng class adapter khi thật sự cần member protected.
  • Chuyển đổi ngữ nghĩa: đơn vị, mã lỗi, ngoại lệ; kiểm tra mất chính xác.
  • Cho client phụ thuộc vào Target để thay adapter hoặc giả lập khi kiểm thử.
  • Giữ Adapter pattern mỏng, không chứa nghiệp vụ.

Môi trường chạy thử Adapter pattern: mã nguồn nhắm tới Java 17 (javac --release 17, không dùng API sau Java 17). Mã nguồn đầy đủ và run.sh nằm trong code/s6-06. Ví dụ cổng thanh toán là giả lập, không gọi dịch vụ ngoài và không chứa khoá hay dữ liệu thật.

Bài tiếp theo

Decorator pattern: học từ java.io và áp dụng cho dịch vụ. Adapter pattern đổi giao diện; Decorator giữ nguyên giao diện và thêm hành vi, như cách BufferedReader bọc InputStreamReader mà ta vừa gặp.