Category: Behavioral
The problem
A request needs to be handled as more than just an immediate method call: it might need to be queued for later, logged, retried, or undone. Calling the receiver's method directly loses that request the instant it returns — there's nothing left to replay if it fails, and nothing to reverse if it needs undoing.
The solution
Wrap the request itself in an object: what to call, on what, with what arguments. The invoker holds and triggers command objects without knowing what they actually do; because a command is a real object rather than a completed method call, it can be queued, logged, retried, or handed an inverse operation for undo.
classDiagram
class Command {
<<interface>>
+execute()
}
class ConcreteCommand {
-receiver
+execute()
}
class Receiver
class Invoker {
+setCommand(c)
+trigger()
}
Command <|.. ConcreteCommand
ConcreteCommand --> Receiver
Invoker --> Command
Classic example
classic/RemoteControl
is the canonical example: it holds whatever Command
was last pressed and can undo it, without ever knowing it's actually a
Light being turned
on or off. LightOnCommand
and LightOffCommand
each know their own inverse, which is what makes generic undo possible at the remote's level.
RemoteControlTest
covers both commands executing and undoing correctly, and undo being a safe no-op before
anything has been pressed.
Applied example: replayable batch processing queue
applied/RecordProcessingCommand
wraps one record's processing as an object instead of running it immediately.
BatchQueue
queues commands and, on failure, re-queues the exact same command object up to a retry limit
— replay works because the request was captured as an object in the first place, not because
the queue reconstructs the request from scratch on each attempt. This is the shape a real
batch pipeline processing millions of records a day actually needs: transient failures (a
downstream service momentarily unavailable) get retried automatically, and only records that
fail every attempt end up needing manual attention.
BatchQueueTest
covers records that succeed immediately, one that fails twice before succeeding on the third
attempt, and one that exhausts every retry and lands in the failed list.
When not to use it
- If the request is always executed immediately and never needs to be queued, logged, retried, or undone, wrapping it in a command object is indirection with no payoff — just call the method.
- Commands that need to carry a lot of contextual state to be replayable later can end up duplicating half the receiver's own state inside the command object. If that's happening, consider whether the command should re-fetch fresh state instead of caching the state it had when originally created.
- For undo specifically: if operations aren't naturally invertible (a network call with side effects outside your system, say), "undo" often has to mean "issue a new compensating command," not "reverse the mutation in place" — plan for that distinction up front rather than discovering it's needed after the fact.
Test coverage
100% instruction coverage, 100% branch coverage (JaCoCo). Reproduce it yourself:
./gradlew :behavioral:command:jacocoTestReport
Report at behavioral/command/build/reports/jacoco/test/html/index.html.
Further reading
- Gamma, E., Helm, R., Johnson, R., & Vlissides, J. (1994). Design Patterns: Elements of Reusable Object-Oriented Software. Addison-Wesley. — Chapter 5 formalizes Command, including undo/redo as one of its motivating use cases.
- Hohpe, G., & Woolf, B. (2003). Enterprise Integration Patterns: Designing, Building, and
Deploying Messaging Solutions. Addison-Wesley. — the "Command Message" pattern in this book
is Command applied at the scale
BatchQueuegestures at: a request captured as a real, serializable message so it can be queued, retried, and processed asynchronously rather than invoked as a direct in-process call.
Unit tests
src/test/java/com/designpatterns/behavioral/command/classic/RemoteControlTest.java
package com.designpatterns.behavioral.command.classic;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
class RemoteControlTest {
@Test
void pressingTheOnButtonTurnsTheLightOn() {
Light light = new Light();
RemoteControl remote = new RemoteControl();
String result = remote.pressButton(new LightOnCommand(light));
assertThat(result).isEqualTo("Light is ON");
assertThat(light.isOn()).isTrue();
}
@Test
void undoReversesTheLastCommandRegardlessOfWhichOneItWas() {
Light light = new Light();
RemoteControl remote = new RemoteControl();
remote.pressButton(new LightOnCommand(light));
String result = remote.pressUndo();
assertThat(result).isEqualTo("Light is OFF");
assertThat(light.isOn()).isFalse();
}
@Test
void undoingTheOffCommandTurnsTheLightBackOn() {
Light light = new Light();
light.turnOn();
RemoteControl remote = new RemoteControl();
remote.pressButton(new LightOffCommand(light));
remote.pressUndo();
assertThat(light.isOn()).isTrue();
}
@Test
void undoWithNothingPressedYetIsANoOp() {
RemoteControl remote = new RemoteControl();
assertThat(remote.pressUndo()).isEqualTo("Nothing to undo");
}
}
src/test/java/com/designpatterns/behavioral/command/applied/BatchQueueTest.java
package com.designpatterns.behavioral.command.applied;
import org.junit.jupiter.api.Test;
import java.util.HashMap;
import java.util.Map;
import static org.assertj.core.api.Assertions.assertThat;
class BatchQueueTest {
@Test
void everyRecordThatProcessesCleanlySucceeds() {
BatchQueue queue = new BatchQueue();
RecordProcessor alwaysSucceeds = recordId -> { };
queue.submit(new RecordProcessingCommand("rec-1", alwaysSucceeds));
queue.submit(new RecordProcessingCommand("rec-2", alwaysSucceeds));
queue.runAll();
assertThat(queue.succeeded()).containsExactlyInAnyOrder("rec-1", "rec-2");
assertThat(queue.failed()).isEmpty();
}
@Test
void aRecordThatFailsTwiceThenSucceedsIsReplayedUntilItWorks() {
BatchQueue queue = new BatchQueue();
Map<String, Integer> attemptsSoFar = new HashMap<>();
RecordProcessor failsTwice = recordId -> {
int attempt = attemptsSoFar.merge(recordId, 1, Integer::sum);
if (attempt < 3) {
throw new RuntimeException("transient failure on attempt " + attempt);
}
};
queue.submit(new RecordProcessingCommand("rec-flaky", failsTwice));
queue.runAll();
assertThat(queue.succeeded()).containsExactly("rec-flaky");
assertThat(queue.failed()).isEmpty();
assertThat(attemptsSoFar.get("rec-flaky")).isEqualTo(3);
}
@Test
void aRecordThatNeverSucceedsEndsUpInTheFailedListAfterExhaustingRetries() {
BatchQueue queue = new BatchQueue();
RecordProcessor alwaysFails = recordId -> {
throw new RuntimeException("permanent failure");
};
queue.submit(new RecordProcessingCommand("rec-doomed", alwaysFails));
queue.runAll();
assertThat(queue.failed()).containsExactly("rec-doomed");
assertThat(queue.succeeded()).isEmpty();
}
}