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:

PartJob
ComponentThe interface callers already use (PaymentGateway).
DecoratorImplements 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.

StrategyDecorator
QuestionWhich algorithm runs?What extra runs around one that already exists?
Lab typeDiscountPolicy — PercentOff vs FlatOffPaymentGateway — Stripe plus retry plus log
Caller seesOne policy objectOne gateway object (the outermost wrapper)
Adding a variantA new implementation of the interfaceA 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.info next to gateway.charge in the composition root — or inside StripeGateway if only Stripe will ever be logged — is a line of code. LoggingGateway alone, 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 generic IdempotentGateway that 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), not instanceof on 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 StripeGateway for retry.
  • Test each wrapper against a fake inner; test OrderProcessor against a fake with no wrappers.
  • Keep Strategy for which discount; keep Decorator for extras around a gateway.

Don’t:

  • Introduce LoggingGateway when a single log.info and no second wrapper will ever exist.
  • Build AuditedRetryingCachedStripeGateway — that name is the smell.
  • Switch on instanceof in OrderProcessor after 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.

Next optional step in the series Wrap a vendor client whose methods do not match charge(Order, amount). Adapter: Make Incompatible Types Talk