The ticket says “add PayPal as a checkout option.” OrderProcessor already speaks PaymentGateway.charge(Order, BigDecimal). You open the PayPal SDK and find createAndCapture(BigDecimal, String, String) — amount, currency, email, no Order, a capture object instead of PaymentResult. The fastest path is to teach process the vendor’s names. You also just made the paid-order workflow a co-owner of every PayPal release, and the next provider will edit the same method again.
That is Adapter’s entire complaint. From the Design Patterns Roadmap: an existing client should keep its types; the vendor’s types get translated, not leaked.
This post stays on one mismatch. The three families and the shared Order / PaymentGateway / DiscountPolicy lab live on the hub. Here we only care about a client you will not rewrite and a SDK you cannot.
The processor already has a verb
After Strategy, checkout already splits pricing, charging, and persistence — and none of them mention a vendor:
public class OrderProcessor {
private final PaymentGateway gateway;
private final DiscountPolicy discount;
private final OrderRepository orders;
public OrderProcessor(
PaymentGateway gateway, DiscountPolicy discount, OrderRepository orders) {
this.gateway = gateway;
this.discount = discount;
this.orders = orders;
}
public void process(Order order) {
BigDecimal payable = discount.payable(order);
PaymentResult result = gateway.charge(order, payable);
if (!result.approved()) {
throw new PaymentDeclinedException(order.id(), result.failureReason());
}
orders.markPaid(order.id(), result.reference());
}
}
The interface the processor already depends on is the target. PayPal did not read that interface:
public class PayPalClient {
public PayPalClient(String clientId, String secret) {
// vendor SDK wiring
}
public PayPalCapture createAndCapture(
BigDecimal amount, String currency, String payerEmail) {
// vendor HTTP — returns a capture with id / status / reason
return PayPalCapture.completed("CAP-9f3");
}
}
Now price the “just call it from process” shortcut:
Teach process() PayPal’s method -> OrderProcessor imports PayPalClient
Add currency as a process() argument -> every caller, every test, every fake
Swap Stripe in a unit test -> construct a real PayPalClient or another if
PayPal adds a required invoice id -> edit the paid-order state machine again
Four costs, and none of them are about capturing a payment. The vendor’s method signature has become the processor’s public API.
Note: The problem is not that PayPal is different. Vendors are always different. The problem is letting that difference sit inside a class whose job is “charge, then mark paid.” Translation belongs next to the vendor.
What Adapter actually is
Three parts, one promise:
| Part | In this lab | Job |
|---|---|---|
| Target | PaymentGateway | The interface callers already compiled against. |
| Adaptee | PayPalClient | The type you cannot (or will not) change. |
| Adapter | PayPalGateway | Implements the target, holds the adaptee, translates. |
The adapter is the only type allowed to know both shapes. OrderProcessor keeps calling charge. PayPalClient keeps exposing createAndCapture. If process starts naming PayPalCapture, you did not adapt; you inlined the vendor.
Decorator uses the same wrap shape and a different job. Hold that contrast for a section — the code is easier to see once the PayPal translation exists.
You do not need a class named Adapter. You need the verb the caller already understands, implemented by a type whose constructor takes the thing that does not speak it:
public interface PaymentGateway {
PaymentResult charge(Order order, BigDecimal amount);
}
That is the target. Everything else is a translation.
The vendor stays behind the target
PayPalGateway implements PaymentGateway and holds a PayPalClient. Currency, email, and capture status live here because they are PayPal’s problem, not checkout’s:
public final class PayPalGateway implements PaymentGateway {
private final PayPalClient client;
private final String currency;
public PayPalGateway(PayPalClient client, String currency) {
this.client = client;
this.currency = currency;
}
@Override
public PaymentResult charge(Order order, BigDecimal amount) {
PayPalCapture capture =
client.createAndCapture(amount, currency, order.customerEmail());
if (capture.isCompleted()) {
return PaymentResult.approved(capture.id());
}
return PaymentResult.declined(capture.statusReason());
}
}
Stripe already matched charge more closely, so its wrapper is thinner. Adapter is not equal thickness; it is the same method behind every SDK:
public final class StripeGateway implements PaymentGateway {
private final StripeClient client;
public StripeGateway(StripeClient client) {
this.client = client;
}
@Override
public PaymentResult charge(Order order, BigDecimal amount) {
try {
return PaymentResult.approved(client.charge(order.id(), amount));
} catch (StripeCardException e) {
return PaymentResult.declined(e.getDeclineCode());
}
}
}
OrderProcessor does not change. Wiring happens at the edge — a config class, main, or a Spring @Bean — the same place secrets already live:
PaymentGateway paypal = new PayPalGateway(
new PayPalClient(env("PAYPAL_ID"), env("PAYPAL_SECRET")), "USD");
OrderProcessor processor = new OrderProcessor(paypal, discount, orders);
The discount policy never hears about PayPal. It still turns an Order into a payable amount. The adapter consumes that amount and the order’s email; it does not re-implement pricing.
Note: Keep the adapter dumb. The moment PayPalGateway starts applying DiscountPolicy, writing SQL, or asking “if the capture is pending, skip fraud checks,” you have rebuilt the god service next to the SDK. Translate. Return. Stop.
What the diff looks like now
Same feature request, both designs:
Before — add PayPal inside process()
M OrderProcessor.java vendor types, currency argument, new branches
M OrderProcessorTest.java existing tests construct a PayPalClient or skip
After — add PayPal behind PaymentGateway
A PayPalGateway.java translation only, existing callers untouched
A PayPalGatewayTest.java maps capture -> PaymentResult, no repository
M PaymentConfig.java one line of wiring
One added file plus one line of registration. OrderProcessor is closed against “a new SDK appears” and still fully open to editing when the workflow changes — a refund step, a second charge attempt. Those belong in process. Mapping PayPalCapture to PaymentResult does not.
Proving the translation without charging a card
The seam pays a second dividend: you can test the mapping with a fake client, and you can test the processor with a fake gateway. Those are two tests, two reasons to change.
final class RecordingPayPalClient extends PayPalClient {
BigDecimal lastAmount;
String lastCurrency;
String lastEmail;
PayPalCapture next = PayPalCapture.completed("CAP-test");
RecordingPayPalClient() {
super("id", "secret");
}
@Override
public PayPalCapture createAndCapture(
BigDecimal amount, String currency, String payerEmail) {
lastAmount = amount;
lastCurrency = currency;
lastEmail = payerEmail;
return next;
}
}
The adapter test asserts that Order and payable become the vendor’s three arguments, and that a failed capture becomes declined — no OrderProcessor, no repository:
@Test
void declinedCaptureBecomesDeclinedResult() {
RecordingPayPalClient client = new RecordingPayPalClient();
client.next = PayPalCapture.failed("INSTRUMENT_DECLINED");
PayPalGateway gateway = new PayPalGateway(client, "USD");
Order order = new Order("o-1", "a@b.com", List.of(), new BigDecimal("40.00"));
PaymentResult result = gateway.charge(order, new BigDecimal("40.00"));
assertFalse(result.approved());
assertEquals("INSTRUMENT_DECLINED", result.failureReason());
assertEquals("a@b.com", client.lastEmail);
}
The processor test still uses a FakePaymentGateway from the Strategy post. It should not construct a PayPalClient. If a declined-charge test needs a real capture object, you have mixed two reasons to change.
Adapter is not Decorator
Both wrap an object. Review comments that say “this is a Decorator” after you introduced PayPalGateway are naming the wrap, not the job.
| Adapter | Decorator | |
|---|---|---|
| In | A type callers do not speak (PayPalClient) | The same interface the caller already uses |
| Out | The target interface (PaymentGateway) | The same interface again |
| Job | Translate a mismatch | Add behavior (log, retry, cache) |
| How many wraps | One per foreign type | Often a stack |
A logging wrapper around an already compatible gateway is Decorator — PaymentGateway in, PaymentGateway out, extra work in the middle:
public final class LoggingGateway implements PaymentGateway {
private final PaymentGateway inner;
public LoggingGateway(PaymentGateway inner) {
this.inner = inner;
}
@Override
public PaymentResult charge(Order order, BigDecimal amount) {
PaymentResult result = inner.charge(order, amount);
// log reference / decline — no signature change
return result;
}
}
You can decorate an adapter: new LoggingGateway(new PayPalGateway(client, "USD")). You cannot invert that and call it Adapter. If the inner object already implements the caller’s interface, you are not adapting. Decorator gets its own post later in this catalog; the test until then is “did the method signatures change, or only the behavior?”
When Adapter is the wrong move
Skip the wrapper when:
- You own both sides. If
PayPalClientis your code, changecreateAndCapturetocharge(Order, BigDecimal)— or extract a method that already matches. An adapter over a type you can edit is two names for one object. - There is one call site and it is at the edge. A checkout controller that maps four fields into the SDK once, with no
OrderProcessorbelow it, does not need a type. A method is enough until a second caller wants the same translation. - You are about to change the target to match the vendor. Adding
currencyandpayerEmailtoPaymentGatewaybecause PayPal needs them makes every gateway speak PayPal. Translate inward, not outward. - The “mismatch” is a parameter, not a shape.
StripeClient.charge(orderId, amount)vscharge(order, amount)is a one-line unwrap. Do not inventStripeAdapterfororder.id().
The healthy trigger is a type you cannot change, behind an interface you will not change. A third-party SDK, a generated SOAP client, a legacy PaymentServiceBean with a 12-argument method — those earn an adapter. A class you wrote last Tuesday does not.
Adapter also is not Facade. Facade hides a subsystem (several types, one simpler front). Adapter translates one foreign type into one expected type. If you are wrapping Stripe + webhooks + refunds + a report export behind CheckoutService, that is a different post.
Cheat sheet
Target PaymentGateway.charge the verb callers already compiled against
Adaptee PayPalClient foreign shape; you do not edit it
Adapter PayPalGateway implements target, holds adaptee, translates
Callers OrderProcessor still says gateway.charge(order, payable)
Trigger to apply: a vendor/legacy type does not match an interface you own
Trigger to stop: you own both types, or one call site at the edge
Scoreboard: new SDK = new adapter file; OrderProcessor tests do not change
Not Decorator: signatures change (translate) vs behavior wraps (same type)
Do:
- Name the adapter after the vendor plus the target role (
PayPalGateway), notPayPalClientAdapterImpl. - Put currency, capture status, and SDK exceptions inside the adapter.
- Test the mapping with a fake adaptee; test
processwith a fake target. - Decorate an adapter if you need logging or retry — that is a second wrap, a second job.
Don’t:
- Edit
OrderProcessorto callcreateAndCapturebecause “it’s just one provider.” - Change
PaymentGatewayto look like the SDK you just imported. - Adapt a class you can change instead of fixing its method.
- Confuse a one-line
order.id()unwrap with a pattern.
Wrap-up
Adapter is a class that implements the interface your callers already use and holds the type that does not. OrderProcessor kept charge(Order, BigDecimal) because that was never PayPal’s verb to rename. PayPalGateway is the only file that knows createAndCapture, capture status, and currency, so the next SDK is another wrapper — not another branch in the paid-order path.
If the next pain is a telescoping constructor of optional checkout flags, that is Builder — assemble the command, then charge.