The ticket says “do not open a Stripe connection until the first charge, refuse intern tokens, and stop re-hitting the network when support retries the same capture.” You open StripeGateway and start stuffing flags into charge: a nullable client created on first use, an if on the caller role, a field that remembers the last PaymentResult. It works for this vendor. PayPal gets a copy of the same three ifs next month, and OrderProcessor still cannot tell a declined charge from “we never called the SDK because the token was wrong.”

That is Proxy’s entire complaint. From the Design Patterns Roadmap: a stand-in implements the same interface as a real object you cannot (or should not) touch directly, and decides whether that object runs.

This post stays on the shared Order / PaymentGateway / DiscountPolicy / OrderProcessor lab. We do not re-lecture the three families. We wrap the gateway because the real one is remote, expensive to build, or restricted — not because wrapping looks designed.

The gateway that always touches the wire

After Adapter and Decorator, checkout already speaks PaymentGateway.charge. The honest Stripe class still does this on every call, including the support retry and the intern’s accidental double-submit:

public final class StripeGateway implements PaymentGateway {

    private final StripeClient client;

    public StripeGateway(StripeClient client) {
        this.client = client;
        client.connect();
    }

    @Override
    public PaymentResult charge(Order order, BigDecimal amount) {
        return PaymentResult.approved(client.charge(order.id(), amount));
    }
}

connect() talks to Stripe during new. charge talks again. Now price the three tickets as edits inside this class — or inside process():

Lazy-connect on first charge     -> nullable client, null checks in StripeGateway
Refuse intern / read-only tokens -> role if inside charge, or inside process()
Same capture retried by support  -> last-result field welded to this vendor
Add PayPal                       -> copy the three ifs into PayPalGateway

Four costs, and none of them are about capturing a card. Access policy has become a private feature of each vendor class.

Note: The problem is not that Stripe is slow. Remote things are slow. The problem is letting whether the remote object is allowed to run sit inside the type whose job is “talk to Stripe,” or inside the type whose job is “charge, then mark paid.”

What Proxy actually is

Three parts, one promise:

PartIn this labJob
SubjectPaymentGatewayThe interface callers already compiled against.
Real subjectStripeGatewayThe object that actually hits the network.
ProxyGatewayProxySame interface. Holds (or creates) the real one. Controls access.

Callers keep calling charge. Only the proxy decides whether the real gateway is built, authorized, or skipped. If OrderProcessor starts checking tokens or holding a StripeClient supplier, you did not proxy; you inlined the stand-in.

You do not need a class named Proxy. You need the verb the caller already uses, implemented by a type whose job is access — lazy init, a cache of the last result, an auth check — not extra product behavior:

public interface PaymentGateway {

    PaymentResult charge(Order order, BigDecimal amount);
}

That is the subject. The real gateway already implements it. The stand-in implements it too.

The real gateway stays behind the stand-in

GatewayProxy implements PaymentGateway and holds a factory for the real one, an authorizer, and the last result. Construction of StripeGateway (and client.connect()) waits until a charge is actually allowed:

public final class GatewayProxy implements PaymentGateway {

    private final Supplier<PaymentGateway> realFactory;
    private final ChargeAuthorizer authorizer;
    private PaymentGateway real;
    private CachedCharge last;

    public GatewayProxy(Supplier<PaymentGateway> realFactory, ChargeAuthorizer authorizer) {
        this.realFactory = realFactory;
        this.authorizer = authorizer;
    }

    @Override
    public PaymentResult charge(Order order, BigDecimal amount) {
        authorizer.assertCanCharge(order);
        if (last != null && last.sameAs(order.id(), amount)) {
            return last.result();
        }
        PaymentResult result = real().charge(order, amount);
        last = new CachedCharge(order.id(), amount, result);
        return result;
    }

    private PaymentGateway real() {
        if (real == null) {
            real = realFactory.get();
        }
        return real;
    }
}

