← All patterns

Command

Behavioral · view source on GitHub

Read this in: English · Português · Español

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

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

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

View full JaCoCo coverage report →