The ticket says “add void — same as refund, but no money moves.” You open RefundProcessor, copy the class, rename it, and change the middle call from gateway.refund to gateway.voidAuthorization. Validate still runs first. Persist still runs after the provider. The customer still gets an email. You also just forked the bookends: a later change to “never notify until the row is committed” now has three places to miss.

Capture, refund, and void are not three algorithms. They are one sequence with a different middle. That is Template Method’s entire complaint. From the Design Patterns Roadmap: the skeleton of an algorithm is stable; a few steps vary.

This post stays on that shared checkout lab — Order, PaymentGateway, DiscountPolicy, OrderProcessor — and one product invariant: validate, call the provider, persist, then notify. We freeze that order in a superclass. We do not re-lecture the three families.

Three processors that share a sequence

Here is capture after it has already been copied once for refund. Nobody wrote this badly on purpose:

public class CaptureProcessor {

    private final PaymentGateway gateway;
    private final OrderRepository orders;
    private final OrderNotifier notifier;

    public CaptureProcessor(
            PaymentGateway gateway, OrderRepository orders, OrderNotifier notifier) {
        this.gateway = gateway;
        this.orders = orders;
        this.notifier = notifier;
    }

    public void process(Order order) {
        if (order.total().signum() <= 0) {
            throw new IllegalArgumentException("nothing to capture");
        }
        PaymentResult result = gateway.capture(order, order.total());
        if (!result.approved()) {
            throw new PaymentDeclinedException(order.id(), result.failureReason());
        }
        orders.markCaptured(order.id(), result.reference());
        notifier.captured(order);
    }
}

Refund is the same file with two lines changed. Void will be a third. Price the next compliance ticket — “persist before notify, always”:

Change notify-after-commit     -> edit CaptureProcessor, RefundProcessor, VoidProcessor
Add idempotency key on validate -> three copies, three chances to skip one
Test declined capture           -> construct three processors to assert the same bookends

Three costs, and none of them are about which provider verb you call. The sequence leaked into every operation class, so the sequence is no longer one thing you can name.

Note: Copy-paste of a finished two-step helper is fine. The problem is a product rule — this order of steps — sitting in three classes that marketing and finance will keep cloning.

What Template Method actually is

Two parts, one promise:

PartJob
TemplateA final method that names the steps in order. Callers run this. Nobody overrides it.
StepsAbstract operations the subclass must fill in, plus optional hooks with a default.

The superclass owns the conversation. Subclasses own the sentences that actually differ. If a subclass overrides process “just this once,” you have not frozen a skeleton; you have invented a second sequence that will drift.

The Gang of Four name is easy to over-read. You do not need a class named Template. You need a verb the caller already understands — here, “run this payment operation”:

public abstract class PaymentOperation {

    private final OrderRepository orders;
    private final OrderNotifier notifier;

    protected PaymentOperation(OrderRepository orders, OrderNotifier notifier) {
        this.orders = orders;
        this.notifier = notifier;
    }

    public final void process(Order order) {
        validate(order);
        PaymentResult result = callProvider(order);
        if (!result.approved()) {
            throw new PaymentDeclinedException(order.id(), result.failureReason());
        }
        persist(order, result);
        notifyCustomer(order);
    }

    protected abstract PaymentResult callProvider(Order order);

    protected abstract void persist(Order order, PaymentResult result);

    protected void validate(Order order) {
        if (order.total().signum() <= 0) {
            throw new IllegalArgumentException("nothing to process");
        }
    }

    protected void notifyCustomer(Order order) {
        notifier.processed(order);
    }

    protected final OrderRepository orders() {
        return orders;
    }
}

process is final. callProvider and persist are the abstract steps — capture writes a capture reference; refund writes a refund. validate and notifyCustomer are hooks: a subclass may tighten them, and most will not.

Capture and refund fill in the middle

Each old processor becomes a thin subclass. The gateway arrives through the constructor, the same way Strategy already injects DiscountPolicy:

