← Todos los patrones

Template Method

Behavioral · ver código fuente en GitHub

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

Category: Behavioral

El problema

Varias variantes de un proceso comparten la misma forma general — los mismos pasos, en el mismo orden — pero difieren en cómo se ejecutan realmente uno o dos de esos pasos. Duplicar el proceso completo para cada variante hace que las partes compartidas (el orden, el manejo de errores, cualquier cosa que no debería variar) se distancien con el tiempo, y una corrección de bug en la lógica compartida hay que aplicarla a cada copia por separado.

La solución

Poner la secuencia fija de pasos en una clase base como un método final, con cada paso delegado a un método abstracto (o un "hook" opcionalmente sobrescribible). Las subclases completan los pasos; no pueden reordenar, saltar, ni duplicar la secuencia en sí, porque nunca la ven.

classDiagram
    class AbstractClass {
        +templateMethod() final
        #stepOne() abstract
        #stepTwo() abstract
        #hook()
    }
    class ConcreteClassA
    class ConcreteClassB
    AbstractClass <|-- ConcreteClassA
    AbstractClass <|-- ConcreteClassB

Ejemplo clásico

classic/Game fija la secuencia initialize() → startPlay() → endPlay() → announceWinner() en un play() final. Chess y Checkers implementan los tres pasos obligatorios de forma distinta, y announceWinner() es un hook — un paso con una implementación por defecto vacía que una subclase puede sobrescribir pero no está obligada a. Chess lo sobrescribe; Checkers no, y eso es una elección completamente válida. GameTest verifica el orden exacto de los pasos para ambos, y que el log de Checkers tiene una entrada menos que el de Chess porque dejó el hook en su valor por defecto.

Ejemplo aplicado: pipeline de migración de sistema heredado

applied/LegacyMigrationPipeline fija read → validate → transform → write en un migrate() final. CobolFixedWidthMigrationPipeline analiza registros posicionales de ancho fijo; CsvLegacyMigrationPipeline analiza registros separados por comas — dos formatos de exportación heredados de la misma época, ambos migrando al mismo formato JSON moderno a través de la misma forma de pipeline. Como validate() corre antes que transform()/write() dentro de la secuencia fija, una falla de validación nunca puede llegar accidentalmente al MigrationSink — ninguna subclase puede equivocar ese orden, porque ninguna subclase controla el orden. LegacyMigrationPipelineTest cubre ambos formatos migrando exitosamente y ambos rechazando un registro malformado antes de que nada se escriba.

Cuándo no usarlo

Cobertura de pruebas

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

./gradlew :behavioral:templatemethod:jacocoTestReport

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

Lecturas adicionales

Pruebas unitarias

src/test/java/com/designpatterns/behavioral/templatemethod/classic/GameTest.java
package com.designpatterns.behavioral.templatemethod.classic;

import org.junit.jupiter.api.Test;

import static org.assertj.core.api.Assertions.assertThat;

class GameTest {

    @Test
    void chessRunsEveryStepInOrderIncludingTheOverriddenHook() {
        Chess chess = new Chess();

        chess.play();

        assertThat(chess.log()).containsExactly(
                "Chess: setting up 32 pieces",
                "Chess: white moves first",
                "Chess: checkmate declared",
                "Chess: white wins by checkmate"
        );
    }

    @Test
    void checkersRunsTheRequiredStepsAndSkipsTheUnoverriddenHook() {
        Checkers checkers = new Checkers();

        checkers.play();

        assertThat(checkers.log()).containsExactly(
                "Checkers: setting up 24 pieces",
                "Checkers: dark pieces move first",
                "Checkers: no more legal moves for one side"
        );
    }
}
src/test/java/com/designpatterns/behavioral/templatemethod/applied/LegacyMigrationPipelineTest.java
package com.designpatterns.behavioral.templatemethod.applied;

import org.junit.jupiter.api.Test;

import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;

class LegacyMigrationPipelineTest {

    @Test
    void migratesAFixedWidthCobolRecordIntoTheModernFormat() {
        InMemoryMigrationSink sink = new InMemoryMigrationSink();
        LegacyMigrationPipeline pipeline = new CobolFixedWidthMigrationPipeline(sink);
        String rawRecord = String.format("%-20s%-10s", "JOAO DA SILVA", "15000");

        pipeline.migrate(rawRecord);

        assertThat(sink.records()).containsExactly("{\"name\":\"JOAO DA SILVA\",\"amountCents\":15000}");
    }

    @Test
    void migratesACsvRecordIntoTheSameModernFormat() {
        InMemoryMigrationSink sink = new InMemoryMigrationSink();
        LegacyMigrationPipeline pipeline = new CsvLegacyMigrationPipeline(sink);

        pipeline.migrate("Maria Souza, 8000");

        assertThat(sink.records()).containsExactly("{\"name\":\"Maria Souza\",\"amountCents\":8000}");
    }

    @Test
    void aValidationFailureStopsThePipelineBeforeAnythingReachesTheSink() {
        InMemoryMigrationSink sink = new InMemoryMigrationSink();
        LegacyMigrationPipeline pipeline = new CsvLegacyMigrationPipeline(sink);

        assertThatThrownBy(() -> pipeline.migrate("no-comma-here"))
                .isInstanceOf(IllegalStateException.class);
        assertThat(sink.records()).isEmpty();
    }

    @Test
    void theCobolPipelineRejectsARecordWithNoName() {
        InMemoryMigrationSink sink = new InMemoryMigrationSink();
        LegacyMigrationPipeline pipeline = new CobolFixedWidthMigrationPipeline(sink);
        String rawRecord = String.format("%-20s%-10s", "", "15000");

        assertThatThrownBy(() -> pipeline.migrate(rawRecord))
                .isInstanceOf(IllegalStateException.class);
        assertThat(sink.records()).isEmpty();
    }
}

Ver informe completo de cobertura JaCoCo →