Skip to content

Low Level DesignDesign patterns

Template method pattern

The Template method design pattern through three real examples, from CSV and JSON importers to a job lifecycle to a payment pipeline, with the composition alternative, diagrams, live demos and code in five languages.

Mid-level20 min read

A template method is a method in a base class that runs an algorithm as a fixed series of steps. Some steps are written once, in the base class. Others are left to subclasses. They fill them in, but can’t change the order or skip a step. The base class owns what happens and when. Subclasses own how a few of the steps are done.

You use it every time you write a test. unittest.TestCase and JUnit call setUp, then your test, then tearDown, in that order, whatever your test does. Java’s AbstractList builds dozens of methods on top of the get and size you write. .NET’s BackgroundService runs the ExecuteAsync you supply, inside a lifecycle it controls. Today the same goal is often met by composition instead. You pass the varying steps in as functions, or as a strategy. This page shows both, and when each one wins.

The idea in one picture
Importer.run() final: the order is fixed, written once read parse ? validate beforeSave default: nothing save report CsvImporter parse() JsonImporter parse() + beforeSave() fills in "Don't call us, we'll call you." the base class calls the subclass, never the reverse
Scroll or pinch to zoom, drag to pan
The base class fixes the order of the steps. Subclasses fill in the slot that's abstract and, if they want, the hook. They never call run() themselves: the base class calls them.

Every template method has the same parts:

  • The abstract class holds the template method, which calls the steps in order, and the fixed steps.
  • Abstract steps are declared in the abstract class and implemented by every subclass.
  • Hooks are steps with a default (often empty) that a subclass may override.
  • Concrete classes implement the abstract steps and, where needed, the hooks. Nothing else.
The general shape

calls templateMethod()

Client

AbstractClass

+templateMethod() : final

-fixedStep()

#abstractStep()

#hook()

ConcreteA

#abstractStep()

ConcreteB

#abstractStep()

#hook()

calls templateMethod()

Client

AbstractClass

+templateMethod() : final

-fixedStep()

#abstractStep()

#hook()

ConcreteA

#abstractStep()

ConcreteB

#abstractStep()

#hook()

Scroll or pinch to zoom, drag to pan
  • Color key
  • Facade
  • Entity
  • Value object
  • Enum
  • Interface
  • Implementation

The client only ever calls templateMethod(). The concrete classes are called by the base class, never by the client and never the other way round. That direction of control is called the Hollywood principle: “don’t call us, we’ll call you”. It’s what every framework does with your code.

Kind Written by Subclass must write it? Example on this page
Fixed step The base class, often private No, and it can’t change it validate, the retry loop, the audit record
Abstract step Each subclass Yes, or it won’t compile parse, execute, authorize
Hook The base class gives a default; a subclass may override No beforeSave, setUp, tearDown, the payment notification

Prefer hooks to abstract steps when most subclasses would write the same thing. Every abstract step is work for every subclass, and, as the Hard level shows, adding one later breaks them all.

The base class’s job is to own the order and the guarantees. A plain class that receives the varying steps can do that just as well. Picture a JobRunner.run(steps) that takes an execute function, and optional setUp and tearDown functions. That is the Strategy pattern applied to the steps. In most new code, it’s the better default. The Medium level puts the two side by side, with the same output.

Easy

CSV and JSON importers

  • The basic shape
  • Abstract steps and hooks
  • One place for the order

A back-office app imports customer lists sent by partners. One partner sends CSV, another sends JSON, and an Excel partner signs next month. Every import does the same things. It reads the file, and parses it into rows. It validates each row (a customer must have an email). It saves the good rows, and reports how many were saved and skipped. Only parsing differs between formats.

Each importer was written as its own class, the second one by copying the first:

CsvImporter.javajson_importer.pyCsvImporter.csr.gojson-importer.ts
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
record Customer(String name, String email) {}
// Stand-ins for the file system and the customer table, so the demo is self-contained.
final class Files {
static final Map<String, String> CONTENT = Map.of(
"customers.csv", "name,email\nada,ada@example.com\nbob,\ncyd,cyd@example.com",
"customers.json", "[{\"name\":\"dee\",\"email\":\"dee@example.com\"},"
+ "{\"name\":\"eve\",\"email\":\"\"},"
+ "{\"name\":\"fay\",\"email\":\"Fay@Example.com\"}]");
static String read(String path) { return CONTENT.get(path); }
}
final class Db {
static final List<Customer> CUSTOMERS = new ArrayList<>();
}
// Two importers, written by two people a few months apart. Only parsing really differs,
// but every step is copied, and the copies have already drifted.
class CsvImporter {
void run(String path) {
String text = Files.read(path);
System.out.println("[csv] read " + path);
List<Customer> rows = new ArrayList<>();
String[] lines = text.split("\n");
for (int i = 1; i < lines.length; i++) { // line 0 is the header
String[] cols = lines[i].split(",", -1);
rows.add(new Customer(cols[0], cols[1]));
}
System.out.println("[csv] parsed " + rows.size() + " rows");
List<Customer> valid = new ArrayList<>();
for (int i = 0; i < rows.size(); i++) {
if (rows.get(i).email().isEmpty()) System.out.println("[csv] row " + (i + 1) + " skipped, missing email");
else valid.add(rows.get(i));
}
Db.CUSTOMERS.addAll(valid);
System.out.println("[csv] saved " + valid.size() + " rows");
System.out.println("[csv] " + valid.size() + " saved, " + (rows.size() - valid.size()) + " skipped");
}
}
class JsonImporter {
// A toy reader for flat JSON objects, so the example needs no library.
private static final Pattern OBJ = Pattern.compile("\\{\"name\":\"(.*?)\",\"email\":\"(.*?)\"\\}");
void run(String path) {
String text = Files.read(path);
System.out.println("[json] read " + path);
List<Customer> rows = new ArrayList<>();
Matcher m = OBJ.matcher(text);
while (m.find()) rows.add(new Customer(m.group(1), m.group(2).toLowerCase()));
System.out.println("[json] parsed " + rows.size() + " rows");
// The flaw: validation was never copied here, so a customer with no email is saved.
Db.CUSTOMERS.addAll(rows);
System.out.println("[json] saved " + rows.size() + " rows");
// And the report line drifted, so the ops dashboard can't read both.
System.out.println("[json] imported " + rows.size());
}
}
class Main {
public static void main(String[] args) {
new CsvImporter().run("customers.csv");
new JsonImporter().run("customers.json");
// [csv] read customers.csv
// [csv] parsed 3 rows
// [csv] row 2 skipped, missing email
// [csv] saved 2 rows
// [csv] 2 saved, 1 skipped
// [json] read customers.json
// [json] parsed 3 rows
// [json] saved 3 rows
// [json] imported 3
System.out.println("db: " + String.join(", ", Db.CUSTOMERS.stream().map(Customer::name).toList()));
// db: ada, cyd, dee, eve, fay
}
}

The JSON copy has no validation step, so eve, who has no email, is now a customer. Its report line says imported 3 while the CSV one says 2 saved, 1 skipped, so the ops dashboard can’t read both. Neither bug is in the code that’s actually different (parsing). Both are in the steps that should have been the same.

Move the steps into an abstract Importer whose run() calls them in order and can’t be overridden. The fixed steps are private. parse is abstract, and beforeSave is a hook with an empty default:

Easy: the base class owns the steps, subclasses only parse

validates and saves

Importer

+run(path) : final

-read(path)

-validate(rows)