public final class CaptureOperation extends PaymentOperation {

    private final PaymentGateway gateway;

    public CaptureOperation(
            PaymentGateway gateway, OrderRepository orders, OrderNotifier notifier) {
        super(orders, notifier);
        this.gateway = gateway;
    }

    @Override
    protected PaymentResult callProvider(Order order) {
        return gateway.capture(order, order.total());
    }

    @Override
    protected void persist(Order order, PaymentResult result) {
        orders().markCaptured(order.id(), result.reference());
    }
}

public final class RefundOperation extends PaymentOperation {

    private final PaymentGateway gateway;

    public RefundOperation(
            PaymentGateway gateway, OrderRepository orders, OrderNotifier notifier) {
        super(orders, notifier);
        this.gateway = gateway;
    }

    @Override
    protected PaymentResult callProvider(Order order) {
        return gateway.refund(order, order.total());
    }

    @Override
    protected void persist(Order order, PaymentResult result) {
        orders().markRefunded(order.id(), result.reference());
    }

    @Override
    protected void validate(Order order) {
        super.validate(order);
        if (!orders().isCaptured(order.id())) {
            throw new IllegalStateException("refund requires a capture");
        }
    }
}

Void is a third subclass — gateway.voidAuthorization and markVoided — not a fourth copy of the bookends. Adding “persist before notify” is one edit to PaymentOperation.process. That is the point.

A hook is allowed to do nothing. If capture should stay silent and refund should email, override notifyCustomer in one subclass and leave the default in the other. Do not add a boolean notify flag on the superclass; a hook is a method, not a configuration bag.

The processor stops owning three verbs

OrderProcessor used to grow a method per operation. It now takes the operation the same way it already takes a gateway — as a collaborator — or it is the operation for the happy path and the subclasses cover the rest. The honest small version is a checkout path that still charges, plus operations for the after-the-fact verbs:

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());
    }
}

Charge can stay here if it is the one sequence that does include pricing. Capture, refund, and void do not re-run DiscountPolicy. They share a different skeleton, so they share a different template. One abstract class for every workflow in checkout is not Template Method; it is a god superclass.

Note: Keep process dumb. The moment PaymentOperation.process starts asking if (this instanceof RefundOperation) to skip fraud, the skeleton leaked the variant names. Either add an honest hook (boolean requiresFraudCheck()) or keep that branch in a class that is allowed to know.

What the diff looks like now

Same feature request, both designs:

Before — add void
  A VoidProcessor.java         copy of RefundProcessor, two lines changed
  M notify-after-commit later  three files, or you miss void

After — add void
  A VoidOperation.java         callProvider + persist only
  (bookends stay in PaymentOperation.process)

One added subclass. The sequence is not in the diff. PaymentOperation is closed against “a new payment verb appears” and still fully open to editing when the sequence changes — a fraud check, an idempotency key, a second provider attempt. Those belong in process, because they are the skeleton.

Proving the skeleton with a fake step

The seam pays a second dividend immediately: you can test the bookends without a live capture.

final class RecordingOperation extends PaymentOperation {

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

    RecordingOperation(OrderRepository orders, OrderNotifier notifier) {
        super(orders, notifier);
    }

    @Override
    protected PaymentResult callProvider(Order order) {
        providerCalls++;
        return next;
    }

    @Override
    protected void persist(Order order, PaymentResult result) {
        persistCalls++;
        orders().markCaptured(order.id(), result.reference());
    }
}

A unit test can now assert that a declined provider call never persists, using a result that never went through Stripe:

@Test
void declinedProviderCallDoesNotPersist() {
    RecordingRepository orders = new RecordingRepository();
    RecordingOperation op = new RecordingOperation(orders, OrderNotifier.noop());
    op.next = PaymentResult.declined("do_not_honor");

    assertThrows(PaymentDeclinedException.class, () -> op.process(order()));
    assertEquals(1, op.providerCalls);
    assertEquals(0, op.persistCalls);
    assertTrue(orders.markCapturedCalls().isEmpty());
}

