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
- Se a solicitação sempre é executada imediatamente e nunca precisa ser enfileirada, registrada em log, repetida, ou desfeita, envolvê-la num objeto de comando é indireção sem retorno — só chame o método.
- Comandos que precisam carregar bastante estado contextual pra serem reproduzíveis depois podem acabar duplicando metade do próprio estado do receptor dentro do objeto de comando. Se isso está acontecendo, considere se o comando deveria buscar estado fresco de novo em vez de guardar em cache o estado que tinha quando foi criado originalmente.
- Especificamente pra undo: se as operações não são naturalmente invertíveis (uma chamada de rede com efeitos colaterais fora do seu sistema, por exemplo), "desfazer" muitas vezes tem que significar "emitir um novo comando compensatório", não "reverter a mutação no lugar" — planeje essa distinção com antecedência em vez de descobrir que ela é necessária depois do fato.
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
- Gamma, E., Helm, R., Johnson, R., & Vlissides, J. (1994). Design Patterns: Elements of Reusable Object-Oriented Software. Addison-Wesley. — o Capítulo 5 formaliza o Command, incluindo undo/redo como um dos seus casos de uso motivadores.
- Hohpe, G., & Woolf, B. (2003). Enterprise Integration Patterns: Designing, Building, and
Deploying Messaging Solutions. Addison-Wesley. — o padrão "Command Message" desse livro é o
Command aplicado na escala pra qual
BatchQueueaponta: uma solicitação capturada como uma mensagem real, serializável, de modo que possa ser enfileirada, repetida, e processada assincronamente em vez de invocada como uma chamada direta em processo.
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();
}
}