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:
| Part | In this lab | Job |
|---|---|---|
| Subject | PaymentGateway | The interface callers already compiled against. |
| Real subject | StripeGateway | The object that actually hits the network. |
| Proxy | GatewayProxy | Same 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.
| Proxy | Decorator | |
|---|---|---|
| Job | Control access to the real object | Add behavior you asked for |
| Typical work | Lazy init, auth, last-result / remote stand-in | Log, retry, metrics, a product cache |
| May skip the inner | Yes — that is the point | Forwards; extras run around the call |
| Client intent | Talk to the subject as if it were the real one | Talk 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
LoggingGatewaya proxy in a review because the Gang of Four also wrapped things. - You own the real object and it is cheap and local. If
InMemoryGatewayis aHashMap, 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
PaymentGatewayproxy. Put role checks next to the token unless everyPaymentGatewaycaller — including batch jobs — must share the same rule. - You are hiding a subsystem. Stripe + webhooks + refunds + a report export behind
CheckoutServiceis Facade, not a proxy ofPaymentGateway.
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), notStripeGatewayProxyImplunless the access rule is vendor-specific. - Put
connect(), token checks, and last-result reuse in the stand-in — not inprocess(). - Test auth and lazy init with a fake inner; test
processwith 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
innerfield. - 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 GatewayProxyinOrderProcessorto 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.