The ticket says “retry Stripe twice, log every charge, and cache identical captures for the webhook replay.” You look at StripeGateway and reach for a subclass. RetryingStripeGateway already exists from last quarter. Logging was going to be LoggingStripeGateway. Cache means AuditedRetryingCachedStripeGateway, and PayPal is launching next month so you can look forward to the same pile with PayPal in the middle of the name.
That is Decorator’s entire complaint. From the Design Patterns Roadmap: a decorator implements the same interface as the object it wraps and adds behavior before or after forwarding the call.
This post is only about stacking extras onto PaymentGateway without a type per combination. Strategy swapped which discount runs. Observer fanned out who reacts. The shared lab and the three families stay on that hub. Here we care about one inheritance tree that multiplies every time ops wants another wrapper.
The subclass that grows a new name per combo
Here is the honest first attempt after retry and logging have both been demanded. It compiles. It will not survive cache, and it will not survive a second vendor:
public final class AuditedRetryingStripeGateway implements PaymentGateway {
private final StripeClient client;
private final int attempts;
public AuditedRetryingStripeGateway(StripeClient client, int attempts) {
this.client = client;
this.attempts = attempts;
}
@Override
public PaymentResult charge(Order order, BigDecimal amount) {
log.info("charging {} amount {}", order.id(), amount);
PaymentResult last = PaymentResult.declined("not_attempted");
for (int i = 0; i < attempts; i++) {
last = chargeOnce(order, amount);
if (last.approved() || !"unavailable".equals(last.failureReason())) {
break;
}
}
log.info("charge result {} ref {}", last.approved(), last.reference());
return last;
}
}
Now price the cache ticket — and PayPal:
Add a capture cache -> new subclass, or more methods in this one
Add the same extras to PayPal -> copy the class, rename Stripe -> PayPal
Turn retry off in admin rebill -> another subclass, or a boolean that is a hierarchy in disguise
Test logging without Stripe -> you cannot; the vendor is the superclass story
Four costs, and none of them are about capturing a card. Every combination of extras has become its own type, welded to one vendor.
Note: The problem is not inheritance. StripeGateway wrapping a StripeClient is the right adapter-shaped class. The problem is using subclasses to mix in retry, logging, and cache — behaviors that should apply to any PaymentGateway, including the fake one in tests.
What Decorator actually is
Two parts, one promise:
| Part | Job |
|---|---|
| Component | The interface callers already use (PaymentGateway). |
| Decorator | Implements that same interface. Holds one inner component. Forwards. |
The wrapper is a PaymentGateway. Callers cannot tell they are not talking to Stripe. That substitutability is the whole pattern. If RetryingGateway.charge returns a different type, or skips PaymentResult to throw, you have not decorated; you have changed the contract.
You do not need a class named Decorator. You need the same verb the inner object already has — here, charge — plus a constructor that takes the thing you are wrapping:
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) {
log.info("charging {} amount {}", order.id(), amount);
PaymentResult result = inner.charge(order, amount);
log.info("charge result {} ref {}", result.approved(), result.reference());
return result;
}
}
That is one extra. Retry is another class, not a subclass of this one. Cache is a third. Each knows one job.
One wrapper per extra, any inner gateway
Retry no longer mentions Stripe. It mentions PaymentGateway and a budget. Use a failure reason you already return from the gateway (here "unavailable") as the retry signal — a boolean on PaymentResult is the same idea:
public final class RetryingGateway implements PaymentGateway {
private final PaymentGateway inner;
private final int attempts;
public RetryingGateway(PaymentGateway inner, int attempts) {
this.inner = inner;
this.attempts = attempts;
}
@Override
public PaymentResult charge(Order order, BigDecimal amount) {
PaymentResult last = PaymentResult.declined("not_attempted");
for (int i = 0; i < attempts; i++) {
last = inner.charge(order, amount);
if (last.approved() || !"unavailable".equals(last.failureReason())) {
return last;
}
}
return last;
}
}
Cache keys on something stable — order id plus amount — and still forwards misses to the inner gateway. Webhook replays stop hitting the vendor. A first-time checkout still does:
public final class CachingGateway implements PaymentGateway {
private final PaymentGateway inner;
private final CaptureCache cache;
public CachingGateway(PaymentGateway inner, CaptureCache cache) {
this.inner = inner;
this.cache = cache;
}
@Override
public PaymentResult charge(Order order, BigDecimal amount) {
return cache.get(order.id(), amount)
.orElseGet(() -> {
PaymentResult result = inner.charge(order, amount);
if (result.approved()) {
cache.put(order.id(), amount, result);
}
return result;
});
}
}
StripeGateway and PayPalGateway go back to doing one thing: talking to a vendor. They do not log, retry, or cache. The extras wrap them at the edge, in whatever order the product actually needs:
PaymentGateway stripe = new StripeGateway(new StripeClient(env("STRIPE_KEY")));
PaymentGateway gateway = new LoggingGateway(
new RetryingGateway(
new CachingGateway(stripe, captureCache), 3));
Admin rebill that must not retry is the same StripeGateway without RetryingGateway in the stack. PayPal is new LoggingGateway(new PayPalGateway(client)) — logging reused, no LoggingPayPalGateway. The combination is an object graph, not a class name.
Note: Order matters and is part of the design. LoggingGateway outside RetryingGateway logs one attempt-budget. Inside, it logs every try. CachingGateway outside retry will cache a decline and never retry; usually you cache only approvals inside the retry loop, which is what CachingGateway above does by wrapping the vendor and being wrapped by retry. Draw the stack once. Do not leave it as “whatever the constructor nesting happened to be.”
Strategy swaps; Decorator wraps
The two patterns sit on the same lab and are easy to mash together. They are not the same seam.
| Strategy | Decorator | |
|---|---|---|
| Question | Which algorithm runs? | What extra runs around one that already exists? |
| Lab type | DiscountPolicy — PercentOff vs FlatOff | PaymentGateway — Stripe plus retry plus log |
| Caller sees | One policy object | One gateway object (the outermost wrapper) |
| Adding a variant | A new implementation of the interface | A new wrapper around any existing implementation |
Strategy replaced a growing if in OrderProcessor with discount.payable(order). You pick one formula. StackedDiscount in that post was a cousin of Decorator: it implemented DiscountPolicy and held two policies. Use that shape when independent rules compose. Use Strategy alone when you swap one formula for another.
OrderProcessor does not change for Decorator. It already depends on PaymentGateway. You hand it the outermost wrapper the same way you used to hand it StripeGateway:
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 processor cannot tell it is talking to three wrappers and a vendor. That is the point. If process starts with if (gateway instanceof RetryingGateway) to skip a fraud check, the wrap was a lie. Either extend PaymentGateway with an honest method or keep that branch in a place that is allowed to know.
What the diff looks like now
Same feature request, both designs:
Before — add cache + keep retry + log, then PayPal
A AuditedRetryingCachedStripeGateway.java one more combinatoric type
A AuditedRetryingCachedPayPalGateway.java copy, rename the vendor
M OrderProcessor wiring pick the right mega-class
After — add cache, then PayPal
A CachingGateway.java one wrapper, any inner gateway
M PaymentConfig.java nest CachingGateway in the graph
A PayPalGateway.java vendor only; reuse the wrappers
One added wrapper plus a nesting change. StripeGateway is closed against “ops wants another extra.” It remains open to editing when the vendor API changes. Retry tests do not construct a StripeClient. PayPal tests do not re-prove logging.
Proving it with a fake inner gateway
The seam pays a second dividend immediately: each wrapper is testable without a vendor, and OrderProcessor is still testable with a fake that has no wrappers at all.
class FlakyGateway implements PaymentGateway {
private int remainingFailures;
private final PaymentResult success;
FlakyGateway(int failuresThen, PaymentResult success) {
this.remainingFailures = failuresThen;
this.success = success;
}
@Override
public PaymentResult charge(Order order, BigDecimal amount) {
if (remainingFailures > 0) {
remainingFailures--;
return PaymentResult.declined("unavailable");
}
return success;
}
}
Retry can be asserted against that fake — three attempts, two transients, then success — with no Stripe key:
@Test
void retriesTransientFailuresThenSucceeds() {
PaymentGateway inner = new FlakyGateway(2, PaymentResult.approved("ref-1"));
PaymentGateway gateway = new RetryingGateway(inner, 3);
Order order = new Order("o-1", "a@b.com", List.of(), new BigDecimal("10.00"));
PaymentResult result = gateway.charge(order, new BigDecimal("10.00"));
assertTrue(result.approved());
assertEquals("ref-1", result.reference());
}
A processor test still injects FakePaymentGateway.alwaysDeclined(...) directly. Do not wrap a fake in LoggingGateway unless you are testing the stack. If a charge test needs real retry math, you have mixed two reasons to change.
When Decorator is the wrong move
Skip the wrapper when:
- There is one extra and no second on the roadmap. A
log.infonext togateway.chargein the composition root — or insideStripeGatewayif only Stripe will ever be logged — is a line of code.LoggingGatewayalone, with no retry and no cache planned, is the cargo-cult the hub already names. - The extra is a parameter of the vendor, not a wrap. Stripe-specific idempotency keys belong in
StripeGateway. Putting them in a genericIdempotentGatewaythat every vendor must honor is how you invent a second SDK. - You need to hide the object or control access, not add behavior. That is Proxy (lazy, remote, auth). Decorator is the object, with extras. Proxy stands in for an object you should not touch directly. Same interface, different reason.
- The caller must know which wrappers ran. If checkout inspects the stack to decide whether to show “retrying…” in the UI, the UI needs an honest API (
ChargeProgress), notinstanceofon decorators.
The healthy trigger is a second extra you can paste from a ticket, applicable to more than one inner type. Logging and retry, on Stripe and the test fake, are two extras and two inners. Logging only, forever, on one vendor, is a log call.
Decorator also is not Adapter. Adapter translates a foreign type into PaymentGateway (StripeClient → StripeGateway). Decorator takes something that already is a PaymentGateway and stacks behavior. Do the vendor translation once. Wrap after.
Do not decorate OrderProcessor. The processor is the workflow. Wrapping process() in LoggingOrderProcessor and RetryingOrderProcessor rebuilds the subclass explosion at the wrong layer. Wrap the gateway (and, if you truly stack discount rules, wrap DiscountPolicy as Strategy already sketched). Leave process as the sequence you can read.
Cheat sheet
Component PaymentGateway charge(order, amount) — callers already depend on this
Decorator Logging / Retrying / CachingGateway same interface, constructor takes PaymentGateway
Inner StripeGateway, PayPalGateway, fakes vendor or test double, no extras in the class
Stack composition root Logging(Retrying(Caching(stripe))) — order is design
Trigger to apply: a second extra, or the same extra needed on a second vendor / fake
Trigger to stop: one wrapper with no second extra and no second inner
Scoreboard: new extra = new file; new vendor reuses the wrappers; no combinatoric type
Do:
- Implement the same interface as the wrappee. Forward the call. Add behavior before, after, or around.
- Give each wrapper one extra. Nest them at the edge; do not subclass
StripeGatewayfor retry. - Test each wrapper against a fake inner; test
OrderProcessoragainst a fake with no wrappers. - Keep Strategy for which discount; keep Decorator for extras around a gateway.
Don’t:
- Introduce
LoggingGatewaywhen a singlelog.infoand no second wrapper will ever exist. - Build
AuditedRetryingCachedStripeGateway— that name is the smell. - Switch on
instanceofinOrderProcessorafter introducing wrappers. - Decorate the workflow class because the gateway was the wrong place and you decorated anyway.
Wrap-up
Decorator is a wrapper that is the component, holds one inner component, and adds one extra. The AuditedRetryingCachedGateway tree was expensive because every combination and every vendor minted a new type. LoggingGateway, RetryingGateway, and CachingGateway around PaymentGateway make the next extra an added class, reuse the same extras on PayPal and on fakes, and leave OrderProcessor unaware of the stack.
The next title on the start-here path is Adapter — make a vendor SDK speak PaymentGateway without rewriting callers.