← All patterns

Template Method

Behavioral · view source on GitHub

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

Category: Behavioral

The problem

Several variants of a process share the same overall shape — the same steps, in the same order — but differ in how one or two of those steps are actually done. Duplicating the whole process for each variant means the shared parts (ordering, error handling, anything that shouldn't vary) drift apart over time, and a bug fix in the shared logic has to be applied to every copy separately.

The solution

Put the fixed sequence of steps in a base class as a final method, with each step delegated to an abstract (or optionally-overridable "hook") method. Subclasses fill in the steps; they cannot reorder, skip, or duplicate the sequence itself, because they never see it.

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

Classic example

classic/Game fixes the sequence initialize() → startPlay() → endPlay() → announceWinner() in a final play(). Chess and Checkers each implement the three required steps differently, and announceWinner() is a hook — a step with a default no-op implementation that a subclass may override but doesn't have to. Chess overrides it; Checkers doesn't, and that's a completely valid choice. GameTest asserts the exact step order for both, and that Checkers' log has one fewer entry than Chess' because it left the hook at its default.

Applied example: legacy system migration pipeline

applied/LegacyMigrationPipeline fixes read → validate → transform → write in a final migrate(). CobolFixedWidthMigrationPipeline parses fixed-width positional records; CsvLegacyMigrationPipeline parses comma-separated ones — two legacy export formats from the same era, both migrating into the identical modern JSON shape through the identical pipeline shape. Because validate() runs before transform()/write() inside the fixed sequence, a validation failure can never accidentally reach the MigrationSink — no subclass can get that ordering wrong, because no subclass controls the ordering. LegacyMigrationPipelineTest covers both formats migrating successfully and both rejecting a malformed record before anything is written.

When not to use it

Test coverage

100% instruction coverage, 100% branch coverage (JaCoCo). Reproduce it yourself:

./gradlew :behavioral:templatemethod:jacocoTestReport

Report at behavioral/templatemethod/build/reports/jacoco/test/html/index.html.

Further reading

Unit tests

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

View full JaCoCo coverage report →