← Todos os padrões

Command

Behavioral · ver código-fonte no GitHub

Leia em: English · Português · Español

Category: Behavioral

O problema

Uma solicitação precisa ser tratada como mais do que só uma chamada de método imediata: ela pode precisar ser enfileirada pra depois, registrada em log, repetida, ou desfeita. Chamar o método do receptor diretamente perde essa solicitação no instante em que ela retorna — não sobra nada pra reproduzir se ela falhar, e nada pra reverter se precisar ser desfeita.

A solução

Envolver a própria solicitação num objeto: o que chamar, em quê, com quais argumentos. Quem invoca guarda e dispara objetos de comando sem saber o que eles de fato fazem; como um comando é um objeto de verdade em vez de uma chamada de método já concluída, ele pode ser enfileirado, registrado em log, repetido, ou receber uma operação inversa pra desfazer.

classDiagram
    class Command {
        <<interface>>
        +execute()
    }
    class ConcreteCommand {
        -receiver
        +execute()
    }
    class Receiver
    class Invoker {
        +setCommand(c)
        +trigger()
    }
    Command <|.. ConcreteCommand
    ConcreteCommand --> Receiver
    Invoker --> Command

Exemplo clássico

classic/RemoteControl é o exemplo canônico: ele guarda qual Command foi pressionado por último e consegue desfazê-lo, sem nunca saber que na verdade é uma Light sendo ligada ou desligada. LightOnCommand e LightOffCommand cada um conhece sua própria inversa, que é o que torna possível o undo genérico no nível do controle remoto. RemoteControlTest cobre os dois comandos executando e desfazendo corretamente, e undo sendo um no-op seguro antes de qualquer coisa ter sido pressionada.

Exemplo aplicado: fila de processamento em lote reproduzível

applied/RecordProcessingCommand envolve o processamento de um registro como um objeto em vez de rodá-lo imediatamente. BatchQueue enfileira comandos e, em caso de falha, reenfileira o exato mesmo objeto de comando até um limite de tentativas — o replay funciona porque a solicitação foi capturada como um objeto desde o início, não porque a fila reconstrói a solicitação do zero a cada tentativa. Essa é a forma que um pipeline de lote real processando milhões de registros por dia de fato precisa: falhas transitórias (um serviço downstream momentaneamente indisponível) são repetidas automaticamente, e só os registros que falham em toda tentativa acabam precisando de atenção manual. BatchQueueTest cobre registros que têm sucesso imediatamente, um que falha duas vezes antes de ter sucesso na terceira tentativa, e um que esgota toda tentativa e vai parar na lista de falhas.

Quando não usar

Cobertura de testes

100% de cobertura de instrução, 100% de cobertura de branch (JaCoCo). Reproduza você mesmo:

./gradlew :behavioral:command:jacocoTestReport

Relatório em behavioral/command/build/reports/jacoco/test/html/index.html.

Leitura complementar

Testes unitários

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

Ver relatório completo de cobertura JaCoCo →