-save(rows)

-report(total, saved)

#parse(text) : List<Customer>

#beforeSave(rows) : List<Customer>

CsvImporter

#parse(text)

JsonImporter

#parse(text)

#beforeSave(rows)

Customer

+name

+email

validates and saves

Importer

+run(path) : final

-read(path)

-validate(rows)

-save(rows)

-report(total, saved)

#parse(text) : List<Customer>

#beforeSave(rows) : List<Customer>

CsvImporter

#parse(text)

JsonImporter

#parse(text)

#beforeSave(rows)

Customer

+name

+email

Scroll or pinch to zoom, drag to pan
  • Color key
  • Facade
  • Entity
  • Value object
  • Enum
  • Interface
  • Implementation

Run each importer and watch which class each step comes from. Then switch to the copied code:

Importer:
Design:

customers.csv

Steps, in the order run() calls them

    Pick an importer and run it.

    Importer.javaimporter.pyImporter.csr.goimporter.ts
    import java.util.ArrayList;
    import java.util.List;
    import java.util.Map;
    import java.util.regex.Matcher;
    import java.util.regex.Pattern;
    record Customer(String name, String email) {}
    // Stand-ins for the file system and the customer table, so the demo is self-contained.
    final class Files {
    static final Map<String, String> CONTENT = Map.of(
    "customers.csv", "name,email\nada,ada@example.com\nbob,\ncyd,cyd@example.com",
    "customers.json", "[{\"name\":\"dee\",\"email\":\"dee@example.com\"},"
    + "{\"name\":\"eve\",\"email\":\"\"},"
    + "{\"name\":\"fay\",\"email\":\"Fay@Example.com\"}]");
    static String read(String path) { return CONTENT.get(path); }
    }
    final class Db {
    static final List<Customer> CUSTOMERS = new ArrayList<>();
    }
    // The base class owns the algorithm. Subclasses fill in the one step that differs.
    abstract class Importer {
    // The template method. `final` means no subclass can reorder the steps or skip one,
    // so every format is validated and reported the same way.
    public final void run(String path) {
    String text = read(path);
    List<Customer> rows = parse(text); // the subclass's step
    List<Customer> valid = validate(rows);
    valid = beforeSave(valid); // a hook: optional, with a default
    save(valid);
    report(rows.size(), valid.size());
    }
    // Abstract step: every importer must say how to turn text into rows.
    protected abstract List<Customer> parse(String text);
    // Short label for log lines, such as "csv".
    protected abstract String label();
    // Hook: a subclass may override it. The default does nothing, so most don't.
    protected List<Customer> beforeSave(List<Customer> rows) { return rows; }
    // Fixed steps. They are private: written once, the same for every format.
    private String read(String path) {
    log("read " + path + " (Importer)");
    return Files.read(path);
    }
    private List<Customer> validate(List<Customer> rows) {
    List<Customer> valid = new ArrayList<>();
    for (int i = 0; i < rows.size(); i++) {
    if (rows.get(i).email().isEmpty()) log("validate: row " + (i + 1) + " skipped, missing email (Importer)");
    else valid.add(rows.get(i));
    }
    return valid;
    }
    private void save(List<Customer> rows) {
    Db.CUSTOMERS.addAll(rows);
    log("save: " + rows.size() + " rows (Importer)");
    }
    private void report(int total, int saved) {
    log("report: " + saved + " saved, " + (total - saved) + " skipped (Importer)");
    }
    protected final void log(String line) { System.out.println("[" + label() + "] " + line); }
    }
    // A subclass is now only what is different about its format.
    class CsvImporter extends Importer {
    protected String label() { return "csv"; }
    protected List<Customer> parse(String text) {
    List<Customer> rows = new ArrayList<>();
    String[] lines = text.split("\n");
    for (int i = 1; i < lines.length; i++) { // line 0 is the header
    String[] cols = lines[i].split(",", -1);
    rows.add(new Customer(cols[0], cols[1]));
    }
    log("parse: " + rows.size() + " rows (CsvImporter)");
    return rows;
    }
    }
    class JsonImporter extends Importer {
    // A toy reader for flat JSON objects, so the example needs no library.
    private static final Pattern OBJ = Pattern.compile("\\{\"name\":\"(.*?)\",\"email\":\"(.*?)\"\\}");
    protected String label() { return "json"; }
    protected List<Customer> parse(String text) {
    List<Customer> rows = new ArrayList<>();
    Matcher m = OBJ.matcher(text);
    while (m.find()) rows.add(new Customer(m.group(1), m.group(2)));
    log("parse: " + rows.size() + " rows (JsonImporter)");
    return rows;
    }
    // This partner sends mixed-case emails, so this importer uses the hook to clean them.
    // It runs after validation, because the base class decided that order.
    @Override
    protected List<Customer> beforeSave(List<Customer> rows) {
    List<Customer> out = new ArrayList<>();
    int changed = 0;
    for (Customer c : rows) {
    String lower = c.email().toLowerCase();
    if (!lower.equals(c.email())) changed++;
    out.add(new Customer(c.name(), lower));
    }
    log("beforeSave: " + changed + " email lowercased (JsonImporter)");
    return out;
    }
    }
    class Main {
    public static void main(String[] args) {
    new CsvImporter().run("customers.csv");
    // [csv] read customers.csv (Importer)
    // [csv] parse: 3 rows (CsvImporter)
    // [csv] validate: row 2 skipped, missing email (Importer)
    // [csv] save: 2 rows (Importer)
    // [csv] report: 2 saved, 1 skipped (Importer)
    new JsonImporter().run("customers.json");
    // [json] read customers.json (Importer)
    // [json] parse: 3 rows (JsonImporter)
    // [json] validate: row 2 skipped, missing email (Importer)
    // [json] beforeSave: 1 email lowercased (JsonImporter)
    // [json] save: 2 rows (Importer)
    // [json] report: 2 saved, 1 skipped (Importer)
    System.out.println("db: " + String.join(", ", Db.CUSTOMERS.stream().map(Customer::name).toList()));
    // db: ada, cyd, dee, fay
    }
    }
    • The steps that must be the same are written once. Validation, saving and reporting live in Importer. A new format can’t forget them, because it never writes them.
    • A subclass is only the difference. CsvImporter is one method. The Excel importer will be one method too, which is the open-closed principle applied to an algorithm: add a format, edit nothing.
    • The hook has a default, so most subclasses ignore it. Only JsonImporter overrides beforeSave, to lowercase that partner’s emails. It runs after validation because the base class decided so, and the subclass can’t move it.
    • Fixed steps are private. A subclass can’t call save early or override validate with a version that lets everything through.
    • Each language guards run() differently. Java has final. C# methods aren’t virtual unless marked, so Run is safe by default. Python’s @typing.final is checked by type checkers such as mypy, not at run time. TypeScript has no final at all. Go has no inheritance, so its Importer holds a Format interface and checks for the optional BeforeSaver hook with a type assertion. Same pattern, built from composition.
    Interviewer

    We import customers from CSV and JSON, and Excel is next. Last quarter one importer skipped validation for weeks before anyone noticed. How would you restructure this?

    What they are testing

    Whether you put the fixed sequence in one method that can’t be overridden, and leave only parsing to each format. The red flag is a shared validate() helper that each importer must remember to call. It can still be forgotten, and that’s the bug that already happened.

    Say this out loud

    The steps are the same for every format except parsing. I’ll make an abstract Importer whose run method is final and calls read, parse, validate, an optional beforeSave hook, save and report in that order. Each format only implements parse.

    Medium

    A scheduled job's lifecycle

    • Cleanup that always runs
    • Retries and timing in the base
    • Making the template non-overridable
    • The composition alternative

    A company runs nightly jobs on a shared scheduler. They send invoice reminders, rebuild the search index, and export orders to finance. Many teams write jobs. The platform team makes three promises. Every job is retried up to 3 times, with a 100 ms backoff. Its run time is measured. And whatever a job sets up (a temp table, a temp file) is torn down, even if the job fails. Job authors should write the work, and nothing else.

    This is the shape of most framework base classes: test fixtures (setUp, test, tearDown), background services, request handlers. The framework owns the lifecycle and calls your code.

    The base class below has the right lifecycle. Its only flaw is that run() can be overridden. One team decided the retry loop was overhead for a quick export and wrote its own run():

    ScheduledJob.javascheduled_job.pyScheduledJob.csrunner.goscheduled-job.ts
    import java.util.LinkedHashSet;
    import java.util.Set;
    final class FakeClock {
    private long now;
    long now() { return now; }
    void advance(long ms) { now += ms; }
    }
    final class TempResources {
    static final Set<String> OPEN = new LinkedHashSet<>();
    }
    // The same base class as the fixed version, with one word missing: run() is not final.
    abstract class ScheduledJob {
    private final String name;
    private final int maxAttempts;
    private static final long BACKOFF_MS = 100;
    protected final FakeClock clock;
    protected ScheduledJob(String name, int maxAttempts, FakeClock clock) {
    this.name = name;
    this.maxAttempts = maxAttempts;
    this.clock = clock;
    }
    // Overridable. The guarantees below (retries, timing, tearDown in finally) hold only
    // for subclasses that choose not to replace this method.
    public boolean run() {
    long start = clock.now();
    String error = null;
    int attempt = 0;
    setUp();
    try {
    while (attempt < maxAttempts) {
    attempt++;
    try {
    String result = execute();
    log("attempt " + attempt + " ok: " + result);
    error = null;
    break;
    } catch (Exception e) {
    error = e.getMessage();
    log("attempt " + attempt + " failed: " + error);
    if (attempt < maxAttempts) clock.advance(BACKOFF_MS);
    }
    }
    } finally {
    tearDown();
    }
    long ms = clock.now() - start;
    String tries = attempt + (attempt == 1 ? " attempt" : " attempts");
    if (error == null) log("OK in " + ms + " ms after " + tries);
    else log("FAILED in " + ms + " ms after " + tries + ": " + error);
    return error == null;
    }
    protected abstract String execute() throws Exception;
    protected void setUp() {}
    protected void tearDown() {}
    protected final void log(String line) { System.out.println("[" + name + "] " + line); }
    }
    class ReindexJob extends ScheduledJob {
    private int calls;
    ReindexJob(FakeClock clock) { super("reindex", 3, clock); }
    @Override protected void setUp() {
    TempResources.OPEN.add("tmp_idx");
    log("setUp: created tmp_idx");
    }
    protected String execute() throws Exception {
    calls++;
    if (calls == 1) { clock.advance(500); throw new Exception("search cluster timeout"); }
    clock.advance(400);
    return "1200 docs indexed";
    }
    @Override protected void tearDown() {
    TempResources.OPEN.remove("tmp_idx");
    log("tearDown: dropped tmp_idx");
    }
    }
    // Someone decided exports are quick and the retry loop is overhead, so they replaced run().
    // It looks reasonable in review. It drops retries, timing and the finally block at once.
    class QuickExportJob extends ScheduledJob {
    QuickExportJob(FakeClock clock) { super("export", 3, clock); }
    @Override public boolean run() {
    setUp();
    String result = execute(); // throws on the first failure...
    tearDown(); // ...so this line never runs
    log("done: " + result);
    return true;
    }
    @Override protected void setUp() {
    TempResources.OPEN.add("export.tmp");
    log("setUp: opened export.tmp");
    }
    protected String execute() {
    clock.advance(300);
    throw new IllegalStateException("disk full");
    }
    @Override protected void tearDown() {
    TempResources.OPEN.remove("export.tmp");
    log("tearDown: deleted export.tmp");
    }
    }
    class Main {
    public static void main(String[] args) {
    FakeClock clock = new FakeClock();
    for (ScheduledJob job : new ScheduledJob[] { new ReindexJob(clock), new QuickExportJob(clock) }) {
    try {
    job.run();
    } catch (RuntimeException e) {
    // The scheduler expected run() never to throw. Now it has to guess what leaked.
    System.out.println("scheduler caught: " + e.getMessage());
    }
    }
    // [reindex] setUp: created tmp_idx
    // [reindex] attempt 1 failed: search cluster timeout
    // [reindex] attempt 2 ok: 1200 docs indexed
    // [reindex] tearDown: dropped tmp_idx
    // [reindex] OK in 1000 ms after 2 attempts
    // [export] setUp: opened export.tmp
    // scheduler caught: disk full
    System.out.println("leftover temp resources: " + (TempResources.OPEN.isEmpty() ? "none" : String.join(", ", TempResources.OPEN)));
    // leftover temp resources: export.tmp
    }
    }

    It looks reasonable in a code review. It also drops the retries, the timing and the finally block at once. The first failure escapes to the scheduler, tearDown never runs, and export.tmp is still on disk. The guarantee held only for subclasses that chose to keep it.

    run() is final. It calls the setUp hook, then execute inside a retry loop, then the tearDown hook in a finally block, and logs the outcome and time. execute is the only abstract step:

    Medium: the framework owns the lifecycle

    calls run()

    Scheduler

    ScheduledJob

    -name, maxAttempts

    #clock

    +run() : bool final

    #execute() : String

    #setUp()

    #tearDown()

    InvoiceRemindersJob

    #execute()

    ReindexJob

    #execute()

    #setUp()

    #tearDown()

    ExportJob

    #execute()

    #setUp()

    #tearDown()

    calls run()

    Scheduler

    ScheduledJob

    -name, maxAttempts

    #clock

    +run() : bool final

    #execute() : String

    #setUp()

    #tearDown()

    InvoiceRemindersJob

    #execute()

    ReindexJob

    #execute()

    #setUp()

    #tearDown()

    ExportJob

    #execute()

    #setUp()

    #tearDown()

    Scroll or pinch to zoom, drag to pan
    • Color key
    • Facade
    • Entity
    • Value object
    • Enum
    • Interface
    • Implementation

    Run a job that fails, then let it override run(), with and without final:

    Job:

    What happened (fake clock)

      Pick a job and run it.

      ScheduledJob.javascheduled_job.pyScheduledJob.csrunner.goscheduled-job.ts
      import java.util.LinkedHashSet;
      import java.util.Set;
      // A fake clock, so timing is deterministic. Each job's work "takes" time by advancing it.
      final class FakeClock {
      private long now;
      long now() { return now; }
      void advance(long ms) { now += ms; }
      }
      // Temp tables and files a job creates. Anything left here after a run is a leak.
      final class TempResources {
      static final Set<String> OPEN = new LinkedHashSet<>();
      }
      // The framework's base class. It owns the lifecycle: set up, retries, timing, tear down.
      // A job author writes execute() and, only if needed, the setUp/tearDown hooks.
      abstract class ScheduledJob {
      private final String name;
      private final int maxAttempts;
      private static final long BACKOFF_MS = 100;
      protected final FakeClock clock;
      protected ScheduledJob(String name, int maxAttempts, FakeClock clock) {
      this.name = name;
      this.maxAttempts = maxAttempts;
      this.clock = clock;
      }
      // The template method, and the only thing the scheduler calls. `final` is the guarantee:
      // no job can skip tearDown, the retries or the timing by overriding run().
      public final boolean run() {
      long start = clock.now();
      String error = null;
      int attempt = 0;
      // setUp runs before the try: if it fails, there is nothing to tear down.
      setUp();
      try {
      while (attempt < maxAttempts) {
      attempt++;
      try {
      String result = execute(); // the only step every job must write
      log("attempt " + attempt + " ok: " + result);
      error = null;
      break;
      } catch (Exception e) {
      error = e.getMessage();
      log("attempt " + attempt + " failed: " + error);
      if (attempt < maxAttempts) clock.advance(BACKOFF_MS);
      }
      }
      } finally {
      // Runs whatever execute() did, even if it threw something we didn't catch.
      tearDown();
      }
      long ms = clock.now() - start;
      String tries = attempt + (attempt == 1 ? " attempt" : " attempts");
      if (error == null) log("OK in " + ms + " ms after " + tries);
      else log("FAILED in " + ms + " ms after " + tries + ": " + error);
      return error == null;
      }
      // Abstract step: the job's actual work.
      protected abstract String execute() throws Exception;
      // Hooks with empty defaults. Most jobs need neither, so they don't have to write them.
      protected void setUp() {}
      protected void tearDown() {}
      protected final void log(String line) { System.out.println("[" + name + "] " + line); }
      }
      // The simplest job: one method. No setup, no cleanup, no retry loop, no timing code.
      class InvoiceRemindersJob extends ScheduledJob {
      InvoiceRemindersJob(FakeClock clock) { super("invoice-reminders", 3, clock); }
      protected String execute() {
      clock.advance(120);
      return "sent 42 reminders";
      }
      }
      // Uses both hooks: a temp table that must be dropped however the run ends.
      class ReindexJob extends ScheduledJob {
      private int calls;
      ReindexJob(FakeClock clock) { super("reindex", 3, clock); }
      @Override protected void setUp() {
      TempResources.OPEN.add("tmp_idx");
      log("setUp: created tmp_idx");
      }
      protected String execute() throws Exception {
      calls++;
      if (calls == 1) { clock.advance(500); throw new Exception("search cluster timeout"); }
      clock.advance(400);
      return "1200 docs indexed";
      }
      @Override protected void tearDown() {
      TempResources.OPEN.remove("tmp_idx");
      log("tearDown: dropped tmp_idx");
      }
      }
      // Fails every time. The base class still tears down and reports a clean failure.
      class ExportJob extends ScheduledJob {
      ExportJob(FakeClock clock) { super("export", 3, clock); }
      @Override protected void setUp() {
      TempResources.OPEN.add("export.tmp");
      log("setUp: opened export.tmp");
      }
      protected String execute() throws Exception {
      clock.advance(300);
      throw new Exception("disk full");
      }
      @Override protected void tearDown() {
      TempResources.OPEN.remove("export.tmp");
      log("tearDown: deleted export.tmp");
      }
      }
      class Main {
      public static void main(String[] args) {
      FakeClock clock = new FakeClock();
      // The scheduler only knows ScheduledJob.run(). It calls the jobs, never the reverse.
      for (ScheduledJob job : new ScheduledJob[] {
      new InvoiceRemindersJob(clock), new ReindexJob(clock), new ExportJob(clock) }) {
      job.run();
      }
      // [invoice-reminders] attempt 1 ok: sent 42 reminders
      // [invoice-reminders] OK in 120 ms after 1 attempt
      // [reindex] setUp: created tmp_idx
      // [reindex] attempt 1 failed: search cluster timeout
      // [reindex] attempt 2 ok: 1200 docs indexed
      // [reindex] tearDown: dropped tmp_idx
      // [reindex] OK in 1000 ms after 2 attempts
      // [export] setUp: opened export.tmp
      // [export] attempt 1 failed: disk full
      // [export] attempt 2 failed: disk full
      // [export] attempt 3 failed: disk full
      // [export] tearDown: deleted export.tmp
      // [export] FAILED in 1100 ms after 3 attempts: disk full
      System.out.println("leftover temp resources: " + (TempResources.OPEN.isEmpty() ? "none" : String.join(", ", TempResources.OPEN)));
      // leftover temp resources: none
      }
      }
      • tearDown is in finally, and setUp is outside the try. If execute throws, cleanup still runs. That’s true even for an error the retry loop doesn’t catch. If setUp itself fails, there is nothing to tear down. Try-with-resources and Python’s unittest behave the same way. (JUnit 5 makes the other choice. It runs @AfterEach even when @BeforeEach fails, so its teardown must cope with a half-done setup.) Pick one, and document it.
      • Hooks keep simple jobs simple. InvoiceRemindersJob is one method. It has no setup and no cleanup, so it writes neither, and it still gets retries and timing.
      • Making the template impossible to override is the guarantee. Java’s final and C#’s default stop overrides at compile time. (In C#, methods aren’t virtual unless marked.) Python’s @final only informs type checkers. So the Python version also rejects an override in __init_subclass__, which runs when the subclass is defined. TypeScript has no final. Its version compares this.run with ScheduledJob.prototype.run in the constructor, and throws. Both are run-time checks, a step weaker than a compiler error.
      • Go protects the guarantee with structure. A Go job never has a Run of its own that the scheduler calls. The scheduler calls Runner.Run(job). So a job can’t skip the lifecycle. It never gets the chance. In the before version it does, and its early return err skips TearDown.
      • Time is injected. The fake clock makes the retry and timing behavior testable, without sleeping. Reindex takes exactly 500 + 100 + 400 = 1000 ms.

      Here is the same lifecycle with no base class. A final JobRunner takes the steps as values: an execute function and optional setUp and tearDown. The output is the same, line for line:

      Medium, composed: a runner that takes the steps

      run(name, steps)

      calls in order

      method references

      Scheduler

      JobRunner

      -clock, maxAttempts

      +run(name, Steps) : bool

      Steps

      +setUp: function, optional

      +execute: function

      +tearDown: function, optional

      Reindex

      +setUp()

      +execute()

      +tearDown()

      run(name, steps)

      calls in order

      method references

      Scheduler

      JobRunner

      -clock, maxAttempts

      +run(name, Steps) : bool

      Steps

      +setUp: function, optional

      +execute: function

      +tearDown: function, optional

      Reindex

      +setUp()

      +execute()

      +tearDown()

      Scroll or pinch to zoom, drag to pan
      • Color key
      • Facade
      • Entity
      • Value object
      • Enum
      • Interface
      • Implementation
      JobRunner.javaexport.pyJobRunner.csjob_runner.gojob-runner.ts
      import java.util.LinkedHashSet;
      import java.util.Set;
      import java.util.concurrent.Callable;
      final class FakeClock {
      private long now;
      long now() { return now; }
      void advance(long ms) { now += ms; }
      }
      final class TempResources {
      static final Set<String> OPEN = new LinkedHashSet<>();
      }
      // The varying parts of a job, passed in as values instead of overridden in a subclass.
      // Only execute is required; the hooks default to doing nothing.
      record Steps(Runnable setUp, Callable<String> execute, Runnable tearDown) {
      static Steps of(Callable<String> execute) { return new Steps(() -> {}, execute, () -> {}); }
      }
      // The same lifecycle as ScheduledJob.run(), in a final class that jobs don't extend.
      // Nothing can override it, because there is nothing to subclass.
      final class JobRunner {
      private static final long BACKOFF_MS = 100;
      private final FakeClock clock;
      private final int maxAttempts;
      JobRunner(FakeClock clock, int maxAttempts) {
      this.clock = clock;
      this.maxAttempts = maxAttempts;
      }
      boolean run(String name, Steps steps) {
      long start = clock.now();
      String error = null;
      int attempt = 0;
      steps.setUp().run();
      try {
      while (attempt < maxAttempts) {
      attempt++;
      try {
      String result = steps.execute().call();
      log(name, "attempt " + attempt + " ok: " + result);
      error = null;
      break;
      } catch (Exception e) {
      error = e.getMessage();
      log(name, "attempt " + attempt + " failed: " + error);
      if (attempt < maxAttempts) clock.advance(BACKOFF_MS);
      }
      }
      } finally {
      steps.tearDown().run();
      }
      long ms = clock.now() - start;
      String tries = attempt + (attempt == 1 ? " attempt" : " attempts");
      if (error == null) log(name, "OK in " + ms + " ms after " + tries);
      else log(name, "FAILED in " + ms + " ms after " + tries + ": " + error);
      return error == null;
      }
      private static void log(String name, String line) { System.out.println("[" + name + "] " + line); }
      }
      // A job with state and cleanup is a plain class. It extends nothing and knows no runner.
      class Reindex {
      private final FakeClock clock;
      private int calls;
      Reindex(FakeClock clock) { this.clock = clock; }
      void setUp() {
      TempResources.OPEN.add("tmp_idx");
      System.out.println("[reindex] setUp: created tmp_idx");
      }
      String execute() throws Exception {
      calls++;
      if (calls == 1) { clock.advance(500); throw new Exception("search cluster timeout"); }
      clock.advance(400);
      return "1200 docs indexed";
      }
      void tearDown() {
      TempResources.OPEN.remove("tmp_idx");
      System.out.println("[reindex] tearDown: dropped tmp_idx");
      }
      }
      class Export {
      private final FakeClock clock;
      Export(FakeClock clock) { this.clock = clock; }
      void setUp() {
      TempResources.OPEN.add("export.tmp");
      System.out.println("[export] setUp: opened export.tmp");
      }
      String execute() throws Exception {
      clock.advance(300);
      throw new Exception("disk full");
      }
      void tearDown() {
      TempResources.OPEN.remove("export.tmp");
      System.out.println("[export] tearDown: deleted export.tmp");
      }
      }
      class Main {
      public static void main(String[] args) {
      FakeClock clock = new FakeClock();
      JobRunner runner = new JobRunner(clock, 3);
      // The simplest job is a lambda: no class at all.
      runner.run("invoice-reminders", Steps.of(() -> { clock.advance(120); return "sent 42 reminders"; }));
      Reindex reindex = new Reindex(clock);
      runner.run("reindex", new Steps(reindex::setUp, reindex::execute, reindex::tearDown));
      Export export = new Export(clock);
      runner.run("export", new Steps(export::setUp, export::execute, export::tearDown));
      // Same output as the subclass version, line for line.
      // [invoice-reminders] attempt 1 ok: sent 42 reminders
      // [invoice-reminders] OK in 120 ms after 1 attempt
      // [reindex] setUp: created tmp_idx
      // [reindex] attempt 1 failed: search cluster timeout
      // [reindex] attempt 2 ok: 1200 docs indexed
      // [reindex] tearDown: dropped tmp_idx
      // [reindex] OK in 1000 ms after 2 attempts
      // [export] setUp: opened export.tmp
      // [export] attempt 1 failed: disk full
      // [export] attempt 2 failed: disk full
      // [export] attempt 3 failed: disk full
      // [export] tearDown: deleted export.tmp
      // [export] FAILED in 1100 ms after 3 attempts: disk full
      System.out.println("leftover temp resources: " + (TempResources.OPEN.isEmpty() ? "none" : String.join(", ", TempResources.OPEN)));
      // leftover temp resources: none
      }
      }

      The simplest job is now a lambda, with no class. A job with state is a plain class that extends nothing, and JobRunner can’t be overridden because there’s nothing to subclass. Which one to choose:

      Subclass (ScheduledJob) Composition (JobRunner and Steps)
      A job is A class that extends the framework A function, or an object with up to three methods
      Guarantee enforced by final, or a run-time check Structure: jobs never own the lifecycle
      Job’s access to framework state Inherited protected fields (clock, log) Only what you pass in
      Mixing behaviors (two jobs’ setups) Not possible with single inheritance Compose the functions
      Swapping at run time No, fixed by the class Yes, steps are values
      Wins when A framework owns the base class and many hooks share state New code, few steps, or variants that need to mix
      Interviewer

      Our job framework promises cleanup, but last week a temp table was left behind after a job failed in production. How can that happen, and how do you make sure it can’t again?

      What they are testing

      Whether you suspect a subclass that replaced the lifecycle method. And whether you fix it by making the template impossible to override, with teardown in finally. Or by moving to a runner that jobs can’t bypass. The red flag is adding “remember to call super.run()” to the docs, or to a review checklist.

      Hard

      A payment pipeline for several teams

      • Invariants no subclass can skip
      • Atomic idempotency
      • Compensation on failure
      • Tracing in the base
      • Versioning the skeleton
      • When to move to a pipeline

      A payments platform takes card payments, bank transfers and wallet payments. A different team owns each method, and each method talks to a different provider. Every payment, whatever the method, must go through the same steps in the same order:

      1. Idempotency check. Clients retry on timeouts. A retried request with the same idempotency key must return the first result, not charge again.
      2. Fraud check. Payments over EUR 5000 are rejected, before any money is reserved.
      3. Authorize. Reserve the money. Method-specific.
      4. Capture. Take it. Method-specific. If it fails after a successful authorize, the reservation must be released (voided), or the customer sees a hold on their card for days.
      5. Record. Every outcome goes to the ledger for audit, including declines and failures.
      6. Notify. Send a receipt, but only when money moved.

      The order carries the guarantees. Fraud after capture means money is taken and then refunded. Idempotency after authorize means a retry reserves twice. A missing record step means an auditor can’t explain a charge. These rules can’t depend on three teams each getting them right, so the platform team owns them in a template, and teams write three methods.

      Hard: the platform owns the order, teams own three steps

      shared

      PaymentFlow

      +process(req) : Result final

      -charge(req)

      -timed(step, owner, body)

      #authorize(req) : Auth

      #capture(req, auth) : Status

      #voidAuthorization(auth)

      #notifyMessage(req, result) : String

      CardFlow

      BankTransferFlow

      #notifyMessage(req, result)

      WalletFlow

      FakeFlow

      IdempotencyStore

      +claim(key) : Result

      +complete(key, result)

      Platform

      +clock

      +ledger

      +outbox

      PaymentRequest

      +key

      +method

      +amount

      shared

      PaymentFlow

      +process(req) : Result final

      -charge(req)

      -timed(step, owner, body)

      #authorize(req) : Auth

      #capture(req, auth) : Status

      #voidAuthorization(auth)

      #notifyMessage(req, result) : String

      CardFlow

      BankTransferFlow

      #notifyMessage(req, result)

      WalletFlow

      FakeFlow

      IdempotencyStore

      +claim(key) : Result

      +complete(key, result)

      Platform

      +clock

      +ledger

      +outbox

      PaymentRequest

      +key

      +method

      +amount

      Scroll or pinch to zoom, drag to pan
      • Color key
      • Facade
      • Entity
      • Value object
      • Enum
      • Interface
      • Implementation
      A client retries pay-1 after a timeout
      LedgerCardFlowIdempotencyStorePaymentFlowClientLedgerCardFlowIdempotencyStorePaymentFlowClientthe response is lost, the client retriesprocess(pay-1, EUR 49)claim(pay-1)new: claimedfraud checkauthorizecapturerecord pay-1 CAPTUREDcomplete(pay-1, CAPTURED)process(pay-1, EUR 49)claim(pay-1)seen: CAPTUREDCAPTURED (stored result, no charge)LedgerCardFlowIdempotencyStorePaymentFlowClientLedgerCardFlowIdempotencyStorePaymentFlowClientthe response is lost, the client retriesprocess(pay-1, EUR 49)claim(pay-1)new: claimedfraud checkauthorizecapturerecord pay-1 CAPTUREDcomplete(pay-1, CAPTURED)process(pay-1, EUR 49)claim(pay-1)seen: CAPTUREDCAPTURED (stored result, no charge)
      Scroll or pinch to zoom, drag to pan
      • Color key
      • Database or Entity
      • Implementation
      • Facade

      Send payments through the pipeline. Retry with the same key, make capture fail, then let the subclass pick its own order:

      Method:
      Amount:

      Trace

        Send a payment.

        Ledger (audit)
          Customer charged0 times
          PaymentFlow.javapayment_flow.pyPaymentFlow.csfake_flow.gopayment-flow.ts
          import java.util.ArrayList;
          import java.util.List;
          import java.util.Map;
          import java.util.concurrent.ConcurrentHashMap;
          import java.util.function.Supplier;
          enum Status { CAPTURED, PENDING, DECLINED, REJECTED, FAILED, IN_PROGRESS }
          record PaymentRequest(String key, String method, long amount) {}
          record Result(Status status, String detail) {}
          record Auth(boolean approved, String id, String reason) {}
          record Span(String step, String owner, long ms) {}
          final class FakeClock {
          private long now;
          long now() { return now; }
          void advance(long ms) { now += ms; }
          }
          // Remembers every idempotency key. claim() is one atomic step, so two copies of the same
          // request arriving at once can't both get through: exactly one sees null.
          final class IdempotencyStore {
          private static final Result CLAIMED = new Result(Status.IN_PROGRESS, "another request with this key is running");
          private final Map<String, Result> results = new ConcurrentHashMap<>();
          Result claim(String key) { return results.putIfAbsent(key, CLAIMED); }
          void complete(String key, Result result) { results.put(key, result); }
          }
          // Shared by every flow: the clock, the idempotency store, the ledger and the outbox.
          final class Platform {
          final FakeClock clock = new FakeClock();
          final IdempotencyStore idempotency = new IdempotencyStore();
          final List<String> ledger = new ArrayList<>();
          final List<String> outbox = new ArrayList<>();
          }
          // The skeleton every payment method runs through, owned by the payments platform team.
          // Card, bank and wallet teams each write a subclass with three steps: authorize, capture
          // and void. Everything that must be true of every payment lives here.
          abstract class PaymentFlow {
          static final long FRAUD_LIMIT = 5000;
          protected final Platform platform;
          private List<Span> lastTrace = List.of();
          protected PaymentFlow(Platform platform) { this.platform = platform; }
          // The template method. final: no team can reorder the steps, skip idempotency or skip
          // the audit record, and every step is timed and traced the same way.
          public final Result process(PaymentRequest req) {
          List<Span> trace = new ArrayList<>();
          lastTrace = trace;
          // 1. Idempotency comes first, before any money moves. A duplicate returns the stored
          // result and is not charged, recorded or notified again.
          Result seen = timed(trace, "idempotency", "PaymentFlow", () -> {
          platform.clock.advance(2);
          return platform.idempotency.claim(req.key());
          });
          if (seen != null) {
          return seen.status() == Status.IN_PROGRESS ? seen : new Result(seen.status(), "duplicate, stored result");
          }
          Result result = null;
          try {
          result = charge(req, trace);
          } catch (RuntimeException e) {
          result = new Result(Status.FAILED, e.getMessage());
          } finally {
          // 5. The audit record runs however the steps ended. A subclass can't skip it.
          Result outcome = result != null ? result : new Result(Status.FAILED, "unknown");
          timed(trace, "record", "PaymentFlow", () -> {
          platform.clock.advance(5);
          platform.ledger.add(req.key() + " " + outcome.status());
          platform.idempotency.complete(req.key(), outcome);
          return null;
          });
          }
          // 6. Notify only when money moved. The message is a hook with a default.
          if (result.status() == Status.CAPTURED || result.status() == Status.PENDING) {
          Result r = result;
          timed(trace, "notify", owner("notify"), () -> {
          platform.clock.advance(10);
          platform.outbox.add(notifyMessage(req, r));
          return null;
          });
          }
          return result;
          }
          private Result charge(PaymentRequest req, List<Span> trace) {
          // 2. Fraud check. Added in version 2 of the skeleton as a base step, so no team
          // had to change their subclass to get it.
          String risk = timed(trace, "fraud", "PaymentFlow", () -> {
          platform.clock.advance(15);
          return req.amount() > FRAUD_LIMIT ? "over EUR " + FRAUD_LIMIT + " limit" : null;
          });
          if (risk != null) return new Result(Status.REJECTED, "fraud: " + risk);
          // 3. Authorize: reserve the money. The subclass's step.
          Auth auth = timed(trace, "authorize", owner("authorize"), () -> authorize(req));
          if (!auth.approved()) return new Result(Status.DECLINED, auth.reason());
          // 4. Capture: take the money. If it fails after a successful authorize, the base
          // class releases the reservation, so no team has to remember to.
          try {
          Status status = timed(trace, "capture", owner("capture"), () -> capture(req, auth));
          return new Result(status, "");
          } catch (RuntimeException e) {
          timed(trace, "void", owner("voidAuthorization"), () -> { voidAuthorization(auth); return null; });
          return new Result(Status.FAILED, "capture failed: " + e.getMessage() + ", authorization voided");
          }
          }
          // Observability lives in the base class: every step gets a span with its duration and
          // owner, even when it throws, without any team writing timing code.
          private <T> T timed(List<Span> trace, String step, String owner, Supplier<T> body) {
          long start = platform.clock.now();
          try {
          return body.get();
          } finally {
          trace.add(new Span(step, owner, platform.clock.now() - start));
          }
          }
          // Which class wrote a step, for the trace. Abstract steps always come from the subclass;
          // the hook comes from the subclass only if it overrides it.
          private String owner(String method) {
          if (!method.equals("notify")) return getClass().getSimpleName();
          try {
          getClass().getDeclaredMethod("notifyMessage", PaymentRequest.class, Result.class);
          return getClass().getSimpleName();
          } catch (NoSuchMethodException e) {
          return "PaymentFlow";
          }
          }
          List<Span> lastTrace() { return lastTrace; }
          // Abstract steps: what differs per payment method.
          protected abstract Auth authorize(PaymentRequest req);
          protected abstract Status capture(PaymentRequest req, Auth auth);
          protected abstract void voidAuthorization(Auth auth);
          // Hook with a default. Bank transfers override it; cards and wallets don't need to.
          protected String notifyMessage(PaymentRequest req, Result result) { return req.key() + " receipt"; }
          }
          class CardFlow extends PaymentFlow {
          int charges;
          CardFlow(Platform p) { super(p); }
          protected Auth authorize(PaymentRequest req) {
          platform.clock.advance(120);
          return new Auth(true, "auth-" + req.key(), "");
          }
          protected Status capture(PaymentRequest req, Auth auth) {
          platform.clock.advance(80);
          charges++;
          return Status.CAPTURED;
          }
          protected void voidAuthorization(Auth auth) { platform.clock.advance(40); }
          }
          class BankTransferFlow extends PaymentFlow {
          BankTransferFlow(Platform p) { super(p); }
          protected Auth authorize(PaymentRequest req) {
          platform.clock.advance(60); // check the customer's direct debit mandate
          return new Auth(true, "mandate-" + req.key(), "");
          }
          protected Status capture(PaymentRequest req, Auth auth) {
          platform.clock.advance(200); // start the transfer; it settles in a day or two
          return Status.PENDING;
          }
          protected void voidAuthorization(Auth auth) { platform.clock.advance(40); }
          @Override
          protected String notifyMessage(PaymentRequest req, Result result) {
          return req.key() + " transfer started (1-2 business days)";
          }
          }
          class WalletFlow extends PaymentFlow {
          private long balance = 50;
          WalletFlow(Platform p) { super(p); }
          protected Auth authorize(PaymentRequest req) {
          platform.clock.advance(30);
          if (req.amount() > balance) return new Auth(false, "", "insufficient balance");
          return new Auth(true, "hold-" + req.key(), "");
          }
          protected Status capture(PaymentRequest req, Auth auth) {
          platform.clock.advance(20);
          balance -= req.amount();
          return Status.CAPTURED;
          }
          protected void voidAuthorization(Auth auth) { platform.clock.advance(40); }
          }
          // A test double. The template is tested once, with this fake, instead of once per team:
          // does a failed capture void the authorization and still write the audit record?
          class FakeFlow extends PaymentFlow {
          final List<String> calls = new ArrayList<>();
          FakeFlow(Platform p) { super(p); }
          protected Auth authorize(PaymentRequest req) { calls.add("authorize"); platform.clock.advance(10); return new Auth(true, "fake", ""); }
          protected Status capture(PaymentRequest req, Auth auth) { calls.add("capture"); platform.clock.advance(10); throw new IllegalStateException("network error"); }
          protected void voidAuthorization(Auth auth) { calls.add("void"); platform.clock.advance(5); }
          }
          class Main {
          static void show(PaymentFlow flow, PaymentRequest req) {
          Result r = flow.process(req);
          System.out.println(req.key() + " " + req.method() + " EUR " + req.amount() + ": " + r.status()
          + (r.detail().isEmpty() ? "" : " (" + r.detail() + ")"));
          }
          public static void main(String[] args) {
          Platform platform = new Platform();
          CardFlow card = new CardFlow(platform);
          BankTransferFlow bank = new BankTransferFlow(platform);
          WalletFlow wallet = new WalletFlow(platform);
          FakeFlow fake = new FakeFlow(platform);
          show(card, new PaymentRequest("pay-1", "card", 49));
          for (Span s : card.lastTrace()) System.out.printf(" %-12s%4d ms %s%n", s.step(), s.ms(), s.owner());
          // pay-1 card EUR 49: CAPTURED
          // idempotency 2 ms PaymentFlow
          // fraud 15 ms PaymentFlow
          // authorize 120 ms CardFlow
          // capture 80 ms CardFlow
          // record 5 ms PaymentFlow
          // notify 10 ms PaymentFlow
          // The client timed out and retried with the same key. The base class stops it.
          show(card, new PaymentRequest("pay-1", "card", 49));
          // pay-1 card EUR 49: CAPTURED (duplicate, stored result)
          show(bank, new PaymentRequest("pay-2", "bank", 900));
          // pay-2 bank EUR 900: PENDING
          show(wallet, new PaymentRequest("pay-3", "wallet", 75));
          // pay-3 wallet EUR 75: DECLINED (insufficient balance)
          show(card, new PaymentRequest("pay-4", "card", 9000));
          // pay-4 card EUR 9000: REJECTED (fraud: over EUR 5000 limit)
          show(fake, new PaymentRequest("pay-5", "fake", 10));
          // pay-5 fake EUR 10: FAILED (capture failed: network error, authorization voided)
          System.out.println("fake calls: " + String.join(", ", fake.calls));
          // fake calls: authorize, capture, void
          // Another server has claimed pay-6 and is still working on it.
          platform.idempotency.claim("pay-6");
          show(card, new PaymentRequest("pay-6", "card", 20));
          // pay-6 card EUR 20: IN_PROGRESS (another request with this key is running)
          System.out.println("ledger: " + String.join(", ", platform.ledger));
          // ledger: pay-1 CAPTURED, pay-2 PENDING, pay-3 DECLINED, pay-4 REJECTED, pay-5 FAILED
          System.out.println("outbox: " + String.join(", ", platform.outbox));
          // outbox: pay-1 receipt, pay-2 transfer started (1-2 business days)
          System.out.println("card charges: " + card.charges);
          // card charges: 1
          }
          }
          • Invariants live in the template, where no subclass can skip them. Idempotency is the first step and recording runs in finally. A team’s capture can throw anything, and the ledger still gets a row. Teams don’t even see these steps.
          • The idempotency claim is atomic. Say you check “have I seen this key?”, and then store it, as two steps. Two copies of a request that arrive together can both pass. claim does it in one step: putIfAbsent in Java, TryAdd in C#, a lock in Python and Go. TypeScript runs one callback at a time. So it’s atomic, as long as there’s no await between the check and the store. A second copy that arrives while the first is running gets IN_PROGRESS, and should retry later. pay-6 shows that. Across several servers, the same rule means a unique key in the database, not a map in memory.
          • Compensation is in the base class too. When capture fails after authorize, the template calls the subclass’s voidAuthorization. Each team writes how to void. The platform decides that it happens. In the demo’s rogue order, nobody voids, and the hold stays open.
          • The base times and traces every step. timed wraps each step, and records a span with its owner, even when the step throws. Teams get consistent traces and per-step latency for free. An on-call engineer can see that authorize in CardFlow took 120 ms, without asking the card team to add logging. In production, these spans go to a tracing system such as OpenTelemetry, instead of a list.
          • Test the template once, with a fake subclass. FakeFlow scripts a capture failure, and records the calls it receives: authorize, capture, void, then a FAILED row in the ledger. That one test covers the rule for every team. Each team then tests only its own three methods.
          • Versioning the skeleton. The fraud step was added in version 2 of the template, as a base step. So no team changed a line. New behavior goes in as a fixed step, or as a hook with a safe default. A new abstract method breaks every team’s build on the same day. So it needs a migration. Add it as a hook with a default first. Then make it abstract a release later, once every team implements it.
          • Hooks only where a variation is real. Only bank transfers override notifyMessage, because their money arrives in a day or two. Everything else uses the default.

          When variations multiply, move to a pipeline

          Section titled “When variations multiply, move to a pipeline”

          A template method fits while every method has the same steps. It strains when they don’t. A wallet needs a top-up step. Card payments in Europe need a 3-D Secure step. Bank transfers skip capture entirely. And each new variation becomes another hook with a flag. At that point, make each step an object with one method, run(context). Build the list of steps per payment method. The platform keeps the invariants by building the list itself. It always puts idempotency first, and recording last (in a finally). Teams supply only the steps in between.

          Template method Pipeline of composed steps
          The order is Fixed in one method A list, built per method
          Adding a step for one method A hook everyone inherits Insert it in that method’s list
          Invariants enforced by final and private steps The builder that always adds them first and last
          Reading one flow Read the base class and one subclass Read the list
          Fits 3 to 5 variants with the same steps Many variants, or steps that come and go
          Interviewer

          Three teams own card, bank transfer and wallet payments. Design the processing so that no team can charge a customer twice, skip the fraud check or leave a payment out of the audit log.

          What they are testing

          Whether you put the order and the invariants in a final template, or in a pipeline the platform builds. Whether you claim the idempotency key atomically, before any money moves. Whether you record in finally, void on capture failure, and test the skeleton once with a fake. The red flag is asking each team to implement the full flow and follow a checklist. So is a check-then-insert idempotency test that two retries at the same time can both pass.

          Say this out loud

          The order of the steps is what makes payments safe. So the platform owns it, in a final process method. Claim the idempotency key atomically. Check fraud. Then run the team’s authorize and capture, and void if capture fails. Record in a finally block, and notify. Teams implement three methods. The base times every step, and I test the skeleton once with a fake.

          Use a template method when several variants share a fixed sequence, and differ in a few steps. It fits best when the sequence carries guarantees (cleanup, audit, ordering), and when you’re writing a framework that calls user code. Pass the steps in as functions or strategies in three other cases. Variants need to mix. The steps must change at run time. Or the base class would exist only to hold one method. In new application code, that second option is usually the default. Template method stays common in frameworks and libraries.

          Language Usual form Built in
          Java abstract class, final template method, protected abstract steps, hooks as protected methods with a default AbstractList (write get and size), HttpServlet.service dispatching to doGet and doPost, InputStream.read(byte[]) built on read()
          Python abc.ABC with @abstractmethod; @typing.final is checked by type checkers only unittest.TestCase (setUp, the test, tearDown), socketserver.BaseRequestHandler (setup, handle, finish), collections.abc.Mapping building get and items on __getitem__, __iter__ and __len__
          C# abstract class; methods are non-virtual by default, so the template can’t be overridden; hooks are virtual; sealed override stops further overriding Stream (ReadByte built on Read), BackgroundService (you write ExecuteAsync)
          Go No inheritance: a struct or function holds an interface or func fields; optional hooks are optional interfaces checked with a type assertion sort.Sort with sort.Interface (Len, Less, Swap); io.Copy uses WriterTo or ReaderFrom if the arguments implement them
          TypeScript abstract class with abstract methods; no final; noImplicitOverride requires override on every override React class components (render, componentDidMount), custom elements’ connectedCallback

          Go deserves a note. Embedding a base struct looks like inheritance, but it isn’t: if Base.Run calls b.step(), it calls Base’s own step, never the outer type’s. So Go can’t express the class-based version at all, and every Go example on this page is the composition version. sort.Sort is the textbook case: the algorithm is fixed, and you supply three methods.

          Pattern Looks like Template method because The difference
          Strategy Both let part of an algorithm vary Template method varies steps through inheritance, fixed when the class is written. Strategy swaps a whole behavior through composition, at run time. Passing each step in as a strategy, as in the Medium level’s JobRunner, is the composition form of a template method.
          Factory method Both are an abstract method a base class calls A factory method is one step of a template method whose job is to create an object. The factory page’s Campaign.run() is a template method; createNotifier() is its factory method step.
          Hooks and callbacks Hook is even the name of one kind of step A callback or event hook (an onSave listener) is registered at run time and called whenever something happens, often by many listeners. A template method’s hook is one overridable step at a fixed point in one algorithm.
          Builder’s director A director also calls steps in a fixed order A director calls the steps of a builder object it is given, to assemble a product. A template method calls steps on itself, implemented by its subclasses. The director is the composition-based relative.
          Observer Both “call you back” Observers are notified of events and the subject doesn’t care how many there are or what they do. A template method’s steps are part of the algorithm and their results are used.

          On this site:

          • Factory: the factory method example is a small template method whose one varying step creates the notifier.
          • Strategy: the composition alternative used in the Medium level.
          • Liskov substitution: a subclass’s steps must honor the contract the template relies on, such as “tearDown never throws”.

          In real systems, you’ll find template methods in:

          • Test frameworks (setUp and tearDown around every test).
          • Servlet and web framework base classes.
          • Spring’s JdbcTemplate. Despite the name, it takes callbacks, which is the composition form.
          • Collection base classes like AbstractList and collections.abc.Mapping.
          • Build tools’ task lifecycles, and every background job or worker base class.
          Interviewer asksWhat's the difference between Template method and Strategy?

          Template method fixes an algorithm’s steps in a base class, and lets subclasses fill some of them in. It uses inheritance, so the choice is made when the class is written. Strategy holds an object that does a whole job. It uses composition, so it can change at run time. You can turn a template method into composition, by passing each step in as a strategy or function.

          Interviewer asksWhy make the template method final?

          Because the order of the steps is the guarantee. If a subclass can override it, it can skip cleanup, retries or an audit step. Then the base class’s promise holds only for the subclasses that chose to keep it. In languages without final, enforce it at run time. Or switch to a runner that subclasses can’t bypass.

          Interviewer asksWhat's the fragile base class problem?

          A change to a base class that looks safe breaks subclasses quietly. Three common ones: reordering steps; calling a new hook whose name a subclass already uses for something else; and calling an overridable method from a constructor, before the subclass is initialized. That’s why a base class with outside subclasses must be designed and documented as an API. Or inheriting from it should be forbidden.

          Interviewer asksWhat is the Hollywood principle?

          “Don’t call us, we’ll call you.” High-level code (a framework, a base class) calls low-level code (your subclass, your callback), not the other way round. Template method is the class-based form of it. Dependency injection and event callbacks are other forms.

          Interviewer asksWhen would you choose composition over a template method?

          When variants need to mix. When steps should change at run time. When there’s only one varying step. Or when the language has no inheritance (Go). Also when one team owns the base class and many extend it. Passed-in steps can’t override the lifecycle, so the guarantee doesn’t depend on final.

          Decision We chose We gave up
          Where the order lives One template method, final or non-virtual Variants can’t change the order
          What subclasses write Abstract steps, plus hooks only if needed Reading a variant means reading two classes
          Cleanup tearDown in finally, called by the base Teardown must tolerate a failed attempt
          Subclass or pass steps in Subclass inside a framework; composition in new code Shared protected state, or a compiler-checked abstract step
          Payment invariants Idempotency first, record in finally, void on failure, all in the base Teams depend on the platform team’s release
          Duplicate requests An atomic claim per key, IN_PROGRESS while running A store that must be shared and durable
          Changing the skeleton New steps as base steps or hooks with safe defaults Abstract steps take a migration
          Many variations A pipeline of composed steps The order is now data, not one readable method
          • Strategy, the composition alternative
          • Factory, for factory method, a template method step that creates
          • Builder, whose director runs steps on another object
          • Open-closed principle and Liskov substitution
          • Joshua Bloch, Effective Java, Item 18, “Favor composition over inheritance”, and Item 19, “Design and document for inheritance or else prohibit it”
          • Erich Gamma, Richard Helm, Ralph Johnson and John Vlissides, Design Patterns, the Template Method chapter