CachedCharge is a tiny record of id, amount, and PaymentResult. It is not a product cache of every historical capture. It is “do not touch the remote object for the call we just made.” ChargeAuthorizer is whatever your edge already knows — a role on the request, an API key scope — and it does not import Stripe.

Wiring stays at the edge, next to secrets. OrderProcessor still receives a PaymentGateway:

PaymentGateway gateway = new GatewayProxy(
        () -> new StripeGateway(new StripeClient(env("STRIPE_KEY"))),
        ChargeAuthorizer.from(requestToken()));
OrderProcessor processor = new OrderProcessor(gateway, discount, orders);

The discount policy never hears about tokens or connect(). It still turns an Order into a payable amount. The processor still charges and marks paid.

Note: Keep the proxy dumb about checkout. The moment GatewayProxy applies DiscountPolicy, writes SQL, or retries on "unavailable", you have rebuilt the god service in the stand-in. Authorize. Maybe construct. Maybe reuse the last result. Forward. Stop.

What the diff looks like now

Same three tickets, both designs:

Before — lazy / auth / last-result inside StripeGateway
  M StripeGateway.java         connect(), role if, last field
  M PayPalGateway.java         the same three ifs, copied
  M OrderProcessorTest.java    must construct a live client or skip

After — one stand-in in front of any real gateway
  A GatewayProxy.java          access only; existing callers untouched
  A GatewayProxyTest.java      auth, lazy, cache — fake inner, no network
  M PaymentConfig.java         wrap the vendor at the edge

One added file plus one line of wrapping. StripeGateway goes back to talking to Stripe. OrderProcessor stays closed against “the SDK is expensive or restricted” and still fully open when the workflow changes — a refund step, a second attempt. Those belong in process. Whether intern tokens reach Stripe does not.

Proving access without opening a socket

The seam pays a second dividend: you can test the stand-in with a fake inner gateway, and you can test the processor with a fake PaymentGateway that is not a proxy. Those are two tests.

final class RecordingGateway implements PaymentGateway {

    int charges;
    PaymentResult next = PaymentResult.approved("ref-1");

    @Override
    public PaymentResult charge(Order order, BigDecimal amount) {
        charges++;
        return next;
    }
}

A proxy test asserts that a denied token never constructs the real gateway, and that a repeated charge does not increment charges:

@Test
void deniedTokenDoesNotConstructRealGateway() {
    AtomicInteger built = new AtomicInteger();
    GatewayProxy proxy = new GatewayProxy(
            () -> {
                built.incrementAndGet();
                return new RecordingGateway();
            },
            ChargeAuthorizer.denyAll());
    Order order = new Order("o-1", "a@b.com", List.of(), new BigDecimal("40.00"));

    assertThrows(ForbiddenChargeException.class,
            () -> proxy.charge(order, new BigDecimal("40.00")));
    assertEquals(0, built.get());
}

@Test
void secondIdenticalChargeDoesNotHitInner() {
    RecordingGateway inner = new RecordingGateway();
    GatewayProxy proxy = new GatewayProxy(() -> inner, ChargeAuthorizer.allowAll());
    Order order = new Order("o-1", "a@b.com", List.of(), new BigDecimal("40.00"));

    proxy.charge(order, new BigDecimal("40.00"));
    proxy.charge(order, new BigDecimal("40.00"));

    assertEquals(1, inner.charges);
}

The processor test still injects FakePaymentGateway.alwaysDeclined(...). It should not construct a GatewayProxy unless you are testing the wrap. If a declined-charge test needs a token check, you have mixed two reasons to change.

Proxy is not Decorator

Both implement PaymentGateway and hold another PaymentGateway. Review comments that say “this is a Decorator” after you introduced GatewayProxy are naming the wrap, not the job.

ProxyDecorator
JobControl access to the real objectAdd behavior you asked for
Typical workLazy init, auth, last-result / remote stand-inLog, retry, metrics, a product cache
May skip the innerYes — that is the pointForwards; extras run around the call
Client intentTalk to the subject as if it were the real oneTalk to the subject plus the extra

