A typical e-commerce project often has an old payment gateway: a vendor .jar that takes amounts in the smallest currency unit, returns an integer code instead of a typed result, and throws checked exceptions. You cannot change that library, and you do not want all of your business code glued to it. The Adapter pattern is the classic answer: put an intermediate class in between that turns the old interface into the one your code wants to use.

This article walks through the Adapter pattern in Java using a payment gateway example, compares object adapters with class adapters, shows how Spring MVC and the JDK use it, and explains when not to add an adapter. The code targets Java 17 (compiled with javac --release 17), was actually run, and the output is pasted verbatim.

This article belongs to the structural group in the Java & Design Patterns series, and follows Prototype pattern: clone, copy constructors and deep copies. Our example reuses an idea from Builder pattern: from Effective Java to Lombok and records (a record as the result type) and the Open/Closed and Dependency Inversion principles from SOLID principles in Java with practical examples.

What problem does the Adapter pattern solve

According to the Refactoring.Guru description of Adapter, this is a structural pattern that lets objects with incompatible interfaces work together. An adapter wraps an object to convert its interface or data format, without modifying the original object. The everyday example in that documentation: an application produces XML data, but a newer analytics library only understands JSON; instead of changing the library, you write an adapter that converts XML to JSON.

ComponentRoleIn this article's example
ClientExisting business logicDonHangService
Target (client interface)The interface the client wants to useThanhToan
Adaptee (service)A useful but incompatible classCongCuLegacy
AdapterImplements Target, holds the Adaptee, translates callsObjectAdapter

The Adapter pattern problem: a legacy payment gateway you cannot change

This is our Adaptee, simulating a vendor library. The amount is a long in dong multiplied by 100, the result is a numeric code (0 means success, 51 means insufficient limit, 96 means system error), and invalid input is reported with a checked exception.

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

    /** Returns 0 = OK, 51 = insufficient funds, 96 = system error. */
    public int charge(String orderRef, long amountMinorUnits) throws LoiCong {
        if (orderRef == null || orderRef.isBlank()) throw new LoiCong("orderRef is empty");
        if (amountMinorUnits > 5_000_000_00L) return 51;
        if (amountMinorUnits <= 0) return 96;
        dem++;
        return 0;
    }
    public String lastTransactionId() { return "LEG-" + dem; }
}

And here is the interface the business code wants: the amount is a BigDecimal in dong, the result is a typed record, with no checked exceptions and no magic numbers.

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

Without the Adapter pattern, every place that needs a payment has to convert units, read error codes, and catch exceptions on its own, and switching providers means editing code everywhere. Calling the library directly shows how many details leak out (here 250_000_00L means 250,000 dong):

--- 1. Calling the legacy API directly: leaks details ---
charge("D1", 25000000) = 0 (unit: dong x 100, 0 = OK)

The Adapter pattern as an object adapter: implemented with composition

In the Adapter pattern, an object adapter implements the Target and holds a reference to the Adaptee. All the conversion lives in the methods that call the wrapped object: converting units, translating error codes, catching the checked exception and turning it into a return value.

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();   // fails if precision would be lost
            int ma = legacy.charge(maDon, minor);
            return switch (ma) {
                case 0 -> new KetQua(true, legacy.lastTransactionId(), null);
                case 51 -> new KetQua(false, null, "Insufficient limit");
                default -> new KetQua(false, null, "Gateway error, code " + ma);
            };
        } catch (CongCuLegacy.LoiCong e) {
            return new KetQua(false, null, "Gateway reported an error: " + e.getMessage());
        } catch (ArithmeticException e) {
            return new KetQua(false, null, "Unsupported fractional amount: " + soTienVnd);
        }
    }
}

Two details are worth noting in this Adapter pattern. First, longValueExact() throws ArithmeticException if the amount has a fractional part, instead of silently rounding: with money, it is better to report an error than to be off by one dong. Second, the adapter does not just "rename methods"; it also converts semantics: units, error codes into types, checked exceptions into values. That is where the value of the Adapter pattern lies. The client now knows only 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 ---
Paid, transaction LEG-1001
Failed: Insufficient limit
Failed: Gateway reported an error: orderRef is empty
Failed: Unsupported fractional amount: 1000.123