If a test for capture math needs a real gateway, you have mixed two reasons to change. Test CaptureOperation.callProvider against a fake PaymentGateway. Test the skeleton against a fake step.

Template Method is not Strategy

Strategy swaps the whole algorithm through an interface the context already holds. Template Method freezes the skeleton in a superclass and lets subclasses fill in steps. Same lab, different axis:

StrategyTemplate Method
What is stableThe caller (OrderProcessor.process)The sequence (validate → call → persist → notify)
What variesThe entire algorithm (payable)Named steps inside a frozen order
How you plug inImplement an interface, inject itExtend a class, override steps
Typical smellGrowing if of formulasCopied bookends around a different middle

Prefer composition when you can. If the only thing that varies is “which PaymentGateway method,” inject the gateway and call it. You do not need a subclass per verb for that. Template Method earns its keep when the sequence itself is the product invariant — PCI, idempotency, “never email a capture that did not commit.” Inheritance is how you make that order unskippable (final process).

When two steps vary independently, inheritance is the wrong grid. Capture-vs-refund times SQL-vs-event-store is four subclasses, then eight. That is two Strategy seams — one for the provider verb, one for persist — composed inside a single process that you are still allowed to write as a plain method. Do not inherit your way into a matrix.

When Template Method is the wrong move

Skip the abstract class when:

  • There is one operation and no second on the roadmap. OrderProcessor.process is a sequence. Wrapping it in AbstractOrderWorkflow “for consistency” is a superclass nobody will extend.
  • The variation is a parameter, not a step. Capture of $10 vs $12 is not two templates. It is callProvider with an amount.
  • Two steps vary independently. That is Strategy (or two strategies), not a subclass per combination.
  • You are about to override process. Then the skeleton is a suggestion. Delete the template; you do not have one.
  • A Strategy for the one varying step is enough. Prefer that. Template Method is for when the order of calls is the thing you must not scramble.

The healthy trigger is a sequence you can quote from a ticket, copied twice already. Validate-call-persist-notify, copied for refund, about to be copied for void. That is three operations and one skeleton. Percent vs flat vs “cheapest line free” is still Strategy — those are whole algorithms, not steps in a frozen order.

Cheat sheet

Template    PaymentOperation.process   final; names the steps; callers run this
Abstract    callProvider, persist      subclass must implement
Hook        validate, notifyCustomer   default in the superclass; override when needed
Variants    Capture, Refund, Void      one subclass per operation, not per amount

Trigger to apply: a sequence is the product invariant, copied in a second class
Trigger to stop:  one operation, or two steps that vary on independent axes
Scoreboard:       new verb = new subclass; bookend tests do not change
Not Strategy:     Strategy swaps the whole algorithm; this freezes the order of steps

Do:

  • Make the template method final. If it is not, you do not have a skeleton.
  • Name abstract steps after the verb (callProvider, persist), not after the subclass (doCapture).
  • Use hooks for optional work. Do not pile booleans onto the superclass.
  • Test the skeleton with a recording subclass; test each operation against a fake gateway.

Don’t:

  • Extract a template for a method that has one implementation and no second verb funded.
  • Override process in a subclass “just this once.”
  • Put a growing if (this instanceof …) inside the template — that is the forest you left.
  • Subclass-explode when two steps vary independently — that matrix is Strategy.

Wrap-up

Template Method is a final method that owns the order of steps, plus abstract operations and hooks for the parts that actually differ. Capture, refund, and void were expensive because every new verb copied validate-persist-notify. Moving that sequence into PaymentOperation.process makes the next verb a subclass, keeps the bookends unit-testable, and leaves process free to change when the skeleton changes.

Prefer a Strategy (or a plain injected PaymentGateway) when you can vary one algorithm without freezing a superclass. Reach for Template Method when the sequence is the product, not the optional ceremony.

Next optional step in the series Hide fraud, tax, discount, and charge behind one checkout conversation. Facade: One Simple Front for a Messy Subsystem