A wrapper that only logs is Decorator. It does not decide whether Stripe exists. It adds a line you wanted:

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.info("charge {} approved={}", order.id(), result.approved());
        return result;
    }
}

You can decorate a proxy: new LoggingGateway(new GatewayProxy(factory, authorizer)). Logging still always forwards. The proxy still decides whether factory.get() runs. If the wrapper only adds behavior and always calls through, it is not a Proxy. Decorator already owns retry and the webhook capture cache. Reuse that stack for extras. Use a proxy when the real object is the thing you are protecting or postponing.

The Decorator CachingGateway keyed every approval for webhook replay — a product feature. This proxy’s last field exists so a repeated identical charge does not touch the remote object. Same wrap shape. Different reason to skip the inner.

When Proxy is the wrong move

Skip the stand-in when:

  • The wrapper only logs, retries, or meters. That is Decorator. Name it that. Do not call LoggingGateway a proxy in a review because the Gang of Four also wrapped things.
  • You own the real object and it is cheap and local. If InMemoryGateway is a HashMap, there is nothing to postpone or protect. Inject it. Stop.
  • The “access control” belongs on the HTTP edge. A servlet filter that rejects intern tokens before checkout runs is not a PaymentGateway proxy. Put role checks next to the token unless every PaymentGateway caller — including batch jobs — must share the same rule.
  • You are hiding a subsystem. Stripe + webhooks + refunds + a report export behind CheckoutService is Facade, not a proxy of PaymentGateway.

The healthy trigger is a real object you cannot touch freely — remote SDK, slow connect, permissioned charge — behind an interface callers already use. A logging wrapper around a gateway you already constructed is not that trigger.

Proxy also is not Adapter. Adapter translates a foreign type (StripeClient) into PaymentGateway. Proxy assumes the real object already is a PaymentGateway and sits in front of it. Do the vendor translation once. Proxy the result if access needs a stand-in.

Cheat sheet

Subject      PaymentGateway.charge    callers already compiled against this
Real         StripeGateway            actually opens the socket
Proxy        GatewayProxy             same interface; lazy, auth, last result
Callers      OrderProcessor           still says gateway.charge(order, payable)

Trigger to apply: the real object is remote, expensive to build, or restricted
Trigger to stop:  the wrap only logs / retries, or the inner is cheap and local
Scoreboard:       access policy = one proxy file; vendor classes stay dumb
Not Decorator:    may skip / delay the inner vs always forward and add extras

Do:

  • Name the proxy after the subject role (GatewayProxy), not StripeGatewayProxyImpl unless the access rule is vendor-specific.
  • Put connect(), token checks, and last-result reuse in the stand-in — not in process().
  • Test auth and lazy init with a fake inner; test process with a fake subject.
  • Decorate a proxy if you still need logging. That is a second wrap, a second job.

Don’t:

  • Call a logging wrapper a Proxy because both types have an inner field.
  • Construct the real SDK in the proxy’s constructor “just in case” — that deletes lazy init.
  • Put discount math or SQL inside the proxy because it is a convenient singleton.
  • Switch on instanceof GatewayProxy in OrderProcessor to skip fraud checks.

Wrap-up

Proxy is a class that implements the interface your callers already use and holds the real object they must not touch directly. OrderProcessor kept charge(Order, BigDecimal) because that was never Stripe’s connect-and-authorize problem. GatewayProxy is the only file that delays new StripeGateway, rejects intern tokens, and reuses the last result, so the next vendor is another real subject behind the same stand-in — not another pile of ifs in the paid-order path.

If the next pain is a tree of SKUs and categories that all need one subtotal(), that is Composite — same interface for the leaf and the group.

Next optional step in the series Treat a category of SKUs as one payable node. Composite: Treat Trees of Objects as One