← Todos los patrones

Command

Behavioral · ver código fuente en GitHub

Leer en: English · Português · Español

Category: Behavioral

El problema

Una solicitud necesita ser tratada como algo más que una simple llamada a método inmediata: puede necesitar ponerse en cola para después, registrarse, reintentarse, o deshacerse. Llamar directamente al método del receptor pierde esa solicitud en el instante en que retorna — no queda nada para repetir si falla, y nada para revertir si necesita deshacerse.

La solución

Envolver la propia solicitud en un objeto: qué llamar, sobre qué, con qué argumentos. Quien invoca mantiene y dispara objetos de comando sin saber qué hacen realmente; como un comando es un objeto real en vez de una llamada a método ya completada, se puede poner en cola, registrar, reintentar, o dotar de una operación inversa para deshacer.

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

Ejemplo clásico

classic/RemoteControl es el ejemplo canónico: mantiene el último Command presionado y puede deshacerlo, sin saber nunca que en realidad es una Light la que se enciende o apaga. LightOnCommand y LightOffCommand cada uno conoce su propia inversa, que es lo que hace posible el undo genérico a nivel del control remoto. RemoteControlTest cubre ambos comandos ejecutándose y deshaciéndose correctamente, y undo siendo un no-op seguro antes de que se haya presionado nada.

Ejemplo aplicado: cola de procesamiento por lotes reproducible

applied/RecordProcessingCommand envuelve el procesamiento de un registro como un objeto en vez de ejecutarlo de inmediato. BatchQueue pone comandos en cola y, ante un fallo, vuelve a encolar el mismo objeto de comando exacto hasta un límite de reintentos — el replay funciona porque la solicitud se capturó como un objeto desde el principio, no porque la cola reconstruya la solicitud desde cero en cada intento. Esta es la forma que realmente necesita un pipeline por lotes real que procesa millones de registros al día: los fallos transitorios (un servicio downstream momentáneamente no disponible) se reintentan automáticamente, y solo los registros que fallan en cada intento terminan necesitando atención manual. BatchQueueTest cubre registros que tienen éxito de inmediato, uno que falla dos veces antes de tener éxito en el tercer intento, y uno que agota cada reintento y termina en la lista de fallidos.

Cuándo no usarlo

Cobertura de pruebas

100% de cobertura de instrucciones, 100% de cobertura de ramas (JaCoCo). Reprodúzcalo usted mismo:

./gradlew :behavioral:command:jacocoTestReport

Informe en behavioral/command/build/reports/jacoco/test/html/index.html.

Lecturas adicionales

Pruebas unitarias

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 informe completo de cobertura JaCoCo →