The introduction left the symptom on the table: PideYa needs to charge through a legacy payment SDK whose interface looks nothing like our PaymentGateway, and we can touch neither the SDK (it isn't ours) nor the clients of PaymentGateway (they are half the system). Adapter is the pattern of incompatible interfaces: the plug that lets you connect an existing piece to a socket that was never designed for it. It is probably the structural pattern you will write most often in your career, because the real world is full of code that doesn't fit: third-party APIs, legacy systems, libraries that don't choose your names.

Contents

  1. The problem in PideYa: the SDK that doesn't fit
  2. Intent and structure: the object Adapter
  3. Complete Java implementation
  4. A second client of the pattern: the restaurant aggregator
  5. Class Adapter: the inheritance variant
  6. Two-way adapters
  7. Adapter in the JDK
  8. When to use it and when not to
  9. Common mistakes, exercises, and conclusion

The problem in PideYa: the SDK that doesn't fit

The business wants to accept PayPal. The only SDK available under the signed agreement is a veteran library, paypal-legacy-sdk, which we cannot modify and whose interface is this:

// Third-party code: we canNOT touch it.
public class PayPalLegacyClient {

    /** Charges an amount expressed in CENTS. Returns a code:
     *  0 = OK, 1 = insufficient funds, 2 = account blocked, 9 = technical error. */
    public int makePayment(long amountInCents, String payerAccountId) { /* ... */ }

    /** Identifier of the last transaction performed by this client. */
    public String getLastTransactionId() { /* ... */ }
}

Our checkout, on the other hand, speaks the language we settled on in module 2, already spoken by Redsys and Conekta:

public interface PaymentGateway {
    PaymentResult charge(BigDecimal amount, PaymentData data);
}

The mismatches come in every flavor, and they are the typical mismatches of any integration:

Aspect PaymentGateway (ours) PayPalLegacyClient (theirs)
Operation name charge makePayment
Amount BigDecimal in euros long in cents
Payer data A PaymentData object A String with the account id
Result A PaymentResult object (accepted/rejected + reason) An int with magic codes
Transaction id Inside PaymentResult Separate call, getLastTransactionId()
Errors Modeled in the result Code 9 and pray

The first impulse —scattering conversions across the checkout: if (method == PAYPAL) { long cents = ...; int code = client.makePayment(...); if (code == 0) ... }— wrecks everything module 2 achieved: the client knows concrete classes again, the switch is reborn, and PayPal's magic codes contaminate our business logic. The need, properly stated: make PayPal look like just another PaymentGateway, without touching the SDK or a single existing client.

Intent and structure: the object Adapter

Intent (GoF): convert the interface of a class into another interface clients expect. Adapter lets classes work together that couldn't otherwise because of incompatible interfaces.

The canonical solution is the object Adapter: a new class that implements the expected interface (Target) and contains the incompatible object (Adaptee), translating every call.

classDiagram
    class PaymentGateway {
        <<interface>>
        +charge(amount: BigDecimal, data: PaymentData) PaymentResult
    }
    class PayPalAdapter {
        -payPalClient: PayPalLegacyClient
        +charge(amount: BigDecimal, data: PaymentData) PaymentResult
    }
    class PayPalLegacyClient {
        +makePayment(amountInCents: long, payerAccountId: String) int
        +getLastTransactionId() String
    }
    class CheckoutService

    PaymentGateway <|.. PayPalAdapter
    PayPalAdapter o-- PayPalLegacyClient : delegates to
    CheckoutService --> PaymentGateway : uses

The GoF roles mapped onto PideYa:

GoF role In PideYa
Target (interface the client expects) PaymentGateway
Adaptee (incompatible existing class) PayPalLegacyClient
Adapter (translator) PayPalAdapter
Client CheckoutService and any code that uses PaymentGateway

The geometry is minimal —one class, one implemented interface, one contained reference— but it repairs exactly the fracture: the client stays monolingual in PaymentGateway, and all the "customs work" (units, codes, names) lives in a single place, honoring SRP: the only reason for PayPalAdapter to change is a change in the SDK or in our contract.

Complete Java implementation

public class PayPalAdapter implements PaymentGateway {

    private final PayPalLegacyClient payPalClient;

    public PayPalAdapter(PayPalLegacyClient payPalClient) {
        this.payPalClient = payPalClient;
    }

    @Override
    public PaymentResult charge(BigDecimal amount, PaymentData data) {
        // 1. Translate the input data: euros -> cents, PaymentData -> account id.
        long cents = amount.movePointRight(2)
                           .setScale(0, RoundingMode.HALF_UP)
                           .longValueExact();
        String account = data.getPayPalAccount();

        // 2. Delegate to the adaptee.
        int code = payPalClient.makePayment(cents, account);

        // 3. Translate the result: magic codes -> our model.
        return switch (code) {
            case 0 -> PaymentResult.accepted(payPalClient.getLastTransactionId());
            case 1 -> PaymentResult.rejected("Insufficient funds");
            case 2 -> PaymentResult.rejected("PayPal account blocked");
            default -> PaymentResult.error("PayPal technical error (code " + code + ")");
        };
    }
}

Three craft details that separate a correct adapter from a dangerous one:

  • Unit conversion is sacred. movePointRight(2) with longValueExact() fails loudly if the amount ever carried fractions of a cent, instead of truncating silently. In payments, an adapter that rounds badly is an accounting incident; write tests specifically for the translation (€19.99 → 1999; €0.105 → exception).
  • Translating errors is translation too. The SDK's code 9 is converted into our vocabulary (PaymentResult.error(...)). An adapter that lets the adaptee's exceptions or codes escape is incomplete: the client would end up knowing the SDK through its errors.
  • The adaptee is injected, not constructed inside with new. That way the adapter can be tested with a test double of the SDK, and creation stays where module 2 said it belongs: in the composition root or in a factory.

And the integration with everything already built comes for free: since PayPalAdapter is a PaymentGateway, it slots into module 2's creational machinery without touching it. For instance, if tomorrow the Spanish market offers PayPal, SpainFactory can return it from its factory method:

@Override
public PaymentGateway createPaymentGateway() {
    return switch (config.getPreferredPaymentMethod()) {
        case REDSYS -> new RedsysGateway(config.getRedsysKey());
        case PAYPAL -> new PayPalAdapter(new PayPalLegacyClient());
    };
}

The checkout never noticed a thing. That is the litmus test of a good adapter: its existence is invisible to the client.

A second client of the pattern: the restaurant aggregator

The same move shows up at PideYa's other border: an external aggregator licenses us its restaurant catalog, with its own model:

// The aggregator's API (third-party): different naming conventions, unnormalized
// phone numbers, categories with their own taxonomy...
public class ExternalRestaurantApi {
    public List<ExternalVenue> fetchVenues(String cityCode) { /* ... */ }
}

Our catalog speaks RestaurantProvider (with List<Restaurant> findByCity(City city)). The solution is identical: an AggregatorAdapter implements RestaurantProvider that contains ExternalRestaurantApi and translates ExternalVenue → Restaurant (field mapping, phone normalization, taxonomy conversion). We won't repeat the code: what matters is recognizing the signature of the context, always the same:

  1. There is a contract of ours that the system programs against (Target).
  2. A piece arrives that doesn't honor it and that we cannot modify (Adaptee).
  3. We don't want the mismatch to contaminate the clients.

When those three conditions hold, Adapter. When the second one is missing —the piece is ours and we can change it— the answer is usually simpler: change the piece, don't strap a plug onto it.

Class Adapter: the inheritance variant

The GoF catalogs a second form, the class Adapter: instead of containing the adaptee, the adapter inherits from it while also implementing the target.

classDiagram
    class PaymentGateway { <<interface>> +charge(amount, data) PaymentResult }
    class PayPalLegacyClient { +makePayment(amountInCents, payerAccountId) int }
    class PayPalClassAdapter { +charge(amount, data) PaymentResult }

    PaymentGateway <|.. PayPalClassAdapter
    PayPalLegacyClient <|-- PayPalClassAdapter : inherits
public class PayPalClassAdapter extends PayPalLegacyClient implements PaymentGateway {
    @Override
    public PaymentResult charge(BigDecimal amount, PaymentData data) {
        int code = makePayment(toCents(amount), data.getPayPalAccount());
        // ... same result translation
    }
}

An honest comparison, which in Java almost decides itself:

Aspect Object Adapter (composition) Class Adapter (inheritance)
GoF scope Object: the relationship is fixed at construction Class: the relationship is fixed at compile time
Can adapt subclasses of the adaptee Yes, whatever is injected No: only the class it inherits from
Can adapt several adaptees at once Yes (contains several) Not in Java (single inheritance)
Can override adaptee behavior Not directly Yes (it is a subclass)
Risk Nothing special Exposes the adaptee's interface to the client (makePayment is still public!)
Requires an inheritable adaptee No: an interface, a final class, multiple instances all work Yes: a non-final class with an accessible constructor

The risk row deserves underlining: PayPalClassAdapter is a PayPalLegacyClient, so anyone can skip the translation and call makePayment directly. The object adapter encapsulates; the class adapter leaks. That is why —along with Java's single inheritance and the "composition over inheritance" motto from the principles lesson— the object variant is the default choice, and the class variant is reserved for very specific cases (you need to override protected methods of the adaptee, or the cost of one extra indirection genuinely matters to you, which is almost never).

Two-way adapters

Sometimes translation is needed in both directions. A real PideYa example: during the migration away from an old delivery system, new modules speak DeliveryService (ours) and old modules still speak LegacyDispatchSystem (the old one), and both must coexist for months. A two-way adapter implements both interfaces and translates in both directions:

public class TwoWayDeliveryAdapter implements DeliveryService, LegacyDispatchSystem {

    private final DeliveryService newDelivery;      // to serve the old modules
    private final LegacyDispatchSystem oldDelivery; // to serve the new modules

    // Call from a new module -> translated to the old system
    @Override
    public void assignCourier(Order order, Courier courier) {
        oldDelivery.dispatch(order.getId().toString(), courier.getLegacyCode());
    }

    // Call from an old module -> translated to the new system
    @Override
    public void dispatch(String orderId, String courierCode) {
        newDelivery.assignCourier(findOrder(orderId), findCourier(courierCode));
    }
    // ...
}

It is a transition pattern: useful while two worlds coexist, and a candidate for deletion once the migration ends. If a two-way adapter is celebrating birthdays in your codebase, it isn't a bridge: it's a border nobody dared to close.

Adapter in the JDK

The JDK is full of adapters with a name and surname; recognizing them cements the pattern better than any invented example:

  • InputStreamReader: Java's most cited adapter. Target: Reader (the world of characters). Adaptee: InputStream (the world of bytes). The constructor new InputStreamReader(inputStream, UTF_8) is literally "I wrap an adaptee and present it as the target", with the charset as the translation rule. Its mirror image is OutputStreamWriter.
  • Arrays.asList(...): presents an array (adaptee) behind the List interface (target). The translation is so direct it doesn't even copy the data.
  • Collections.enumeration(...) / Collections.list(...): a pair of adapters between the old Enumeration and the modern collections — a museum-piece two-way adapter, born of exactly the kind of migration described in the previous section.

Whenever a javadoc says "bridge from X to Y" or you see a constructor that takes "the other" interface, you are almost always looking at an Adapter (even though the javadoc of InputStreamReader says bridge, the Bridge pattern is something else, as we will see in the next lesson).

When to use it and when not to

Use it when:

  • You need to use an existing class —third-party, legacy, generated— and its interface doesn't match the one your system expects.
  • You want to shield your clients from the details (units, codes, names, errors) of an external dependency: the adapter doubles as a miniature anti-corruption layer.
  • You are migrating between two systems that must coexist (two-way, temporary).

Don't use it when:

  • Both classes are yours and you can align their interfaces directly: an adapter between two pieces you own is usually a refactoring you didn't dare to do.
  • What you want is not to translate an interface but to add behavior to it (that's Decorator) or to collapse many calls into one (that's Facade).
  • The mismatch is so deep that the "adapter" needs to reimplement half the logic: that is no longer translating, it's building something else, and it deserves a design of its own.

Relationship to other patterns (mentions only): the factories from module 2 are the natural place to decide whether to hand out the native object or the adapted one; Decorator and Proxy share the wrapper mechanics but keep the interface instead of changing it; Bridge resembles it on paper but is designed before the pieces exist, not after; and Facade also mediates with foreign code, but by simplifying an entire subsystem rather than translating one class. The full head-to-head of the four wrappers arrives in the comparison lesson.

Common Mistakes and Tips

  • An adapter with business logic. If PayPalAdapter starts deciding discounts or validating carts, it has stopped being a customs office and become a smuggler. Rule: an adapter contains translation only (types, units, names, errors). Everything else belongs to its own layer.
  • Incomplete translation. Adapting the happy paths and letting the adaptee's exceptions pass through raw. The client ends up with catch (PayPalConnectionException e), and the coupling you wanted to avoid has come back through the back door.
  • Adapting what is yours. Putting an adapter on your own class to avoid refactoring it is piling up debt with interest: now there are two interfaces and one translator to maintain.
  • The adaptee escapes. Returning SDK types (ExternalVenue, int codes) from some secondary method of the adapter. The border must be watertight: everything that crosses gets translated.
  • One adapter to rule them all. A UniversalPaymentAdapter that adapts three different SDKs with internal ifs. Each adaptee deserves its own adapter; uniformity is already provided by the target.
  • Tip: name adapters transparently (PayPalAdapter, with an Adapter suffix). Unlike other patterns, announcing the pattern in the name helps here: whoever reads it will know that only translation lives inside and where to find each third party's customs office.

Exercises

Exercise 1: adapting a new provider's SMS service

PideYa signs up a cheaper SMS provider whose SDK (untouchable) is:

public class CheapSmsService {
    /** Returns true if the message was queued. The number must come WITHOUT the international prefix. */
    public boolean queueMessage(String phoneWithoutPrefix, String body) { /* ... */ }
}

Our interface from module 2 is Notifier with void send(String recipient, String message), where recipient is a phone number with a prefix (+34600111222), and a delivery failure is reported by throwing NotificationException. Write CheapSmsAdapter.

Exercise 2: spotting the defective adapter

What two design problems does this restaurant aggregator adapter have?

public class AggregatorAdapter implements RestaurantProvider {
    private final ExternalRestaurantApi api = new ExternalRestaurantApi();

    @Override
    public List<Restaurant> findByCity(City city) {
        List<ExternalVenue> venues = api.fetchVenues(city.getCode());
        List<Restaurant> result = new ArrayList<>();
        for (ExternalVenue v : venues) {
            if (v.getRating() >= 4.0) {          // "we only want good restaurants"
                result.add(translate(v));
            }
        }
        return result;
    }
}

Exercise 3: object or class?

The PayPalLegacyClient SDK has a protected void refreshToken() method that should be invoked before each charge, and the class is not final. A colleague proposes the class Adapter "because that way we can call refreshToken()". Evaluate the proposal: is that reason enough? What downsides does it accept in exchange? Is there an alternative that keeps the object variant?

Solutions

Solution 1:

public class CheapSmsAdapter implements Notifier {

    private final CheapSmsService service;

    public CheapSmsAdapter(CheapSmsService service) {
        this.service = service;
    }

    @Override
    public void send(String recipient, String message) {
        // Input translation: strip the international prefix (+34 -> "")
        String withoutPrefix = recipient.replaceFirst("^\\+\\d{1,3}", "");

        // Delegation + result translation: boolean -> an exception from our vocabulary
        boolean queued = service.queueMessage(withoutPrefix, message);
        if (!queued) {
            throw new NotificationException("The SMS provider rejected the message to " + recipient);
        }
    }
}

Both translations (phone format and error model) stay locked inside the adapter; for module 2's NotifierRegistry, registering this channel is one more line: registry.register("cheap-sms", () -> new CheapSmsAdapter(new CheapSmsService()));.

Solution 2: (1) It builds its adaptee with new inside (new ExternalRestaurantApi()): impossible to test without a network, and creation out of place; it must be injected. (2) Smuggled business logic: the rating >= 4.0 filter is a catalog rule, not a translation; tomorrow the business will change it and nobody will look inside an adapter. The filtering belongs to the catalog service that uses the provider. (Translating rating into our model would indeed be the adapter's job; deciding with it is not.)

Solution 3: it is not sufficient reason on its own, but it is a legitimate one: accessing protected members is one of the few real motives for the class Adapter. In exchange it accepts: the SDK's public interface is exposed on the adapter (anyone can call makePayment without translation), the relationship is welded to that one concrete class, and Java's single inheritance card is spent. An alternative that keeps the object variant: a minimal subclass of the SDK (PayPalClientWithRefresh extends PayPalLegacyClient) that only exposes chargeWithFreshToken() combining refreshToken() + makePayment(...), with the normal object adapter wrapping it. Inheritance stays confined to a technical detail and the border remains watertight.

Conclusion

Adapter resolves the mismatch between the interface your system expects and the one an untouchable piece offers: a translator class that implements the target, contains the adaptee, and converts calls, data, and errors at a single point. You know its two variants (object by default, class for rare cases), its two-way version for migrations, and its museum specimens in the JDK. And you have its purity criterion: inside an adapter lives translation only.

Notice that Adapter always arrives late and through the emergency room: the pieces already existed and didn't fit. The next question is more ambitious: what if we could design ahead of time so that two dimensions bound to vary —what gets communicated and through which channel— never get welded together in the first place? In PideYa that tension is already showing: notification types on one side, delivery channels on the other, and a hierarchy threatening to multiply. That up-front design bears the name of a civil engineering work: see you in Bridge.

© Copyright 2026. All rights reserved