Four scenarios: success, over the limit (code 51), the library's checked exception, and a fractional amount. All four produce a consistent message, and no try/catch or magic number leaks into the business code.

The Adapter pattern as a class adapter: inheriting instead of wrapping

The Refactoring.Guru description distinguishes two variants of the Adapter pattern. An object adapter uses composition, and most languages can do it. A class adapter uses inheritance, which originally meant multiple inheritance as in C++. Java allows inheriting from only one class but implementing many interfaces, so we get one form of class adapter: extend the Adaptee and implement the 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, "Error code " + ma);
        } catch (LoiCong e) {
            return new KetQua(false, null, e.getMessage());
        }
    }
}
--- 3. Class adapter (inheritance) ---
Paid, transaction LEG-1001
CriterionObject adapterClass adapter
MechanismCompositionInheritance
Swap the Adaptee at runtimeYes (pass it to the constructor)No, tied to one class
Adaptee is final or an interfaceWorksDoes not work if final
Access to protected methodsNoYes
Exposes the old API to the outsideNoYes, because ClassAdapter is still a CongCuLegacy

In Java, the object adapter form of the Adapter pattern is almost always the better choice: it is more flexible, easier to test, and follows the principle of "favor composition over inheritance". The class adapter has a drawback that is easy to miss: because it inherits, ClassAdapter is still a CongCuLegacy, so outside code can still call charge(...) directly and defeat the goal of hiding the old API.

The Adapter pattern makes testing easier

A practical benefit of the Adapter pattern is often underestimated: when business code depends only on ThanhToan, you can swap the adapter for a fake in tests without needing the real gateway. Because ThanhToan has just one abstract method, a lambda is enough:

ThanhToan gia = (ma, tien) -> new ThanhToan.KetQua(true, "FAKE-1", null);
System.out.println(new DonHangService(gia).datHang("D6", BigDecimal.TEN));
--- 4. Benefit: swap in a fake for testing ---
Paid, transaction FAKE-1

It also means that when you move to a new provider, you write one more adapter for the same ThanhToan and leave DonHangService untouched. This is the Open/Closed Principle applied directly, and it is why Refactoring.Guru lists "Single Responsibility" (separating conversion code from business logic) and "Open/Closed" as the main advantages of the Adapter pattern.

The Adapter pattern in Spring MVC: HandlerAdapter

The best-known example of the Adapter pattern in the Java ecosystem is in Spring MVC. According to the HandlerAdapter Javadoc, it is an SPI that allows the core MVC workflow to be parameterized; DispatcherServlet accesses every handler through this interface, so it contains no code specific to any handler type. A handler is typed as Object, which lets it integrate old-style controllers, servlets, HttpRequestHandler, handler functions, or methods annotated with @RequestMapping. The two main methods are supports(Object handler) (can this adapter handle that kind of handler) and handle(request, response, handler). Spring ships many implementations of this interface, such as RequestMappingHandlerAdapter (for methods annotated with @RequestMapping), HttpRequestHandlerAdapter, and SimpleControllerHandlerAdapter (for old-style controllers). The Javadoc states clearly that this interface is meant for framework authors, not application developers, so we use it only to learn the mechanism.

To see the mechanism without pulling in all of Spring, the code below is a miniature that reproduces exactly those two methods: a "dispatcher" walks a list of adapters, asks supports, then calls handle. The snippet below omits the definitions of Controller, Lambda, and the ADAPTERS list; the full runnable code is in 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("No adapter for " + handler.getClass().getSimpleName());
}
--- 5. Spring-style HandlerAdapter (miniature) ---
Legacy controller got: GET /don
Function got: GET /don
Error: No adapter for String

Two completely different kinds of handlers pass through the same dispatch. Adding a third kind only needs one more adapter, which is exactly what the Javadoc describes: extension without modifying DispatcherServlet. This is only a simulation to illustrate the mechanism, not Spring's code.

The Adapter pattern in the JDK

The JDK has several notable adapters, all of them the Adapter pattern. InputStreamReader is the bridge from byte streams to character streams: it reads bytes from an InputStream and decodes them with a charset. Arrays.asList bridges array-based and collection-based APIs, returning a fixed-size list backed by the array itself. Collections.enumeration and Collections.list convert back and forth between the legacy Enumeration and the newer Iterator/List. The output of a run:

--- 6. Adapters in the JDK ---
InputStreamReader: 27 bytes -> 21 chars: Đơn hàng #42: Ốp lưng
Arrays.asList write-through: mang[0]=X
Arrays.asList.add -> UnsupportedOperationException
Enumeration -> Iterator: xy
Arrays.asList(int[]).size() = 1  (pitfall: List<int[]>, not List<Integer>)

The first line shows the "adapter" nature of InputStreamReader: 27 UTF-8 bytes become 21 characters, because accented Vietnamese letters take several bytes each (the sample text is a Vietnamese phrase meaning "Order #42: Phone case"). Always specify the charset explicitly (StandardCharsets.UTF_8) instead of relying on the platform default. Lines two and three show the write-through behavior of Arrays.asList: writing to the list changes the original array, and you cannot add. The last line is a classic pitfall: with a primitive array, Arrays.asList(int[]) returns a List<int[]> containing exactly one element.

Telling Adapter apart from Decorator, Facade and Proxy

These four patterns all "wrap" another object, so they are easy to confuse. The difference is in the intent:

PatternInterface after wrappingPurpose
AdapterChanges to a different interfaceLets two incompatible interfaces work together
DecoratorKeeps the same interfaceAdds behavior, can be stacked
FacadeA new, simple interface for a whole subsystemHides the complexity of many classes
ProxyKeeps the same interfaceAccess control, lazy loading, logging

A quick rule: if you wrap one object to change its interface, that is the Adapter pattern. If you keep the interface and add behavior, that is a Decorator, the pattern covered in the next article. If you hide a whole group of classes behind a new interface, that is a Facade.

When not to use the Adapter pattern

Refactoring.Guru names the downside clearly: it increases complexity by adding interfaces and classes, and sometimes changing the service directly is simpler. Specifically, do not use the Adapter pattern when:

  • You own and can change the Adaptee: changing its interface directly is usually cleaner than adding an intermediate class.
  • It is used exactly once, in one place: a static utility function may be enough, and there is no need for a Target interface yet.
  • The two interfaces differ too much in semantics: for example, one side is synchronous and the other asynchronous with a very different error model. An adapter can hide method names but not differences in timing and failure; in that case you need to redesign the boundary.
  • The adapter starts to contain business logic: if it makes decisions, computes values, and remembers state, you are stuffing business logic into the conversion layer.

Common mistakes

  1. Leaking the Adaptee's types into the Target (the most common Adapter pattern mistake): if ThanhToan returned CongCuLegacy.LoiCong, the client would again depend on the old library. The Target must use your own types.
  2. Silent precision loss in unit conversion: use longValueExact() or an explicit check, as in the 1000.123 example.
  3. Swallowing exceptions: returning false with no reason makes diagnosis impossible; the example above keeps the original message.
  4. Using a class adapter when you need to hide the old API: inheritance exposes the original API; prefer an object adapter.
  5. An adapter that holds state shared between threads: lastTransactionId() in the simulated example is a poor design in a real multithreaded environment; with a real library, make the pair of calls (charge and fetching the transaction ID) atomic, or use an API that returns the transaction ID directly.
  6. Chains of adapters wrapping adapters: several layers of the Adapter pattern make debugging hard; if you find yourself writing an adapter for an adapter, revisit the Target.
  7. Forgetting to specify the charset with InputStreamReader: results differ between machines.

Checklist

  • Use the Adapter pattern when you need to integrate a class with a mismatched interface that you cannot change.
  • Define the Target with your own types and semantics (BigDecimal, a result record, no exceptions from the old library).
  • Prefer an object adapter (composition); use a class adapter only when you really need protected members.
  • Convert semantics: units, error codes, exceptions; check for precision loss.
  • Make the client depend on the Target so you can swap the adapter or use a fake in tests.
  • Keep the Adapter pattern thin, with no business logic.

Test environment for the Adapter pattern: the source code targets Java 17 (javac --release 17, no APIs newer than Java 17). The full source and run.sh are in code/s6-06. The payment gateway example is simulated, calls no external service, and contains no real keys or data.

Next article

Decorator pattern: lessons from java.io applied to services. The Adapter pattern changes the interface; a Decorator keeps the interface and adds behavior, the way BufferedReader wraps the InputStreamReader we just met.