The ticket says “retry failed captures overnight, and let support undo a charge from the last hour.” You have gateway.charge, gateway.refund, and gateway.capture — methods. You cannot put a method on a queue. You cannot serialize a method into an audit row. You cannot pass a method to an undo stack. Support needs an object that is the request.
That is Command’s entire complaint. From the Design Patterns Roadmap: a request must be queued, logged, or undone as an object.
This post stays on the shared Order / PaymentGateway / DiscountPolicy / OrderProcessor lab. We do not re-lecture the three families. We wrap charge, refund, and capture because they need a life after execute() — not because a code review asked for a pattern name.
Methods you cannot queue
Here is the processor after capture and refund landed as extra methods. Fine for “run it now in this thread”:
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());
}
public void capture(Order order) {
PaymentResult result = gateway.capture(order, order.total());
if (!result.approved()) {
throw new PaymentDeclinedException(order.id(), result.failureReason());
}
orders.markCaptured(order.id(), result.reference());
}
public void refund(Order order) {
PaymentResult result = gateway.refund(order, order.total());
if (!result.approved()) {
throw new PaymentDeclinedException(order.id(), result.failureReason());
}
orders.markRefunded(order.id(), result.reference());
}
}
Now price the overnight-retry ticket — and undo:
Retry failed captures at 02:00 -> need a list of *requests*, not a list of Order ids plus a string verb
Undo last charge -> need the payable, the reference, and a refund path stored with the request
Audit who charged what -> need a record of the action, not a stack trace of process()
Three costs, and none of them are about card networks. A method is gone when the stack frame returns. A command is still there.
Note: process(order) on the happy path is not a failure. The problem starts when the same request must survive a thread, a restart, a retry, or an undo. That is a different lifetime than a method call.
What Command actually is
Three parts, one promise:
| Part | Job |
|---|---|
| Command | An object that is the request. execute. Optionally undo. Holds the receiver and the arguments. |
| Receiver | The thing that already knows how to work: PaymentGateway, OrderRepository. |
| Invoker | Queue, scheduler, button, audit log — runs execute without knowing which command it is. |
The invoker depends on the command interface. It does not know it charged a card. If the retry worker still switches on "CAPTURE" vs "REFUND" to pick a method, you have not turned the request into an object; you have queued a type code.
The Gang of Four name is easy to over-read. You do not need a class named Command. You need a verb the invoker already understands — here, “run this payment request”:
public interface PaymentCommand {
void execute();
void undo();
}
That is the seam. Everything else is an implementation that holds an Order and the receivers it needs.
Charge as an object
Each method on OrderProcessor that must be queued or undone becomes a type. The discount math stays on DiscountPolicy; the command stores the payable it actually used so undo is not a guess:
public final class ChargeOrderCommand implements PaymentCommand {
private final PaymentGateway gateway;
private final DiscountPolicy discount;
private final OrderRepository orders;
private final Order order;
private BigDecimal payable;
private String reference;
private boolean executed;
public ChargeOrderCommand(
PaymentGateway gateway,
DiscountPolicy discount,
OrderRepository orders,
Order order) {
this.gateway = gateway;
this.discount = discount;
this.orders = orders;
this.order = order;
}
@Override
public void execute() {
payable = discount.payable(order);
PaymentResult result = gateway.charge(order, payable);
if (!result.approved()) {
throw new PaymentDeclinedException(order.id(), result.failureReason());
}
reference = result.reference();
orders.markPaid(order.id(), reference);
executed = true;
}
@Override
public void undo() {
if (!executed) {
return;
}
PaymentResult result = gateway.refund(order, payable);
if (!result.approved()) {
throw new PaymentDeclinedException(order.id(), result.failureReason());
}
orders.markRefunded(order.id(), result.reference());
executed = false;
}
}
CaptureOrderCommand and RefundOrderCommand have the same shape around gateway.capture / gateway.refund. Undo for capture is typically a void or a refund — product rules, not a second pattern. Store what you need to reverse on the command when execute succeeds.
OrderProcessor.process can remain for the synchronous checkout path. Commands are for the requests that must travel. Do not delete a working method because you introduced an object; use the object where the request needs a lifetime.
The invoker never names charge
A queue, a retry loop, and an undo stack all speak PaymentCommand. Construction happens at the edge — a controller, a consumer, a support tool — the same place you already decide which request it is:
public final class PaymentCommandQueue {
private final Deque<PaymentCommand> pending = new ArrayDeque<>();
private final Deque<PaymentCommand> history = new ArrayDeque<>();
public void enqueue(PaymentCommand command) {
pending.addLast(command);
}
public void drain() {
while (!pending.isEmpty()) {
PaymentCommand command = pending.removeFirst();
command.execute();
history.addLast(command);
}
}
public void undoLast() {
if (history.isEmpty()) {
return;
}
history.removeLast().undo();
}
}
Support’s “undo last charge” is undoLast(). Overnight retry is enqueue of the failed captures, then drain. The queue file does not import DiscountPolicy. Adding a new request — void authorization — is a new class plus one enqueue at the edge.
Note: Keep the command focused on one request. The moment ChargeOrderCommand.execute starts sending a marketing email “because we have the order,” you have mixed a payment request with a notification policy. Compose, or let a Facade own that conversation. A command is a request, not a workflow dump.
The ceremony to refuse
This is not Command. This is a method call with extra objects:
new ProcessOrderCommand(processor, order).execute();
Constructed and execute()d on the same line, never queued, never undone, never written to an audit table. You paid for a type and a virtual call to do what processor.process(order) already did. The Design Patterns Roadmap names this cargo-cult on purpose.
If checkout is synchronous and the request dies with the HTTP call, keep the method. Reach for a command when you can point at a queue, a retry, an undo, or a log that must store the action.
What the diff looks like now
Same feature request, both designs:
Before — overnight retry + undo
M OrderProcessor.java more flags: lastReference, lastPayable, lastVerb
M RetryJob.java switch on lastVerb
M SupportController.java another switch to refund vs void
After — overnight retry + undo
A ChargeOrderCommand.java execute + undo, stores payable and reference
A CaptureOrderCommand.java
A RefundOrderCommand.java
A PaymentCommandQueue.java drain / undoLast, no verb switch
M edge (job, support) enqueue / undoLast only
The invoker does not grow a case per verb. PaymentCommandQueue is closed against “a new request type appears” and still fully open to editing when how you run requests changes — persistence, visibility timeouts, a dead-letter. Those belong on the invoker, because they are not charge vs refund.
Proving undo with a fake gateway
The seam pays a second dividend immediately: you can assert undo without a card.
@Test
void undoRefundsThePayableThatWasCharged() {
FakePaymentGateway gateway = new FakePaymentGateway();
RecordingRepository orders = new RecordingRepository();
Order order = orderWithTotal(new BigDecimal("40.00"));
ChargeOrderCommand charge = new ChargeOrderCommand(
gateway, new PercentOff(new BigDecimal("10")), orders, order);
charge.execute();
charge.undo();
assertEquals(new BigDecimal("36.00"), gateway.lastRefundAmount());
assertEquals(order.id(), orders.lastRefundedId());
}
And the queue can be tested with a recording command that never touches a network:
final class RecordingCommand implements PaymentCommand {
private final List<String> calls = new ArrayList<>();
@Override
public void execute() {
calls.add("execute");
}
@Override
public void undo() {
calls.add("undo");
}
List<String> calls() {
return List.copyOf(calls);
}
}
@Test
void undoLastRunsUndoOnTheMostRecentExecute() {
RecordingCommand first = new RecordingCommand();
RecordingCommand second = new RecordingCommand();
PaymentCommandQueue queue = new PaymentCommandQueue();
queue.enqueue(first);
queue.enqueue(second);
queue.drain();
queue.undoLast();
assertEquals(List.of("execute"), first.calls());
assertEquals(List.of("execute", "undo"), second.calls());
}
If a queue test needs a real PercentOff formula, you have mixed two reasons to change. Test commands with fakes. Test the invoker with recording commands.
When Command is the wrong move
Skip the object when:
- You run the method once, in this thread, with no queue, undo, or log.
processor.process(order)is the design.ProcessOrderCommandconstructed andexecute()d on the next line is ceremony. - The “undo” is a second ticketed workflow. A refund next Tuesday is not
undo()on last week’s charge object still sitting in a heap. It is a newRefundOrderCommand(or a method) with today’s arguments. - You only needed a function. A
Runnableor a method reference is enough for “run this later” if you do not need undo, identity, or audit fields. Command earns the class when the request has state. - Every command is a thin wrap of the same
process. Then you wanted a queue ofOrderids and one worker, not three empty classes.
The healthy trigger is a lifetime you can point at: queue, retry, undo stack, or audit row. Charge, capture, and refund that must all sit on that lifetime are three commands. A checkout that charges and returns 200 is still a method.
Command is not Template Method: a template freezes a sequence in a superclass. Command packages a request so something else can run it later. Command is not Strategy: Strategy swaps an algorithm the context calls now; Command is the call itself, delayed or reversed.
Cheat sheet
Command ChargeOrderCommand execute / undo; holds order, payable, reference
Receiver PaymentGateway, repo already knew how to charge and persist
Invoker PaymentCommandQueue drain / undoLast; does not name ChargeOrderCommand
Edge job, support, HTTP constructs the right command; enqueues it
Trigger to apply: the request must live after the stack frame (queue, retry, undo, audit)
Trigger to stop: one synchronous call, no undo, no log of the action as an object
Scoreboard: new request type = new class; invoker tests do not change
Not ceremony: new FooCommand(x).execute() on the same line as the old method
Do:
- Name the command after the request (
ChargeOrderCommand), not after the processor. - Store on the command whatever
undowill need — payable, reference, previous status. - Let the invoker depend only on
PaymentCommand. - Test
execute/undowith a fake gateway; test the queue with a recording command.
Don’t:
- Wrap
process()inProcessOrderCommandconstructed andexecute()d on the same line. - Switch on a verb inside the invoker after introducing the interface.
- Treat next week’s refund as
undo()on an object that is no longer around. - Create
XxxCommand/XxxCommandImplwith a single execute and no queue, undo, or audit.
Wrap-up
Command is a request that became an object so a queue, a retry, an undo stack, or an audit log can hold it. Charge, capture, and refund were expensive as methods because overnight retry and support undo needed the action, not a leftover Order id and a string. ChargeOrderCommand with execute / undo makes the next verb a class, keeps reversal testable, and leaves the invoker free of payment verbs.
If the next pain is “every controller still wires fraud, tax, and charge,” that is a Facade — a conversation, not a request. If you only needed to swap how you price, that is Strategy. Do not wrap a one-shot process() and call it a pattern.