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.
- Read first
- Strategy
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 shape
Section titled “The shape”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.
- 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.
Three kinds of step
Section titled “Three kinds of step”| 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 same idea with composition
Section titled “The same idea with composition”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.
Worked examples
Section titled “Worked examples”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.
Without the pattern
Section titled “Without the pattern”Each importer was written as its own class, the second one by copying the first:
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 }}import jsonfrom dataclasses import dataclass
@dataclassclass Customer: name: str email: str
# Stand-ins for the file system and the customer table, so the demo is self-contained.FILES = { "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"}]',}DB: list[Customer] = []
# 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: def run(self, path: str) -> None: text = FILES[path] print(f"[csv] read {path}") rows = [] for line in text.split("\n")[1:]: # line 0 is the header name, email = line.split(",") rows.append(Customer(name, email)) print(f"[csv] parsed {len(rows)} rows") valid = [] for i, row in enumerate(rows, start=1): if not row.email: print(f"[csv] row {i} skipped, missing email") else: valid.append(row) DB.extend(valid) print(f"[csv] saved {len(valid)} rows") print(f"[csv] {len(valid)} saved, {len(rows) - len(valid)} skipped")
class JsonImporter: def run(self, path: str) -> None: text = FILES[path] print(f"[json] read {path}") rows = [Customer(o["name"], o["email"].lower()) for o in json.loads(text)] print(f"[json] parsed {len(rows)} rows") # The flaw: validation was never copied here, so a customer with no email is saved. DB.extend(rows) print(f"[json] saved {len(rows)} rows") # And the report line drifted, so the ops dashboard can't read both. print(f"[json] imported {len(rows)}")
CsvImporter().run("customers.csv")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 3print("db: " + ", ".join(c.name for c in DB)) # db: ada, cyd, dee, eve, fayusing System;using System.Collections.Generic;using System.Linq;using System.Text.Json;
public record Customer(string Name, string Email);
// Stand-ins for the file system and the customer table, so the demo is self-contained.public static class Files{ public static readonly Dictionary<string, string> Content = new() { ["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\"}]", };}
public static class Db{ public static readonly List<Customer> Customers = new();}
// 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.public class CsvImporter{ public void Run(string path) { var text = Files.Content[path]; Console.WriteLine($"[csv] read {path}"); var rows = new List<Customer>(); foreach (var line in text.Split('\n').Skip(1)) // line 0 is the header { var cols = line.Split(','); rows.Add(new Customer(cols[0], cols[1])); } Console.WriteLine($"[csv] parsed {rows.Count} rows"); var valid = new List<Customer>(); for (int i = 0; i < rows.Count; i++) { if (rows[i].Email == "") Console.WriteLine($"[csv] row {i + 1} skipped, missing email"); else valid.Add(rows[i]); } Db.Customers.AddRange(valid); Console.WriteLine($"[csv] saved {valid.Count} rows"); Console.WriteLine($"[csv] {valid.Count} saved, {rows.Count - valid.Count} skipped"); }}
public class JsonImporter{ public void Run(string path) { var text = Files.Content[path]; Console.WriteLine($"[json] read {path}"); var rows = JsonDocument.Parse(text).RootElement.EnumerateArray() .Select(o => new Customer(o.GetProperty("name").GetString()!, o.GetProperty("email").GetString()!.ToLowerInvariant())) .ToList(); Console.WriteLine($"[json] parsed {rows.Count} rows"); // The flaw: validation was never copied here, so a customer with no email is saved. Db.Customers.AddRange(rows); Console.WriteLine($"[json] saved {rows.Count} rows"); // And the report line drifted, so the ops dashboard can't read both. Console.WriteLine($"[json] imported {rows.Count}"); }}
public static class Program{ public static void Main() { 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 Console.WriteLine("db: " + string.Join(", ", Db.Customers.Select(c => c.Name))); // db: ada, cyd, dee, eve, fay }}package main
import ( "encoding/json" "fmt" "strings")
type Customer struct { Name string `json:"name"` Email string `json:"email"`}
// Stand-ins for the file system and the customer table, so the demo is self-contained.var files = map[string]string{ "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"}]`,}
var db []Customer
// 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.type CsvImporter struct{}
func (CsvImporter) Run(path string) { text := files[path] fmt.Println("[csv] read " + path) var rows []Customer for _, line := range strings.Split(text, "\n")[1:] { // line 0 is the header cols := strings.Split(line, ",") rows = append(rows, Customer{cols[0], cols[1]}) } fmt.Printf("[csv] parsed %d rows\n", len(rows)) var valid []Customer for i, row := range rows { if row.Email == "" { fmt.Printf("[csv] row %d skipped, missing email\n", i+1) } else { valid = append(valid, row) } } db = append(db, valid...) fmt.Printf("[csv] saved %d rows\n", len(valid)) fmt.Printf("[csv] %d saved, %d skipped\n", len(valid), len(rows)-len(valid))}
type JsonImporter struct{}
func (JsonImporter) Run(path string) { text := files[path] fmt.Println("[json] read " + path) var rows []Customer if err := json.Unmarshal([]byte(text), &rows); err != nil { panic(err) } for i := range rows { rows[i].Email = strings.ToLower(rows[i].Email) } fmt.Printf("[json] parsed %d rows\n", len(rows)) // The flaw: validation was never copied here, so a customer with no email is saved. db = append(db, rows...) fmt.Printf("[json] saved %d rows\n", len(rows)) // And the report line drifted, so the ops dashboard can't read both. fmt.Printf("[json] imported %d\n", len(rows))}
func main() { CsvImporter{}.Run("customers.csv") 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 var names []string for _, c := range db { names = append(names, c.Name) } fmt.Println("db: " + strings.Join(names, ", ")) // db: ada, cyd, dee, eve, fay}interface Customer { name: string; email: string;}
// Stand-ins for the file system and the customer table, so the demo is self-contained.const files: Record<string, string> = { '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"}]',};const db: Customer[] = [];
// 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 { run(path: string): void { const text = files[path]; console.log(`[csv] read ${path}`); const rows = text .split('\n') .slice(1) // line 0 is the header .map((line) => { const [name, email] = line.split(','); return { name, email }; }); console.log(`[csv] parsed ${rows.length} rows`); const valid: Customer[] = []; rows.forEach((row, i) => { if (!row.email) console.log(`[csv] row ${i + 1} skipped, missing email`); else valid.push(row); }); db.push(...valid); console.log(`[csv] saved ${valid.length} rows`); console.log(`[csv] ${valid.length} saved, ${rows.length - valid.length} skipped`); }}
class JsonImporter { run(path: string): void { const text = files[path]; console.log(`[json] read ${path}`); const rows = (JSON.parse(text) as Customer[]).map((o) => ({ name: o.name, email: o.email.toLowerCase() })); console.log(`[json] parsed ${rows.length} rows`); // The flaw: validation was never copied here, so a customer with no email is saved. db.push(...rows); console.log(`[json] saved ${rows.length} rows`); // And the report line drifted, so the ops dashboard can't read both. console.log(`[json] imported ${rows.length}`); }}
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 3console.log('db: ' + db.map((c) => c.name).join(', ')); // db: ada, cyd, dee, eve, fayThe 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.
With Template method
Section titled “With Template method”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:
- 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:
customers.csv
Steps, in the order run() calls them
Pick an importer and run it.
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 }}import jsonfrom abc import ABC, abstractmethodfrom dataclasses import dataclassfrom typing import final
@dataclassclass Customer: name: str email: str
# Stand-ins for the file system and the customer table, so the demo is self-contained.FILES = { "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"}]',}DB: list[Customer] = []
# The base class owns the algorithm. Subclasses fill in the one step that differs.class Importer(ABC): label = ""
# The template method. Python can't stop an override at run time; @final asks type # checkers (mypy, pyright) to reject one, so every format is validated and reported # the same way. @final def run(self, path: str) -> None: text = self._read(path) rows = self.parse(text) # the subclass's step valid = self._validate(rows) valid = self.before_save(valid) # a hook: optional, with a default self._save(valid) self._report(len(rows), len(valid))
# Abstract step: every importer must say how to turn text into rows. @abstractmethod def parse(self, text: str) -> list[Customer]: ...
# Hook: a subclass may override it. The default does nothing, so most don't. def before_save(self, rows: list[Customer]) -> list[Customer]: return rows
# Fixed steps. The underscore marks them as the base class's own. def _read(self, path: str) -> str: self.log(f"read {path} (Importer)") return FILES[path]
def _validate(self, rows: list[Customer]) -> list[Customer]: valid = [] for i, row in enumerate(rows, start=1): if not row.email: self.log(f"validate: row {i} skipped, missing email (Importer)") else: valid.append(row) return valid
def _save(self, rows: list[Customer]) -> None: DB.extend(rows) self.log(f"save: {len(rows)} rows (Importer)")
def _report(self, total: int, saved: int) -> None: self.log(f"report: {saved} saved, {total - saved} skipped (Importer)")
def log(self, line: str) -> None: print(f"[{self.label}] {line}")
# A subclass is now only what is different about its format.class CsvImporter(Importer): label = "csv"
def parse(self, text: str) -> list[Customer]: rows = [] for line in text.split("\n")[1:]: # line 0 is the header name, email = line.split(",") rows.append(Customer(name, email)) self.log(f"parse: {len(rows)} rows (CsvImporter)") return rows
class JsonImporter(Importer): label = "json"
def parse(self, text: str) -> list[Customer]: rows = [Customer(o["name"], o["email"]) for o in json.loads(text)] self.log(f"parse: {len(rows)} 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. def before_save(self, rows: list[Customer]) -> list[Customer]: changed = sum(1 for c in rows if c.email != c.email.lower()) self.log(f"beforeSave: {changed} email lowercased (JsonImporter)") return [Customer(c.name, c.email.lower()) for c in rows]
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)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)print("db: " + ", ".join(c.name for c in DB)) # db: ada, cyd, dee, fayusing System;using System.Collections.Generic;using System.Linq;using System.Text.Json;
public record Customer(string Name, string Email);
// Stand-ins for the file system and the customer table, so the demo is self-contained.public static class Files{ public static readonly Dictionary<string, string> Content = new() { ["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\"}]", };}
public static class Db{ public static readonly List<Customer> Customers = new();}
// The base class owns the algorithm. Subclasses fill in the one step that differs.public abstract class Importer{ // The template method. C# methods are not virtual unless marked, so no subclass can // override Run: every format is validated and reported the same way. public void Run(string path) { var text = Read(path); var rows = Parse(text); // the subclass's step var valid = Validate(rows); valid = BeforeSave(valid); // a hook: optional, with a default Save(valid); Report(rows.Count, valid.Count); }
// 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 { get; }
// Hook: a subclass may override it. The default does nothing, so most don't. protected virtual List<Customer> BeforeSave(List<Customer> rows) => rows;
// Fixed steps. They are private: written once, the same for every format. private string Read(string path) { Log($"read {path} (Importer)"); return Files.Content[path]; }
private List<Customer> Validate(List<Customer> rows) { var valid = new List<Customer>(); for (int i = 0; i < rows.Count; i++) { if (rows[i].Email == "") Log($"validate: row {i + 1} skipped, missing email (Importer)"); else valid.Add(rows[i]); } return valid; }
private void Save(List<Customer> rows) { Db.Customers.AddRange(rows); Log($"save: {rows.Count} rows (Importer)"); }
private void Report(int total, int saved) => Log($"report: {saved} saved, {total - saved} skipped (Importer)");
protected void Log(string line) => Console.WriteLine($"[{Label}] {line}");}
// A subclass is now only what is different about its format.public class CsvImporter : Importer{ protected override string Label => "csv";
protected override List<Customer> Parse(string text) { var rows = new List<Customer>(); foreach (var line in text.Split('\n').Skip(1)) // line 0 is the header { var cols = line.Split(','); rows.Add(new Customer(cols[0], cols[1])); } Log($"parse: {rows.Count} rows (CsvImporter)"); return rows; }}
public class JsonImporter : Importer{ protected override string Label => "json";
protected override List<Customer> Parse(string text) { var rows = JsonDocument.Parse(text).RootElement.EnumerateArray() .Select(o => new Customer(o.GetProperty("name").GetString()!, o.GetProperty("email").GetString()!)) .ToList(); Log($"parse: {rows.Count} 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. protected override List<Customer> BeforeSave(List<Customer> rows) { var changed = rows.Count(c => c.Email != c.Email.ToLowerInvariant()); Log($"beforeSave: {changed} email lowercased (JsonImporter)"); return rows.Select(c => c with { Email = c.Email.ToLowerInvariant() }).ToList(); }}
public static class Program{ public static void Main() { 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) Console.WriteLine("db: " + string.Join(", ", Db.Customers.Select(c => c.Name))); // db: ada, cyd, dee, fay }}package main
import ( "encoding/json" "fmt" "strings")
type Customer struct { Name string `json:"name"` Email string `json:"email"`}
// Stand-ins for the file system and the customer table, so the demo is self-contained.var files = map[string]string{ "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"}]`,}
var db []Customer
// Go has no inheritance, so there is no base class to extend. The same pattern is built by// composition: Importer owns the algorithm and holds the part that varies behind an interface.
// Format is the abstract step: every format must say how to turn text into rows.type Format interface { Label() string Parse(text string, log func(string)) []Customer}
// BeforeSaver is the hook. It is a separate, optional interface: a format that doesn't// implement it gets the default, which is to do nothing.type BeforeSaver interface { BeforeSave(rows []Customer, log func(string)) []Customer}
// Importer is the template. Its Run is the only way in, and a Format can't change the order// of the steps or skip one, because it never sees them.type Importer struct{ Format Format }
func (im Importer) Run(path string) { log := func(line string) { fmt.Printf("[%s] %s\n", im.Format.Label(), line) } text := im.read(path, log) rows := im.Format.Parse(text, log) // the format's step valid := im.validate(rows, log) if h, ok := im.Format.(BeforeSaver); ok { // the hook, only if implemented valid = h.BeforeSave(valid, log) } im.save(valid, log) log(fmt.Sprintf("report: %d saved, %d skipped (Importer)", len(valid), len(rows)-len(valid)))}
// Fixed steps, written once, the same for every format.func (Importer) read(path string, log func(string)) string { log("read " + path + " (Importer)") return files[path]}
func (Importer) validate(rows []Customer, log func(string)) []Customer { var valid []Customer for i, row := range rows { if row.Email == "" { log(fmt.Sprintf("validate: row %d skipped, missing email (Importer)", i+1)) } else { valid = append(valid, row) } } return valid}
func (Importer) save(rows []Customer, log func(string)) { db = append(db, rows...) log(fmt.Sprintf("save: %d rows (Importer)", len(rows)))}
// A format is now only what is different about it. The names match the other languages.type CsvImporter struct{}
func (CsvImporter) Label() string { return "csv" }
func (CsvImporter) Parse(text string, log func(string)) []Customer { var rows []Customer for _, line := range strings.Split(text, "\n")[1:] { // line 0 is the header cols := strings.Split(line, ",") rows = append(rows, Customer{cols[0], cols[1]}) } log(fmt.Sprintf("parse: %d rows (CsvImporter)", len(rows))) return rows}
type JsonImporter struct{}
func (JsonImporter) Label() string { return "json" }
func (JsonImporter) Parse(text string, log func(string)) []Customer { var rows []Customer if err := json.Unmarshal([]byte(text), &rows); err != nil { panic(err) } log(fmt.Sprintf("parse: %d rows (JsonImporter)", len(rows))) return rows}
// This partner sends mixed-case emails, so this format implements the hook to clean them.// It runs after validation, because Importer decided that order.func (JsonImporter) BeforeSave(rows []Customer, log func(string)) []Customer { changed := 0 out := make([]Customer, 0, len(rows)) for _, c := range rows { lower := strings.ToLower(c.Email) if lower != c.Email { changed++ } out = append(out, Customer{c.Name, lower}) } log(fmt.Sprintf("beforeSave: %d email lowercased (JsonImporter)", changed)) return out}
func main() { Importer{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) Importer{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) var names []string for _, c := range db { names = append(names, c.Name) } fmt.Println("db: " + strings.Join(names, ", ")) // db: ada, cyd, dee, fay}interface Customer { name: string; email: string;}
// Stand-ins for the file system and the customer table, so the demo is self-contained.const files: Record<string, string> = { '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"}]',};const db: Customer[] = [];
// The base class owns the algorithm. Subclasses fill in the one step that differs.abstract class Importer { protected abstract readonly label: string;
// The template method. TypeScript has no `final`, so nothing stops a subclass from // overriding run(). The comment and code review are the only guard; see the page. run(path: string): void { const text = this.read(path); const rows = this.parse(text); // the subclass's step let valid = this.validate(rows); valid = this.beforeSave(valid); // a hook: optional, with a default this.save(valid); this.report(rows.length, valid.length); }
// Abstract step: every importer must say how to turn text into rows. protected abstract parse(text: string): Customer[];
// Hook: a subclass may override it. The default does nothing, so most don't. protected beforeSave(rows: Customer[]): Customer[] { return rows; }
// Fixed steps. They are private: written once, the same for every format. private read(path: string): string { this.log(`read ${path} (Importer)`); return files[path]; }
private validate(rows: Customer[]): Customer[] { const valid: Customer[] = []; rows.forEach((row, i) => { if (!row.email) this.log(`validate: row ${i + 1} skipped, missing email (Importer)`); else valid.push(row); }); return valid; }
private save(rows: Customer[]): void { db.push(...rows); this.log(`save: ${rows.length} rows (Importer)`); }
private report(total: number, saved: number): void { this.log(`report: ${saved} saved, ${total - saved} skipped (Importer)`); }
protected log(line: string): void { console.log(`[${this.label}] ${line}`); }}
// A subclass is now only what is different about its format.class CsvImporter extends Importer { protected readonly label = 'csv';
protected parse(text: string): Customer[] { const rows = text .split('\n') .slice(1) // line 0 is the header .map((line) => { const [name, email] = line.split(','); return { name, email }; }); this.log(`parse: ${rows.length} rows (CsvImporter)`); return rows; }}
class JsonImporter extends Importer { protected readonly label = 'json';
protected parse(text: string): Customer[] { const rows = (JSON.parse(text) as Customer[]).map((o) => ({ name: o.name, email: o.email })); this.log(`parse: ${rows.length} 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. protected override beforeSave(rows: Customer[]): Customer[] { const changed = rows.filter((c) => c.email !== c.email.toLowerCase()).length; this.log(`beforeSave: ${changed} email lowercased (JsonImporter)`); return rows.map((c) => ({ name: c.name, email: c.email.toLowerCase() })); }}
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)console.log('db: ' + db.map((c) => c.name).join(', ')); // db: ada, cyd, dee, fayWhat to notice
Section titled “What to notice”- 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.
CsvImporteris 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
JsonImporteroverridesbeforeSave, 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
saveearly or overridevalidatewith a version that lets everything through. - Each language guards
run()differently. Java hasfinal. C# methods aren’t virtual unless marked, soRunis safe by default. Python’s@typing.finalis checked by type checkers such as mypy, not at run time. TypeScript has nofinalat all. Go has no inheritance, so itsImporterholds aFormatinterface and checks for the optionalBeforeSaverhook with a type assertion. Same pattern, built from composition.
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?
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.
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.
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.
When the template can be overridden
Section titled “When the template can be overridden”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():
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 }}from abc import ABC, abstractmethod
class FakeClock: def __init__(self) -> None: self._now = 0
def now(self) -> int: return self._now
def advance(self, ms: int) -> None: self._now += ms
TEMP_RESOURCES: dict[str, None] = {}
# The same base class as the fixed version, minus the guard: run() can be overridden.class ScheduledJob(ABC): BACKOFF_MS = 100
def __init__(self, name: str, max_attempts: int, clock: FakeClock) -> None: self._name = name self._max_attempts = max_attempts self.clock = clock
# Overridable. The guarantees below (retries, timing, tear_down in finally) hold only # for subclasses that choose not to replace this method. def run(self) -> bool: start = self.clock.now() error = None attempt = 0 self.set_up() try: while attempt < self._max_attempts: attempt += 1 try: result = self.execute() self.log(f"attempt {attempt} ok: {result}") error = None break except Exception as e: error = str(e) self.log(f"attempt {attempt} failed: {error}") if attempt < self._max_attempts: self.clock.advance(self.BACKOFF_MS) finally: self.tear_down() ms = self.clock.now() - start tries = f"{attempt} attempt" + ("" if attempt == 1 else "s") if error is None: self.log(f"OK in {ms} ms after {tries}") else: self.log(f"FAILED in {ms} ms after {tries}: {error}") return error is None
@abstractmethod def execute(self) -> str: ...
def set_up(self) -> None: pass
def tear_down(self) -> None: pass
def log(self, line: str) -> None: print(f"[{self._name}] {line}")
class ReindexJob(ScheduledJob): def __init__(self, clock: FakeClock) -> None: super().__init__("reindex", 3, clock) self._calls = 0
def set_up(self) -> None: TEMP_RESOURCES["tmp_idx"] = None self.log("setUp: created tmp_idx")
def execute(self) -> str: self._calls += 1 if self._calls == 1: self.clock.advance(500) raise Exception("search cluster timeout") self.clock.advance(400) return "1200 docs indexed"
def tear_down(self) -> None: TEMP_RESOURCES.pop("tmp_idx", None) self.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(ScheduledJob): def __init__(self, clock: FakeClock) -> None: super().__init__("export", 3, clock)
def run(self) -> bool: self.set_up() result = self.execute() # raises on the first failure... self.tear_down() # ...so this line never runs self.log(f"done: {result}") return True
def set_up(self) -> None: TEMP_RESOURCES["export.tmp"] = None self.log("setUp: opened export.tmp")
def execute(self) -> str: self.clock.advance(300) raise RuntimeError("disk full")
def tear_down(self) -> None: TEMP_RESOURCES.pop("export.tmp", None) self.log("tearDown: deleted export.tmp")
clock = FakeClock()for job in [ReindexJob(clock), QuickExportJob(clock)]: try: job.run() except Exception as e: # The scheduler expected run() never to raise. Now it has to guess what leaked. print(f"scheduler caught: {e}")# [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 fullprint("leftover temp resources: " + (", ".join(TEMP_RESOURCES) or "none"))# leftover temp resources: export.tmpusing System;using System.Collections.Generic;
public sealed class FakeClock{ private long _now; public long Now => _now; public void Advance(long ms) => _now += ms;}
public static class TempResources{ public static readonly List<string> Open = new();}
// The same base class as the fixed version, with one word added: Run() is virtual.public abstract class ScheduledJob{ private const long BackoffMs = 100; private readonly string _name; private readonly int _maxAttempts; protected readonly FakeClock Clock;
protected ScheduledJob(string name, int maxAttempts, FakeClock clock) { _name = name; _maxAttempts = maxAttempts; Clock = clock; }
// Overridable. The guarantees below (retries, timing, TearDown in finally) hold only // for subclasses that choose not to replace this method. public virtual bool Run() { long start = Clock.Now; string? error = null; int attempt = 0; SetUp(); try { while (attempt < _maxAttempts) { attempt++; try { var result = Execute(); Log($"attempt {attempt} ok: {result}"); error = null; break; } catch (Exception e) { error = e.Message; Log($"attempt {attempt} failed: {error}"); if (attempt < _maxAttempts) Clock.Advance(BackoffMs); } } } finally { TearDown(); } long ms = Clock.Now - start; var tries = $"{attempt} attempt{(attempt == 1 ? "" : "s")}"; 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(); protected virtual void SetUp() { } protected virtual void TearDown() { } protected void Log(string line) => Console.WriteLine($"[{_name}] {line}");}
public class ReindexJob : ScheduledJob{ private int _calls; public ReindexJob(FakeClock clock) : base("reindex", 3, clock) { }
protected override void SetUp() { TempResources.Open.Add("tmp_idx"); Log("setUp: created tmp_idx"); }
protected override string Execute() { _calls++; if (_calls == 1) { Clock.Advance(500); throw new Exception("search cluster timeout"); } Clock.Advance(400); return "1200 docs indexed"; }
protected override 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.public class QuickExportJob : ScheduledJob{ public QuickExportJob(FakeClock clock) : base("export", 3, clock) { }
public override bool Run() { SetUp(); var result = Execute(); // throws on the first failure... TearDown(); // ...so this line never runs Log($"done: {result}"); return true; }
protected override void SetUp() { TempResources.Open.Add("export.tmp"); Log("setUp: opened export.tmp"); }
protected override string Execute() { Clock.Advance(300); throw new InvalidOperationException("disk full"); }
protected override void TearDown() { TempResources.Open.Remove("export.tmp"); Log("tearDown: deleted export.tmp"); }}
public static class Program{ public static void Main() { var clock = new FakeClock(); foreach (ScheduledJob job in new ScheduledJob[] { new ReindexJob(clock), new QuickExportJob(clock) }) { try { job.Run(); } catch (Exception e) { // The scheduler expected Run() never to throw. Now it has to guess what leaked. Console.WriteLine($"scheduler caught: {e.Message}"); } } // [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 Console.WriteLine("leftover temp resources: " + (TempResources.Open.Count == 0 ? "none" : string.Join(", ", TempResources.Open))); // leftover temp resources: export.tmp }}package main
import ( "errors" "fmt" "strings")
type FakeClock struct{ now int }
func (c *FakeClock) Now() int { return c.now }func (c *FakeClock) Advance(ms int) { c.now += ms }
var tempResources []string
func addTemp(name string) { tempResources = append(tempResources, name) }func removeTemp(name string) { for i, n := range tempResources { if n == name { tempResources = append(tempResources[:i], tempResources[i+1:]...) return } }}
type Job interface { Name() string SetUp(log func(string)) Execute() (string, error) TearDown(log func(string))}
// Go has no final, but it has no overriding either. The hole here is structural: the// scheduler calls each job's own Run, and jobs are trusted to hand themselves to the runner.type Runnable interface{ Run() error }
type Runner struct { Clock *FakeClock MaxAttempts int BackoffMs int}
// Run is the same lifecycle as in the fixed version: retries, timing, deferred TearDown.func (r Runner) Run(job Job) error { log := func(line string) { fmt.Printf("[%s] %s\n", job.Name(), line) } start := r.Clock.Now() var err error attempt := 0 job.SetUp(log) func() { defer job.TearDown(log) for attempt < r.MaxAttempts { attempt++ var result string result, err = job.Execute() if err == nil { log(fmt.Sprintf("attempt %d ok: %s", attempt, result)) break } log(fmt.Sprintf("attempt %d failed: %v", attempt, err)) if attempt < r.MaxAttempts { r.Clock.Advance(r.BackoffMs) } } }() ms := r.Clock.Now() - start tries := fmt.Sprintf("%d attempt", attempt) if attempt != 1 { tries += "s" } if err == nil { log(fmt.Sprintf("OK in %d ms after %s", ms, tries)) } else { log(fmt.Sprintf("FAILED in %d ms after %s: %v", ms, tries, err)) } return nil // the runner reports failures itself; the scheduler never sees an error}
var clock = &FakeClock{}var runner = Runner{Clock: clock, MaxAttempts: 3, BackoffMs: 100}
type ReindexJob struct{ calls int }
// Run does the expected thing: it hands the job to the runner.func (j *ReindexJob) Run() error { return runner.Run(j) }func (*ReindexJob) Name() string { return "reindex" }func (*ReindexJob) SetUp(log func(string)) { addTemp("tmp_idx") log("setUp: created tmp_idx")}func (j *ReindexJob) Execute() (string, error) { j.calls++ if j.calls == 1 { clock.Advance(500) return "", errors.New("search cluster timeout") } clock.Advance(400) return "1200 docs indexed", nil}func (*ReindexJob) TearDown(log func(string)) { removeTemp("tmp_idx") log("tearDown: dropped tmp_idx")}
// QuickExportJob decided exports are quick and the runner is overhead, so its Run does the// steps itself. It drops retries, timing and the deferred TearDown at once.type QuickExportJob struct{}
func (j QuickExportJob) Run() error { log := func(line string) { fmt.Printf("[%s] %s\n", j.Name(), line) } j.SetUp(log) result, err := j.Execute() if err != nil { return err // the early return skips TearDown } j.TearDown(log) log("done: " + result) return nil}func (QuickExportJob) Name() string { return "export" }func (QuickExportJob) SetUp(log func(string)) { addTemp("export.tmp") log("setUp: opened export.tmp")}func (QuickExportJob) Execute() (string, error) { clock.Advance(300) return "", errors.New("disk full")}func (QuickExportJob) TearDown(log func(string)) { removeTemp("export.tmp") log("tearDown: deleted export.tmp")}
func main() { for _, job := range []Runnable{&ReindexJob{}, QuickExportJob{}} { if err := job.Run(); err != nil { // The scheduler expected Run never to fail. Now it has to guess what leaked. fmt.Println("scheduler caught: " + err.Error()) } } // [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 left := "none" if len(tempResources) > 0 { left = strings.Join(tempResources, ", ") } fmt.Println("leftover temp resources: " + left) // leftover temp resources: export.tmp}class FakeClock { private t = 0; now(): number { return this.t; } advance(ms: number): void { this.t += ms; }}
const tempResources = new Set<string>();
// The same base class as the fixed version, minus the guard: run() can be overridden.abstract class ScheduledJob { private static readonly BACKOFF_MS = 100;
protected constructor( private readonly name: string, private readonly maxAttempts: number, protected readonly clock: FakeClock, ) {}
// Overridable. The guarantees below (retries, timing, tearDown in finally) hold only // for subclasses that choose not to replace this method. run(): boolean { const start = this.clock.now(); let error: string | undefined; let attempt = 0; this.setUp(); try { while (attempt < this.maxAttempts) { attempt++; try { const result = this.execute(); this.log(`attempt ${attempt} ok: ${result}`); error = undefined; break; } catch (e) { error = (e as Error).message; this.log(`attempt ${attempt} failed: ${error}`); if (attempt < this.maxAttempts) this.clock.advance(ScheduledJob.BACKOFF_MS); } } } finally { this.tearDown(); } const ms = this.clock.now() - start; const tries = `${attempt} attempt${attempt === 1 ? '' : 's'}`; if (error === undefined) this.log(`OK in ${ms} ms after ${tries}`); else this.log(`FAILED in ${ms} ms after ${tries}: ${error}`); return error === undefined; }
protected abstract execute(): string; protected setUp(): void {} protected tearDown(): void {} protected log(line: string): void { console.log(`[${this.name}] ${line}`); }}
class ReindexJob extends ScheduledJob { private calls = 0; constructor(clock: FakeClock) { super('reindex', 3, clock); } protected override setUp(): void { tempResources.add('tmp_idx'); this.log('setUp: created tmp_idx'); } protected execute(): string { this.calls++; if (this.calls === 1) { this.clock.advance(500); throw new Error('search cluster timeout'); } this.clock.advance(400); return '1200 docs indexed'; } protected override tearDown(): void { tempResources.delete('tmp_idx'); this.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 { constructor(clock: FakeClock) { super('export', 3, clock); } override run(): boolean { this.setUp(); const result = this.execute(); // throws on the first failure... this.tearDown(); // ...so this line never runs this.log(`done: ${result}`); return true; } protected override setUp(): void { tempResources.add('export.tmp'); this.log('setUp: opened export.tmp'); } protected execute(): string { this.clock.advance(300); throw new Error('disk full'); } protected override tearDown(): void { tempResources.delete('export.tmp'); this.log('tearDown: deleted export.tmp'); }}
const clock = new FakeClock();for (const job of [new ReindexJob(clock), new QuickExportJob(clock)]) { try { job.run(); } catch (e) { // The scheduler expected run() never to throw. Now it has to guess what leaked. console.log(`scheduler caught: ${(e as Error).message}`); }}// [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 fullconsole.log('leftover temp resources: ' + ([...tempResources].join(', ') || 'none'));// leftover temp resources: export.tmpIt 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.
With Template method
Section titled “With Template method”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:
- Color key
- Facade
- Entity
- Value object
- Enum
- Interface
- Implementation
Run a job that fails, then let it override run(), with and without final:
What happened (fake clock)
Pick a job and run it.
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 }}from abc import ABC, abstractmethodfrom typing import final
# A fake clock, so timing is deterministic. Each job's work "takes" time by advancing it.class FakeClock: def __init__(self) -> None: self._now = 0
def now(self) -> int: return self._now
def advance(self, ms: int) -> None: self._now += ms
# Temp tables and files a job creates. Anything left here after a run is a leak.TEMP_RESOURCES: dict[str, None] = {} # a dict keeps insertion order, like a linked set
# 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 set_up/tear_down hooks.class ScheduledJob(ABC): BACKOFF_MS = 100
def __init__(self, name: str, max_attempts: int, clock: FakeClock) -> None: self._name = name self._max_attempts = max_attempts self.clock = clock
# Python has no `final` that the interpreter enforces. @final asks type checkers to # reject an override; this check rejects it when the subclass is defined, at import. def __init_subclass__(cls, **kwargs): super().__init_subclass__(**kwargs) if "run" in cls.__dict__: raise TypeError(f"{cls.__name__} must not override run(); use the hooks")
# The template method, and the only thing the scheduler calls. No job can skip # tear_down, the retries or the timing. @final def run(self) -> bool: start = self.clock.now() error = None attempt = 0 # set_up runs before the try: if it fails, there is nothing to tear down. self.set_up() try: while attempt < self._max_attempts: attempt += 1 try: result = self.execute() # the only step every job must write self.log(f"attempt {attempt} ok: {result}") error = None break except Exception as e: error = str(e) self.log(f"attempt {attempt} failed: {error}") if attempt < self._max_attempts: self.clock.advance(self.BACKOFF_MS) finally: # Runs whatever execute() did, even if it raised something we didn't catch. self.tear_down() ms = self.clock.now() - start tries = f"{attempt} attempt" + ("" if attempt == 1 else "s") if error is None: self.log(f"OK in {ms} ms after {tries}") else: self.log(f"FAILED in {ms} ms after {tries}: {error}") return error is None
# Abstract step: the job's actual work. @abstractmethod def execute(self) -> str: ...
# Hooks with empty defaults. Most jobs need neither, so they don't have to write them. def set_up(self) -> None: pass
def tear_down(self) -> None: pass
def log(self, line: str) -> None: print(f"[{self._name}] {line}")
# The simplest job: one method. No setup, no cleanup, no retry loop, no timing code.class InvoiceRemindersJob(ScheduledJob): def __init__(self, clock: FakeClock) -> None: super().__init__("invoice-reminders", 3, clock)
def execute(self) -> str: self.clock.advance(120) return "sent 42 reminders"
# Uses both hooks: a temp table that must be dropped however the run ends.class ReindexJob(ScheduledJob): def __init__(self, clock: FakeClock) -> None: super().__init__("reindex", 3, clock) self._calls = 0
def set_up(self) -> None: TEMP_RESOURCES["tmp_idx"] = None self.log("setUp: created tmp_idx")
def execute(self) -> str: self._calls += 1 if self._calls == 1: self.clock.advance(500) raise Exception("search cluster timeout") self.clock.advance(400) return "1200 docs indexed"
def tear_down(self) -> None: TEMP_RESOURCES.pop("tmp_idx", None) self.log("tearDown: dropped tmp_idx")
# Fails every time. The base class still tears down and reports a clean failure.class ExportJob(ScheduledJob): def __init__(self, clock: FakeClock) -> None: super().__init__("export", 3, clock)
def set_up(self) -> None: TEMP_RESOURCES["export.tmp"] = None self.log("setUp: opened export.tmp")
def execute(self) -> str: self.clock.advance(300) raise Exception("disk full")
def tear_down(self) -> None: TEMP_RESOURCES.pop("export.tmp", None) self.log("tearDown: deleted export.tmp")
clock = FakeClock()# The scheduler only knows ScheduledJob.run(). It calls the jobs, never the reverse.for job in [InvoiceRemindersJob(clock), ReindexJob(clock), 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 fullprint("leftover temp resources: " + (", ".join(TEMP_RESOURCES) or "none"))# leftover temp resources: noneusing System;using System.Collections.Generic;
// A fake clock, so timing is deterministic. Each job's work "takes" time by advancing it.public sealed class FakeClock{ private long _now; public long Now => _now; public void Advance(long ms) => _now += ms;}
// Temp tables and files a job creates. Anything left here after a run is a leak.public static class TempResources{ public static readonly List<string> Open = new();}
// 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.public abstract class ScheduledJob{ private const long BackoffMs = 100; private readonly string _name; private readonly int _maxAttempts; protected readonly FakeClock Clock;
protected ScheduledJob(string name, int maxAttempts, FakeClock clock) { _name = name; _maxAttempts = maxAttempts; Clock = clock; }
// The template method, and the only thing the scheduler calls. It is not virtual (the // C# default), so no job can skip TearDown, the retries or the timing by overriding it. public bool 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 { var result = Execute(); // the only step every job must write Log($"attempt {attempt} ok: {result}"); error = null; break; } catch (Exception e) { error = e.Message; Log($"attempt {attempt} failed: {error}"); if (attempt < _maxAttempts) Clock.Advance(BackoffMs); } } } finally { // Runs whatever Execute() did, even if it threw something we didn't catch. TearDown(); } long ms = Clock.Now - start; var tries = $"{attempt} attempt{(attempt == 1 ? "" : "s")}"; 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();
// Hooks with empty defaults. Most jobs need neither, so they don't have to write them. protected virtual void SetUp() { } protected virtual void TearDown() { }
protected void Log(string line) => Console.WriteLine($"[{_name}] {line}");}
// The simplest job: one method. No setup, no cleanup, no retry loop, no timing code.public class InvoiceRemindersJob : ScheduledJob{ public InvoiceRemindersJob(FakeClock clock) : base("invoice-reminders", 3, clock) { }
protected override string Execute() { Clock.Advance(120); return "sent 42 reminders"; }}
// Uses both hooks: a temp table that must be dropped however the run ends.public class ReindexJob : ScheduledJob{ private int _calls; public ReindexJob(FakeClock clock) : base("reindex", 3, clock) { }
protected override void SetUp() { TempResources.Open.Add("tmp_idx"); Log("setUp: created tmp_idx"); }
protected override string Execute() { _calls++; if (_calls == 1) { Clock.Advance(500); throw new Exception("search cluster timeout"); } Clock.Advance(400); return "1200 docs indexed"; }
protected override 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.public class ExportJob : ScheduledJob{ public ExportJob(FakeClock clock) : base("export", 3, clock) { }
protected override void SetUp() { TempResources.Open.Add("export.tmp"); Log("setUp: opened export.tmp"); }
protected override string Execute() { Clock.Advance(300); throw new Exception("disk full"); }
protected override void TearDown() { TempResources.Open.Remove("export.tmp"); Log("tearDown: deleted export.tmp"); }}
public static class Program{ public static void Main() { var clock = new FakeClock(); // The scheduler only knows ScheduledJob.Run(). It calls the jobs, never the reverse. foreach (ScheduledJob job in 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 Console.WriteLine("leftover temp resources: " + (TempResources.Open.Count == 0 ? "none" : string.Join(", ", TempResources.Open))); // leftover temp resources: none }}package main
import ( "errors" "fmt" "strings")
// FakeClock keeps timing deterministic. Each job's work "takes" time by advancing it.type FakeClock struct{ now int }
func (c *FakeClock) Now() int { return c.now }func (c *FakeClock) Advance(ms int) { c.now += ms }
// tempResources holds temp tables and files a job creates. Anything left after a run is a leak.var tempResources []string
func addTemp(name string) { tempResources = append(tempResources, name) }func removeTemp(name string) { for i, n := range tempResources { if n == name { tempResources = append(tempResources[:i], tempResources[i+1:]...) return } }}
// Go has no inheritance, so the lifecycle can't live in a base class. It lives in a Runner// that holds the job, which is composition. The scheduler calls Runner.Run, never the job,// so a job can't skip tearDown or retries: it is never given the chance to run itself.
// Job is the abstract step: the work every job must write.type Job interface { Name() string Execute() (string, error)}
// SetUpper and TearDowner are the hooks. Each is an optional interface: a job that doesn't// implement one gets the default, which is to do nothing.type SetUpper interface{ SetUp(log func(string)) }type TearDowner interface{ TearDown(log func(string)) }
// Runner owns the lifecycle: set up, retries, timing, tear down.type Runner struct { Clock *FakeClock MaxAttempts int BackoffMs int}
// Run is the template method.func (r Runner) Run(job Job) bool { log := func(line string) { fmt.Printf("[%s] %s\n", job.Name(), line) } start := r.Clock.Now() var err error attempt := 0 // SetUp runs before the deferred TearDown: if it fails, there is nothing to tear down. if s, ok := job.(SetUpper); ok { s.SetUp(log) } func() { // defer runs however this function ends, even on a panic inside Execute. defer func() { if t, ok := job.(TearDowner); ok { t.TearDown(log) } }() for attempt < r.MaxAttempts { attempt++ var result string result, err = job.Execute() // the only step every job must write if err == nil { log(fmt.Sprintf("attempt %d ok: %s", attempt, result)) break } log(fmt.Sprintf("attempt %d failed: %v", attempt, err)) if attempt < r.MaxAttempts { r.Clock.Advance(r.BackoffMs) } } }() ms := r.Clock.Now() - start tries := fmt.Sprintf("%d attempt", attempt) if attempt != 1 { tries += "s" } if err == nil { log(fmt.Sprintf("OK in %d ms after %s", ms, tries)) } else { log(fmt.Sprintf("FAILED in %d ms after %s: %v", ms, tries, err)) } return err == nil}
// InvoiceRemindersJob is the simplest job: one method. No setup, cleanup, retries or timing.type InvoiceRemindersJob struct{ Clock *FakeClock }
func (InvoiceRemindersJob) Name() string { return "invoice-reminders" }func (j InvoiceRemindersJob) Execute() (string, error) { j.Clock.Advance(120) return "sent 42 reminders", nil}
// ReindexJob uses both hooks: a temp table that must be dropped however the run ends.type ReindexJob struct { Clock *FakeClock calls int}
func (*ReindexJob) Name() string { return "reindex" }func (*ReindexJob) SetUp(log func(string)) { addTemp("tmp_idx") log("setUp: created tmp_idx")}func (j *ReindexJob) Execute() (string, error) { j.calls++ if j.calls == 1 { j.Clock.Advance(500) return "", errors.New("search cluster timeout") } j.Clock.Advance(400) return "1200 docs indexed", nil}func (*ReindexJob) TearDown(log func(string)) { removeTemp("tmp_idx") log("tearDown: dropped tmp_idx")}
// ExportJob fails every time. The runner still tears down and reports a clean failure.type ExportJob struct{ Clock *FakeClock }
func (ExportJob) Name() string { return "export" }func (ExportJob) SetUp(log func(string)) { addTemp("export.tmp") log("setUp: opened export.tmp")}func (j ExportJob) Execute() (string, error) { j.Clock.Advance(300) return "", errors.New("disk full")}func (ExportJob) TearDown(log func(string)) { removeTemp("export.tmp") log("tearDown: deleted export.tmp")}
func main() { clock := &FakeClock{} runner := Runner{Clock: clock, MaxAttempts: 3, BackoffMs: 100} // The scheduler only knows Runner.Run. It calls the jobs, never the reverse. for _, job := range []Job{InvoiceRemindersJob{clock}, &ReindexJob{Clock: clock}, ExportJob{clock}} { runner.Run(job) } // [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 left := "none" if len(tempResources) > 0 { left = strings.Join(tempResources, ", ") } fmt.Println("leftover temp resources: " + left) // leftover temp resources: none}// A fake clock, so timing is deterministic. Each job's work "takes" time by advancing it.class FakeClock { private t = 0; now(): number { return this.t; } advance(ms: number): void { this.t += ms; }}
// Temp tables and files a job creates. Anything left here after a run is a leak.const tempResources = new Set<string>();
// 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 static readonly BACKOFF_MS = 100;
protected constructor( private readonly name: string, private readonly maxAttempts: number, protected readonly clock: FakeClock, ) { // TypeScript has no `final`. This check rejects an override of run() when the job is // created, which is the closest run-time equivalent. if (this.run !== ScheduledJob.prototype.run) { throw new TypeError(`${new.target.name} must not override run(); use the hooks`); } }
// The template method, and the only thing the scheduler calls. No job can skip // tearDown, the retries or the timing. run(): boolean { const start = this.clock.now(); let error: string | undefined; let attempt = 0; // setUp runs before the try: if it fails, there is nothing to tear down. this.setUp(); try { while (attempt < this.maxAttempts) { attempt++; try { const result = this.execute(); // the only step every job must write this.log(`attempt ${attempt} ok: ${result}`); error = undefined; break; } catch (e) { error = (e as Error).message; this.log(`attempt ${attempt} failed: ${error}`); if (attempt < this.maxAttempts) this.clock.advance(ScheduledJob.BACKOFF_MS); } } } finally { // Runs whatever execute() did, even if it threw something we didn't catch. this.tearDown(); } const ms = this.clock.now() - start; const tries = `${attempt} attempt${attempt === 1 ? '' : 's'}`; if (error === undefined) this.log(`OK in ${ms} ms after ${tries}`); else this.log(`FAILED in ${ms} ms after ${tries}: ${error}`); return error === undefined; }
// Abstract step: the job's actual work. protected abstract execute(): string;
// Hooks with empty defaults. Most jobs need neither, so they don't have to write them. protected setUp(): void {} protected tearDown(): void {}
protected log(line: string): void { console.log(`[${this.name}] ${line}`); }}
// The simplest job: one method. No setup, no cleanup, no retry loop, no timing code.class InvoiceRemindersJob extends ScheduledJob { constructor(clock: FakeClock) { super('invoice-reminders', 3, clock); } protected execute(): string { this.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 calls = 0; constructor(clock: FakeClock) { super('reindex', 3, clock); } protected override setUp(): void { tempResources.add('tmp_idx'); this.log('setUp: created tmp_idx'); } protected execute(): string { this.calls++; if (this.calls === 1) { this.clock.advance(500); throw new Error('search cluster timeout'); } this.clock.advance(400); return '1200 docs indexed'; } protected override tearDown(): void { tempResources.delete('tmp_idx'); this.log('tearDown: dropped tmp_idx'); }}
// Fails every time. The base class still tears down and reports a clean failure.class ExportJob extends ScheduledJob { constructor(clock: FakeClock) { super('export', 3, clock); } protected override setUp(): void { tempResources.add('export.tmp'); this.log('setUp: opened export.tmp'); } protected execute(): string { this.clock.advance(300); throw new Error('disk full'); } protected override tearDown(): void { tempResources.delete('export.tmp'); this.log('tearDown: deleted export.tmp'); }}
const clock = new FakeClock();// The scheduler only knows ScheduledJob.run(). It calls the jobs, never the reverse.for (const job of [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 fullconsole.log('leftover temp resources: ' + ([...tempResources].join(', ') || 'none'));// leftover temp resources: noneWhat to notice
Section titled “What to notice”tearDownis infinally, andsetUpis outside thetry. Ifexecutethrows, cleanup still runs. That’s true even for an error the retry loop doesn’t catch. IfsetUpitself fails, there is nothing to tear down. Try-with-resources and Python’sunittestbehave the same way. (JUnit 5 makes the other choice. It runs@AfterEacheven when@BeforeEachfails, so its teardown must cope with a half-done setup.) Pick one, and document it.- Hooks keep simple jobs simple.
InvoiceRemindersJobis 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
finaland C#’s default stop overrides at compile time. (In C#, methods aren’tvirtualunless marked.) Python’s@finalonly informs type checkers. So the Python version also rejects an override in__init_subclass__, which runs when the subclass is defined. TypeScript has nofinal. Its version comparesthis.runwithScheduledJob.prototype.runin 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
Runof its own that the scheduler calls. The scheduler callsRunner.Run(job). So a job can’t skip the lifecycle. It never gets the chance. In the before version it does, and its earlyreturn errskipsTearDown. - Time is injected. The fake clock makes the retry and timing behavior testable, without sleeping. Reindex takes exactly 500 + 100 + 400 = 1000 ms.
The composition alternative
Section titled “The composition alternative”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:
- Color key
- Facade
- Entity
- Value object
- Enum
- Interface
- Implementation
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 }}from dataclasses import dataclassfrom typing import Callable
class FakeClock: def __init__(self) -> None: self._now = 0
def now(self) -> int: return self._now
def advance(self, ms: int) -> None: self._now += ms
TEMP_RESOURCES: dict[str, None] = {}
# 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.@dataclass(frozen=True)class Steps: execute: Callable[[], str] set_up: Callable[[], None] = lambda: None tear_down: Callable[[], None] = lambda: None
# The same lifecycle as ScheduledJob.run(), in a class that jobs don't extend.# Nothing can override it, because there is nothing to subclass.class JobRunner: BACKOFF_MS = 100
def __init__(self, clock: FakeClock, max_attempts: int) -> None: self._clock = clock self._max_attempts = max_attempts
def run(self, name: str, steps: Steps) -> bool: def log(line: str) -> None: print(f"[{name}] {line}")
start = self._clock.now() error = None attempt = 0 steps.set_up() try: while attempt < self._max_attempts: attempt += 1 try: result = steps.execute() log(f"attempt {attempt} ok: {result}") error = None break except Exception as e: error = str(e) log(f"attempt {attempt} failed: {error}") if attempt < self._max_attempts: self._clock.advance(self.BACKOFF_MS) finally: steps.tear_down() ms = self._clock.now() - start tries = f"{attempt} attempt" + ("" if attempt == 1 else "s") if error is None: log(f"OK in {ms} ms after {tries}") else: log(f"FAILED in {ms} ms after {tries}: {error}") return error is None
# A job with state and cleanup is a plain class. It extends nothing and knows no runner.class Reindex: def __init__(self, clock: FakeClock) -> None: self._clock = clock self._calls = 0
def set_up(self) -> None: TEMP_RESOURCES["tmp_idx"] = None print("[reindex] setUp: created tmp_idx")
def execute(self) -> str: self._calls += 1 if self._calls == 1: self._clock.advance(500) raise Exception("search cluster timeout") self._clock.advance(400) return "1200 docs indexed"
def tear_down(self) -> None: TEMP_RESOURCES.pop("tmp_idx", None) print("[reindex] tearDown: dropped tmp_idx")
class Export: def __init__(self, clock: FakeClock) -> None: self._clock = clock
def set_up(self) -> None: TEMP_RESOURCES["export.tmp"] = None print("[export] setUp: opened export.tmp")
def execute(self) -> str: self._clock.advance(300) raise Exception("disk full")
def tear_down(self) -> None: TEMP_RESOURCES.pop("export.tmp", None) print("[export] tearDown: deleted export.tmp")
clock = FakeClock()runner = JobRunner(clock, 3)
# The simplest job is a plain function: no class at all.def send_reminders() -> str: clock.advance(120) return "sent 42 reminders"
runner.run("invoice-reminders", Steps(send_reminders))reindex = Reindex(clock)runner.run("reindex", Steps(reindex.execute, reindex.set_up, reindex.tear_down))export = Export(clock)runner.run("export", Steps(export.execute, export.set_up, export.tear_down))# 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 fullprint("leftover temp resources: " + (", ".join(TEMP_RESOURCES) or "none"))# leftover temp resources: noneusing System;using System.Collections.Generic;
public sealed class FakeClock{ private long _now; public long Now => _now; public void Advance(long ms) => _now += ms;}
public static class TempResources{ public static readonly List<string> Open = new();}
// 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.public sealed record Steps(Func<string> Execute, Action? SetUp = null, Action? TearDown = null);
// The same lifecycle as ScheduledJob.Run(), in a sealed class that jobs don't extend.// Nothing can override it, because there is nothing to subclass.public sealed class JobRunner{ private const long BackoffMs = 100; private readonly FakeClock _clock; private readonly int _maxAttempts;
public JobRunner(FakeClock clock, int maxAttempts) { _clock = clock; _maxAttempts = maxAttempts; }
public bool Run(string name, Steps steps) { void Log(string line) => Console.WriteLine($"[{name}] {line}"); long start = _clock.Now; string? error = null; int attempt = 0; steps.SetUp?.Invoke(); try { while (attempt < _maxAttempts) { attempt++; try { var result = steps.Execute(); Log($"attempt {attempt} ok: {result}"); error = null; break; } catch (Exception e) { error = e.Message; Log($"attempt {attempt} failed: {error}"); if (attempt < _maxAttempts) _clock.Advance(BackoffMs); } } } finally { steps.TearDown?.Invoke(); } long ms = _clock.Now - start; var tries = $"{attempt} attempt{(attempt == 1 ? "" : "s")}"; if (error == null) Log($"OK in {ms} ms after {tries}"); else Log($"FAILED in {ms} ms after {tries}: {error}"); return error == null; }}
// A job with state and cleanup is a plain class. It extends nothing and knows no runner.public class Reindex{ private readonly FakeClock _clock; private int _calls; public Reindex(FakeClock clock) => _clock = clock;
public void SetUp() { TempResources.Open.Add("tmp_idx"); Console.WriteLine("[reindex] setUp: created tmp_idx"); }
public string Execute() { _calls++; if (_calls == 1) { _clock.Advance(500); throw new Exception("search cluster timeout"); } _clock.Advance(400); return "1200 docs indexed"; }
public void TearDown() { TempResources.Open.Remove("tmp_idx"); Console.WriteLine("[reindex] tearDown: dropped tmp_idx"); }}
public class Export{ private readonly FakeClock _clock; public Export(FakeClock clock) => _clock = clock;
public void SetUp() { TempResources.Open.Add("export.tmp"); Console.WriteLine("[export] setUp: opened export.tmp"); }
public string Execute() { _clock.Advance(300); throw new Exception("disk full"); }
public void TearDown() { TempResources.Open.Remove("export.tmp"); Console.WriteLine("[export] tearDown: deleted export.tmp"); }}
public static class Program{ public static void Main() { var clock = new FakeClock(); var runner = new JobRunner(clock, 3); // The simplest job is a lambda: no class at all. runner.Run("invoice-reminders", new Steps(() => { clock.Advance(120); return "sent 42 reminders"; })); var reindex = new Reindex(clock); runner.Run("reindex", new Steps(reindex.Execute, reindex.SetUp, reindex.TearDown)); var export = new Export(clock); runner.Run("export", new Steps(export.Execute, export.SetUp, 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 Console.WriteLine("leftover temp resources: " + (TempResources.Open.Count == 0 ? "none" : string.Join(", ", TempResources.Open))); // leftover temp resources: none }}package main
import ( "errors" "fmt" "strings")
type FakeClock struct{ now int }
func (c *FakeClock) Now() int { return c.now }func (c *FakeClock) Advance(ms int) { c.now += ms }
var tempResources []string
func addTemp(name string) { tempResources = append(tempResources, name) }func removeTemp(name string) { for i, n := range tempResources { if n == name { tempResources = append(tempResources[:i], tempResources[i+1:]...) return } }}
// Steps holds the varying parts of a job as plain functions. Only Execute is required;// a nil SetUp or TearDown means "nothing to do". This is the lightest form of the pattern:// no interfaces, no types per job.type Steps struct { SetUp func() Execute func() (string, error) TearDown func()}
// JobRunner owns the same lifecycle as the interface-based Runner.type JobRunner struct { Clock *FakeClock MaxAttempts int BackoffMs int}
func (r JobRunner) Run(name string, s Steps) bool { log := func(line string) { fmt.Printf("[%s] %s\n", name, line) } start := r.Clock.Now() var err error attempt := 0 if s.SetUp != nil { s.SetUp() } func() { defer func() { if s.TearDown != nil { s.TearDown() } }() for attempt < r.MaxAttempts { attempt++ var result string result, err = s.Execute() if err == nil { log(fmt.Sprintf("attempt %d ok: %s", attempt, result)) break } log(fmt.Sprintf("attempt %d failed: %v", attempt, err)) if attempt < r.MaxAttempts { r.Clock.Advance(r.BackoffMs) } } }() ms := r.Clock.Now() - start tries := fmt.Sprintf("%d attempt", attempt) if attempt != 1 { tries += "s" } if err == nil { log(fmt.Sprintf("OK in %d ms after %s", ms, tries)) } else { log(fmt.Sprintf("FAILED in %d ms after %s: %v", ms, tries, err)) } return err == nil}
// Reindex is a plain type with state and cleanup. It knows nothing about the runner.type Reindex struct { Clock *FakeClock calls int}
func (*Reindex) SetUp() { addTemp("tmp_idx") fmt.Println("[reindex] setUp: created tmp_idx")}func (j *Reindex) Execute() (string, error) { j.calls++ if j.calls == 1 { j.Clock.Advance(500) return "", errors.New("search cluster timeout") } j.Clock.Advance(400) return "1200 docs indexed", nil}func (*Reindex) TearDown() { removeTemp("tmp_idx") fmt.Println("[reindex] tearDown: dropped tmp_idx")}
type Export struct{ Clock *FakeClock }
func (Export) SetUp() { addTemp("export.tmp") fmt.Println("[export] setUp: opened export.tmp")}func (e Export) Execute() (string, error) { e.Clock.Advance(300) return "", errors.New("disk full")}func (Export) TearDown() { removeTemp("export.tmp") fmt.Println("[export] tearDown: deleted export.tmp")}
func main() { clock := &FakeClock{} runner := JobRunner{Clock: clock, MaxAttempts: 3, BackoffMs: 100} // The simplest job is a function literal: no type at all. runner.Run("invoice-reminders", Steps{Execute: func() (string, error) { clock.Advance(120) return "sent 42 reminders", nil }}) reindex := &Reindex{Clock: clock} runner.Run("reindex", Steps{reindex.SetUp, reindex.Execute, reindex.TearDown}) export := Export{clock} runner.Run("export", Steps{export.SetUp, export.Execute, export.TearDown}) // Same output as the interface 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 left := "none" if len(tempResources) > 0 { left = strings.Join(tempResources, ", ") } fmt.Println("leftover temp resources: " + left) // leftover temp resources: none}class FakeClock { private t = 0; now(): number { return this.t; } advance(ms: number): void { this.t += ms; }}
const tempResources = new Set<string>();
// 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.interface Steps { setUp?: () => void; execute: () => string; tearDown?: () => void;}
// The same lifecycle as ScheduledJob.run(), in a class that jobs don't extend.// Nothing can override it, because there is nothing to subclass.class JobRunner { private static readonly BACKOFF_MS = 100;
constructor( private readonly clock: FakeClock, private readonly maxAttempts: number, ) {}
run(name: string, steps: Steps): boolean { const log = (line: string) => console.log(`[${name}] ${line}`); const start = this.clock.now(); let error: string | undefined; let attempt = 0; steps.setUp?.(); try { while (attempt < this.maxAttempts) { attempt++; try { const result = steps.execute(); log(`attempt ${attempt} ok: ${result}`); error = undefined; break; } catch (e) { error = (e as Error).message; log(`attempt ${attempt} failed: ${error}`); if (attempt < this.maxAttempts) this.clock.advance(JobRunner.BACKOFF_MS); } } } finally { steps.tearDown?.(); } const ms = this.clock.now() - start; const tries = `${attempt} attempt${attempt === 1 ? '' : 's'}`; if (error === undefined) log(`OK in ${ms} ms after ${tries}`); else log(`FAILED in ${ms} ms after ${tries}: ${error}`); return error === undefined; }}
// A job with state and cleanup is a plain object. It extends nothing and knows no runner.function reindex(clock: FakeClock): Steps { let calls = 0; return { setUp() { tempResources.add('tmp_idx'); console.log('[reindex] setUp: created tmp_idx'); }, execute() { calls++; if (calls === 1) { clock.advance(500); throw new Error('search cluster timeout'); } clock.advance(400); return '1200 docs indexed'; }, tearDown() { tempResources.delete('tmp_idx'); console.log('[reindex] tearDown: dropped tmp_idx'); }, };}
function exportJob(clock: FakeClock): Steps { return { setUp() { tempResources.add('export.tmp'); console.log('[export] setUp: opened export.tmp'); }, execute() { clock.advance(300); throw new Error('disk full'); }, tearDown() { tempResources.delete('export.tmp'); console.log('[export] tearDown: deleted export.tmp'); }, };}
const clock = new FakeClock();const runner = new JobRunner(clock, 3);// The simplest job is a function: no class at all.runner.run('invoice-reminders', { execute: () => { clock.advance(120); return 'sent 42 reminders'; },});runner.run('reindex', reindex(clock));runner.run('export', exportJob(clock));// 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 fullconsole.log('leftover temp resources: ' + ([...tempResources].join(', ') || 'none'));// leftover temp resources: noneThe 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 |
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?
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.
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:
- Idempotency check. Clients retry on timeouts. A retried request with the same idempotency key must return the first result, not charge again.
- Fraud check. Payments over EUR 5000 are rejected, before any money is reserved.
- Authorize. Reserve the money. Method-specific.
- 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.
- Record. Every outcome goes to the ledger for audit, including declines and failures.
- 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.
With Template method
Section titled “With Template method”- Color key
- Facade
- Entity
- Value object
- Enum
- Interface
- Implementation
- 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:
Trace
Send a payment.
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 }}import threadingfrom abc import ABC, abstractmethodfrom dataclasses import dataclass, fieldfrom enum import Enumfrom typing import Callable, TypeVar, final
T = TypeVar("T")
class Status(Enum): CAPTURED = "CAPTURED" PENDING = "PENDING" DECLINED = "DECLINED" REJECTED = "REJECTED" FAILED = "FAILED" IN_PROGRESS = "IN_PROGRESS"
@dataclass(frozen=True)class PaymentRequest: key: str method: str amount: int
@dataclass(frozen=True)class Result: status: Status detail: str = ""
@dataclass(frozen=True)class Auth: approved: bool id: str = "" reason: str = ""
@dataclass(frozen=True)class Span: step: str owner: str ms: int
class FakeClock: def __init__(self) -> None: self._now = 0
def now(self) -> int: return self._now
def advance(self, ms: int) -> None: self._now += ms
# Remembers every idempotency key. claim() checks and stores under one lock, so two copies# of the same request arriving at once can't both get through: exactly one sees None.class IdempotencyStore: CLAIMED = Result(Status.IN_PROGRESS, "another request with this key is running")
def __init__(self) -> None: self._results: dict[str, Result] = {} self._lock = threading.Lock()
def claim(self, key: str) -> Result | None: with self._lock: seen = self._results.get(key) if seen is None: self._results[key] = self.CLAIMED return seen
def complete(self, key: str, result: Result) -> None: with self._lock: self._results[key] = result
# Shared by every flow: the clock, the idempotency store, the ledger and the outbox.@dataclassclass Platform: clock: FakeClock = field(default_factory=FakeClock) idempotency: IdempotencyStore = field(default_factory=IdempotencyStore) ledger: list[str] = field(default_factory=list) outbox: list[str] = field(default_factory=list)
# 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.class PaymentFlow(ABC): FRAUD_LIMIT = 5000
def __init__(self, platform: Platform) -> None: self.platform = platform self.last_trace: list[Span] = []
# Reject an override of process() when the subclass is defined. def __init_subclass__(cls, **kwargs): super().__init_subclass__(**kwargs) if "process" in cls.__dict__: raise TypeError(f"{cls.__name__} must not override process()")
# The template method. No team can reorder the steps, skip idempotency or skip the # audit record, and every step is timed and traced the same way. @final def process(self, req: PaymentRequest) -> Result: trace: list[Span] = [] self.last_trace = trace
# 1. Idempotency comes first, before any money moves. A duplicate returns the stored # result and is not charged, recorded or notified again. def check() -> Result | None: self.platform.clock.advance(2) return self.platform.idempotency.claim(req.key)
seen = self._timed(trace, "idempotency", "PaymentFlow", check) if seen is not None: return seen if seen.status is Status.IN_PROGRESS else Result(seen.status, "duplicate, stored result")
result: Result | None = None try: result = self._charge(req, trace) except Exception as e: result = Result(Status.FAILED, str(e)) finally: # 5. The audit record runs however the steps ended. A subclass can't skip it. outcome = result if result is not None else Result(Status.FAILED, "unknown")
def record() -> None: self.platform.clock.advance(5) self.platform.ledger.append(f"{req.key} {outcome.status.value}") self.platform.idempotency.complete(req.key, outcome)
self._timed(trace, "record", "PaymentFlow", record)
# 6. Notify only when money moved. The message is a hook with a default. if result.status in (Status.CAPTURED, Status.PENDING):
def notify() -> None: self.platform.clock.advance(10) self.platform.outbox.append(self.notify_message(req, result))
self._timed(trace, "notify", self._owner("notify"), notify) return result
def _charge(self, req: PaymentRequest, trace: list[Span]) -> Result: # 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. def fraud() -> str | None: self.platform.clock.advance(15) return f"over EUR {self.FRAUD_LIMIT} limit" if req.amount > self.FRAUD_LIMIT else None
risk = self._timed(trace, "fraud", "PaymentFlow", fraud) if risk is not None: return Result(Status.REJECTED, f"fraud: {risk}") # 3. Authorize: reserve the money. The subclass's step. auth = self._timed(trace, "authorize", self._owner("authorize"), lambda: self.authorize(req)) if not auth.approved: return 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 = self._timed(trace, "capture", self._owner("capture"), lambda: self.capture(req, auth)) return Result(status) except Exception as e: self._timed(trace, "void", self._owner("void"), lambda: self.void_authorization(auth)) return Result(Status.FAILED, f"capture failed: {e}, authorization voided")
# Observability lives in the base class: every step gets a span with its duration and # owner, even when it raises, without any team writing timing code. def _timed(self, trace: list[Span], step: str, owner: str, body: Callable[[], T]) -> T: start = self.platform.clock.now() try: return body() finally: trace.append(Span(step, owner, self.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. def _owner(self, step: str) -> str: if step != "notify" or type(self).notify_message is not PaymentFlow.notify_message: return type(self).__name__ return "PaymentFlow"
# Abstract steps: what differs per payment method. @abstractmethod def authorize(self, req: PaymentRequest) -> Auth: ...
@abstractmethod def capture(self, req: PaymentRequest, auth: Auth) -> Status: ...
@abstractmethod def void_authorization(self, auth: Auth) -> None: ...
# Hook with a default. Bank transfers override it; cards and wallets don't need to. def notify_message(self, req: PaymentRequest, result: Result) -> str: return f"{req.key} receipt"
class CardFlow(PaymentFlow): def __init__(self, platform: Platform) -> None: super().__init__(platform) self.charges = 0
def authorize(self, req: PaymentRequest) -> Auth: self.platform.clock.advance(120) return Auth(True, f"auth-{req.key}")
def capture(self, req: PaymentRequest, auth: Auth) -> Status: self.platform.clock.advance(80) self.charges += 1 return Status.CAPTURED
def void_authorization(self, auth: Auth) -> None: self.platform.clock.advance(40)
class BankTransferFlow(PaymentFlow): def authorize(self, req: PaymentRequest) -> Auth: self.platform.clock.advance(60) # check the customer's direct debit mandate return Auth(True, f"mandate-{req.key}")
def capture(self, req: PaymentRequest, auth: Auth) -> Status: self.platform.clock.advance(200) # start the transfer; it settles in a day or two return Status.PENDING
def void_authorization(self, auth: Auth) -> None: self.platform.clock.advance(40)
def notify_message(self, req: PaymentRequest, result: Result) -> str: return f"{req.key} transfer started (1-2 business days)"
class WalletFlow(PaymentFlow): def __init__(self, platform: Platform) -> None: super().__init__(platform) self._balance = 50
def authorize(self, req: PaymentRequest) -> Auth: self.platform.clock.advance(30) if req.amount > self._balance: return Auth(False, reason="insufficient balance") return Auth(True, f"hold-{req.key}")
def capture(self, req: PaymentRequest, auth: Auth) -> Status: self.platform.clock.advance(20) self._balance -= req.amount return Status.CAPTURED
def void_authorization(self, auth: Auth) -> None: self.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(PaymentFlow): def __init__(self, platform: Platform) -> None: super().__init__(platform) self.calls: list[str] = []
def authorize(self, req: PaymentRequest) -> Auth: self.calls.append("authorize") self.platform.clock.advance(10) return Auth(True, "fake")
def capture(self, req: PaymentRequest, auth: Auth) -> Status: self.calls.append("capture") self.platform.clock.advance(10) raise RuntimeError("network error")
def void_authorization(self, auth: Auth) -> None: self.calls.append("void") self.platform.clock.advance(5)
def show(flow: PaymentFlow, req: PaymentRequest) -> None: r = flow.process(req) detail = f" ({r.detail})" if r.detail else "" print(f"{req.key} {req.method} EUR {req.amount}: {r.status.value}{detail}")
platform = Platform()card = CardFlow(platform)bank = BankTransferFlow(platform)wallet = WalletFlow(platform)fake = FakeFlow(platform)
show(card, PaymentRequest("pay-1", "card", 49))for s in card.last_trace: print(f" {s.step:<12}{s.ms:>4} 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, PaymentRequest("pay-1", "card", 49))# pay-1 card EUR 49: CAPTURED (duplicate, stored result)show(bank, PaymentRequest("pay-2", "bank", 900))# pay-2 bank EUR 900: PENDINGshow(wallet, PaymentRequest("pay-3", "wallet", 75))# pay-3 wallet EUR 75: DECLINED (insufficient balance)show(card, PaymentRequest("pay-4", "card", 9000))# pay-4 card EUR 9000: REJECTED (fraud: over EUR 5000 limit)show(fake, PaymentRequest("pay-5", "fake", 10))# pay-5 fake EUR 10: FAILED (capture failed: network error, authorization voided)print("fake calls: " + ", ".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, PaymentRequest("pay-6", "card", 20))# pay-6 card EUR 20: IN_PROGRESS (another request with this key is running)
print("ledger: " + ", ".join(platform.ledger))# ledger: pay-1 CAPTURED, pay-2 PENDING, pay-3 DECLINED, pay-4 REJECTED, pay-5 FAILEDprint("outbox: " + ", ".join(platform.outbox))# outbox: pay-1 receipt, pay-2 transfer started (1-2 business days)print(f"card charges: {card.charges}")# card charges: 1using System;using System.Collections.Concurrent;using System.Collections.Generic;using System.Reflection;
public enum Status { Captured, Pending, Declined, Rejected, Failed, InProgress }
public record PaymentRequest(string Key, string Method, long Amount);public record Result(Status Status, string Detail = "");public record Auth(bool Approved, string Id = "", string Reason = "");public record Span(string Step, string Owner, long Ms);
public sealed class FakeClock{ private long _now; public long Now => _now; public void Advance(long ms) => _now += ms;}
// Remembers every idempotency key. Claim() is one atomic step (TryAdd), so two copies of// the same request arriving at once can't both get through: exactly one sees null.public sealed class IdempotencyStore{ private static readonly Result Claimed = new(Status.InProgress, "another request with this key is running"); private readonly ConcurrentDictionary<string, Result> _results = new();
public Result? Claim(string key) => _results.TryAdd(key, Claimed) ? null : _results[key]; public void Complete(string key, Result result) => _results[key] = result;}
// Shared by every flow: the clock, the idempotency store, the ledger and the outbox.public sealed class Platform{ public readonly FakeClock Clock = new(); public readonly IdempotencyStore Idempotency = new(); public readonly List<string> Ledger = new(); public readonly List<string> Outbox = new();}
// 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.public abstract class PaymentFlow{ public const long FraudLimit = 5000; protected readonly Platform Platform; public List<Span> LastTrace { get; private set; } = new();
protected PaymentFlow(Platform platform) => Platform = platform;
public static string Label(Status s) => s switch { Status.Captured => "CAPTURED", Status.Pending => "PENDING", Status.Declined => "DECLINED", Status.Rejected => "REJECTED", Status.Failed => "FAILED", _ => "IN_PROGRESS", };
// The template method. Not virtual (the C# default): no team can reorder the steps, // skip idempotency or skip the audit record, and every step is timed and traced the same way. public Result Process(PaymentRequest req) { var trace = new List<Span>(); LastTrace = trace; // 1. Idempotency comes first, before any money moves. A duplicate returns the stored // result and is not charged, recorded or notified again. var seen = Timed(trace, "idempotency", "PaymentFlow", () => { Platform.Clock.Advance(2); return Platform.Idempotency.Claim(req.Key); }); if (seen != null) { return seen.Status == Status.InProgress ? seen : new Result(seen.Status, "duplicate, stored result"); } Result? result = null; try { result = Charge(req, trace); } catch (Exception e) { result = new Result(Status.Failed, e.Message); } finally { // 5. The audit record runs however the steps ended. A subclass can't skip it. var outcome = result ?? new Result(Status.Failed, "unknown"); Timed<object?>(trace, "record", "PaymentFlow", () => { Platform.Clock.Advance(5); Platform.Ledger.Add($"{req.Key} {Label(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 is Status.Captured or Status.Pending) { var done = result; Timed<object?>(trace, "notify", Owner("notify"), () => { Platform.Clock.Advance(10); Platform.Outbox.Add(NotifyMessage(req, done)); 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. var risk = Timed(trace, "fraud", "PaymentFlow", () => { Platform.Clock.Advance(15); return req.Amount > FraudLimit ? $"over EUR {FraudLimit} limit" : null; }); if (risk != null) return new Result(Status.Rejected, $"fraud: {risk}"); // 3. Authorize: reserve the money. The subclass's step. var 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 { var status = Timed(trace, "capture", Owner("capture"), () => Capture(req, auth)); return new Result(status); } catch (Exception e) { Timed<object?>(trace, "void", Owner("void"), () => { VoidAuthorization(auth); return null; }); return new Result(Status.Failed, $"capture failed: {e.Message}, 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 Timed<T>(List<Span> trace, string step, string owner, Func<T> body) { long start = Platform.Clock.Now; try { return body(); } 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 step) { if (step != "notify") return GetType().Name; var m = GetType().GetMethod(nameof(NotifyMessage), BindingFlags.Instance | BindingFlags.NonPublic)!; return m.DeclaringType == typeof(PaymentFlow) ? "PaymentFlow" : GetType().Name; }
// 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 virtual string NotifyMessage(PaymentRequest req, Result result) => $"{req.Key} receipt";}
public class CardFlow : PaymentFlow{ public int Charges; public CardFlow(Platform p) : base(p) { }
protected override Auth Authorize(PaymentRequest req) { Platform.Clock.Advance(120); return new Auth(true, $"auth-{req.Key}"); }
protected override Status Capture(PaymentRequest req, Auth auth) { Platform.Clock.Advance(80); Charges++; return Status.Captured; }
protected override void VoidAuthorization(Auth auth) => Platform.Clock.Advance(40);}
public class BankTransferFlow : PaymentFlow{ public BankTransferFlow(Platform p) : base(p) { }
protected override Auth Authorize(PaymentRequest req) { Platform.Clock.Advance(60); // check the customer's direct debit mandate return new Auth(true, $"mandate-{req.Key}"); }
protected override Status Capture(PaymentRequest req, Auth auth) { Platform.Clock.Advance(200); // start the transfer; it settles in a day or two return Status.Pending; }
protected override void VoidAuthorization(Auth auth) => Platform.Clock.Advance(40);
protected override string NotifyMessage(PaymentRequest req, Result result) => $"{req.Key} transfer started (1-2 business days)";}
public class WalletFlow : PaymentFlow{ private long _balance = 50; public WalletFlow(Platform p) : base(p) { }
protected override Auth Authorize(PaymentRequest req) { Platform.Clock.Advance(30); if (req.Amount > _balance) return new Auth(false, Reason: "insufficient balance"); return new Auth(true, $"hold-{req.Key}"); }
protected override Status Capture(PaymentRequest req, Auth auth) { Platform.Clock.Advance(20); _balance -= req.Amount; return Status.Captured; }
protected override 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?public class FakeFlow : PaymentFlow{ public readonly List<string> Calls = new(); public FakeFlow(Platform p) : base(p) { }
protected override Auth Authorize(PaymentRequest req) { Calls.Add("authorize"); Platform.Clock.Advance(10); return new Auth(true, "fake"); }
protected override Status Capture(PaymentRequest req, Auth auth) { Calls.Add("capture"); Platform.Clock.Advance(10); throw new InvalidOperationException("network error"); }
protected override void VoidAuthorization(Auth auth) { Calls.Add("void"); Platform.Clock.Advance(5); }}
public static class Program{ static void Show(PaymentFlow flow, PaymentRequest req) { var r = flow.Process(req); var detail = r.Detail == "" ? "" : $" ({r.Detail})"; Console.WriteLine($"{req.Key} {req.Method} EUR {req.Amount}: {PaymentFlow.Label(r.Status)}{detail}"); }
public static void Main() { var platform = new Platform(); var card = new CardFlow(platform); var bank = new BankTransferFlow(platform); var wallet = new WalletFlow(platform); var fake = new FakeFlow(platform);
Show(card, new PaymentRequest("pay-1", "card", 49)); foreach (var s in card.LastTrace) Console.WriteLine($" {s.Step,-12}{s.Ms,4} 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) Console.WriteLine("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)
Console.WriteLine("ledger: " + string.Join(", ", platform.Ledger)); // ledger: pay-1 CAPTURED, pay-2 PENDING, pay-3 DECLINED, pay-4 REJECTED, pay-5 FAILED Console.WriteLine("outbox: " + string.Join(", ", platform.Outbox)); // outbox: pay-1 receipt, pay-2 transfer started (1-2 business days) Console.WriteLine($"card charges: {card.Charges}"); // card charges: 1 }}package main
import ( "errors" "fmt" "strings" "sync")
type Status string
const ( Captured Status = "CAPTURED" Pending Status = "PENDING" Declined Status = "DECLINED" Rejected Status = "REJECTED" Failed Status = "FAILED" InProgress Status = "IN_PROGRESS")
type PaymentRequest struct { Key string Method string Amount int}
type Result struct { Status Status Detail string}
type Auth struct { Approved bool ID string Reason string}
type Span struct { Step string Owner string Ms int}
type FakeClock struct{ now int }
func (c *FakeClock) Now() int { return c.now }func (c *FakeClock) Advance(ms int) { c.now += ms }
// IdempotencyStore remembers every key. Claim checks and stores under one lock, so two// copies of the same request arriving at once can't both get through: exactly one sees false.type IdempotencyStore struct { mu sync.Mutex results map[string]Result}
var claimed = Result{InProgress, "another request with this key is running"}
func (s *IdempotencyStore) Claim(key string) (Result, bool) { s.mu.Lock() defer s.mu.Unlock() if seen, ok := s.results[key]; ok { return seen, true } s.results[key] = claimed return Result{}, false}
func (s *IdempotencyStore) Complete(key string, r Result) { s.mu.Lock() defer s.mu.Unlock() s.results[key] = r}
// Platform is shared by every flow: the clock, the idempotency store, the ledger and the outbox.type Platform struct { Clock *FakeClock Idempotency *IdempotencyStore Ledger []string Outbox []string}
// Go has no inheritance, so there is no base class for teams to extend. The skeleton is a// PaymentFlow that holds a Method. Each team implements Method; only PaymentFlow.Process// runs the steps, so a Method can't reorder them or skip one.
// Method holds the abstract steps: what differs per payment method.type Method interface { Name() string // for the trace, such as "CardFlow" Authorize(req PaymentRequest) Auth Capture(req PaymentRequest, auth Auth) (Status, error) Void(auth Auth)}
// Notifier is the hook. A Method that doesn't implement it gets the default receipt.type Notifier interface { NotifyMessage(req PaymentRequest, r Result) string}
const fraudLimit = 5000
// PaymentFlow is the skeleton every payment method runs through, owned by the platform team.type PaymentFlow struct{ P *Platform }
// Process is the template method. It also returns the trace: every step is timed and// traced here, so no team writes timing code.func (f PaymentFlow) Process(m Method, req PaymentRequest) (Result, []Span) { var trace []Span timed := func(step, owner string, body func()) { start := f.P.Clock.Now() defer func() { trace = append(trace, Span{step, owner, f.P.Clock.Now() - start}) }() body() } // 1. Idempotency comes first, before any money moves. A duplicate returns the stored // result and is not charged, recorded or notified again. var seen Result var dup bool timed("idempotency", "PaymentFlow", func() { f.P.Clock.Advance(2) seen, dup = f.P.Idempotency.Claim(req.Key) }) if dup { if seen.Status == InProgress { return seen, trace } return Result{seen.Status, "duplicate, stored result"}, trace } result := f.charge(m, req, timed) // 5. The audit record runs however the steps ended. A Method can't skip it. timed("record", "PaymentFlow", func() { f.P.Clock.Advance(5) f.P.Ledger = append(f.P.Ledger, req.Key+" "+string(result.Status)) f.P.Idempotency.Complete(req.Key, result) }) // 6. Notify only when money moved. The message is a hook with a default. if result.Status == Captured || result.Status == Pending { owner, msg := "PaymentFlow", req.Key+" receipt" if n, ok := m.(Notifier); ok { owner, msg = m.Name(), n.NotifyMessage(req, result) } timed("notify", owner, func() { f.P.Clock.Advance(10) f.P.Outbox = append(f.P.Outbox, msg) }) } return result, trace}
func (f PaymentFlow) charge(m Method, req PaymentRequest, timed func(string, string, func())) Result { // 2. Fraud check. Added in version 2 of the skeleton as a base step, so no team had // to change their Method to get it. risk := "" timed("fraud", "PaymentFlow", func() { f.P.Clock.Advance(15) if req.Amount > fraudLimit { risk = fmt.Sprintf("over EUR %d limit", fraudLimit) } }) if risk != "" { return Result{Rejected, "fraud: " + risk} } // 3. Authorize: reserve the money. The Method's step. var auth Auth timed("authorize", m.Name(), func() { auth = m.Authorize(req) }) if !auth.Approved { return Result{Declined, auth.Reason} } // 4. Capture: take the money. If it fails after a successful authorize, the flow // releases the reservation, so no team has to remember to. var status Status var err error timed("capture", m.Name(), func() { status, err = m.Capture(req, auth) }) if err != nil { timed("void", m.Name(), func() { m.Void(auth) }) return Result{Failed, "capture failed: " + err.Error() + ", authorization voided"} } return Result{status, ""}}
type CardFlow struct { P *Platform Charges int}
func (*CardFlow) Name() string { return "CardFlow" }func (c *CardFlow) Authorize(req PaymentRequest) Auth { c.P.Clock.Advance(120) return Auth{true, "auth-" + req.Key, ""}}func (c *CardFlow) Capture(PaymentRequest, Auth) (Status, error) { c.P.Clock.Advance(80) c.Charges++ return Captured, nil}func (c *CardFlow) Void(Auth) { c.P.Clock.Advance(40) }
type BankTransferFlow struct{ P *Platform }
func (BankTransferFlow) Name() string { return "BankTransferFlow" }func (b BankTransferFlow) Authorize(req PaymentRequest) Auth { b.P.Clock.Advance(60) // check the customer's direct debit mandate return Auth{true, "mandate-" + req.Key, ""}}func (b BankTransferFlow) Capture(PaymentRequest, Auth) (Status, error) { b.P.Clock.Advance(200) // start the transfer; it settles in a day or two return Pending, nil}func (b BankTransferFlow) Void(Auth) { b.P.Clock.Advance(40) }func (BankTransferFlow) NotifyMessage(req PaymentRequest, _ Result) string { return req.Key + " transfer started (1-2 business days)"}
type WalletFlow struct { P *Platform Balance int}
func (*WalletFlow) Name() string { return "WalletFlow" }func (w *WalletFlow) Authorize(req PaymentRequest) Auth { w.P.Clock.Advance(30) if req.Amount > w.Balance { return Auth{false, "", "insufficient balance"} } return Auth{true, "hold-" + req.Key, ""}}func (w *WalletFlow) Capture(req PaymentRequest, _ Auth) (Status, error) { w.P.Clock.Advance(20) w.Balance -= req.Amount return Captured, nil}func (w *WalletFlow) Void(Auth) { w.P.Clock.Advance(40) }
// FakeFlow is a test double. The skeleton is tested once, with this fake, instead of once// per team: does a failed capture void the authorization and still write the audit record?type FakeFlow struct { P *Platform Calls []string}
func (*FakeFlow) Name() string { return "FakeFlow" }func (f *FakeFlow) Authorize(PaymentRequest) Auth { f.Calls = append(f.Calls, "authorize") f.P.Clock.Advance(10) return Auth{true, "fake", ""}}func (f *FakeFlow) Capture(PaymentRequest, Auth) (Status, error) { f.Calls = append(f.Calls, "capture") f.P.Clock.Advance(10) return "", errors.New("network error")}func (f *FakeFlow) Void(Auth) { f.Calls = append(f.Calls, "void") f.P.Clock.Advance(5)}
func show(r Result, req PaymentRequest) { detail := "" if r.Detail != "" { detail = " (" + r.Detail + ")" } fmt.Printf("%s %s EUR %d: %s%s\n", req.Key, req.Method, req.Amount, r.Status, detail)}
func main() { p := &Platform{Clock: &FakeClock{}, Idempotency: &IdempotencyStore{results: map[string]Result{}}} flow := PaymentFlow{p} card := &CardFlow{P: p} bank := BankTransferFlow{p} wallet := &WalletFlow{P: p, Balance: 50} fake := &FakeFlow{P: p} run := func(m Method, req PaymentRequest) []Span { r, trace := flow.Process(m, req) show(r, req) return trace }
for _, s := range run(card, PaymentRequest{"pay-1", "card", 49}) { fmt.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 flow stops it. run(card, PaymentRequest{"pay-1", "card", 49}) // pay-1 card EUR 49: CAPTURED (duplicate, stored result) run(bank, PaymentRequest{"pay-2", "bank", 900}) // pay-2 bank EUR 900: PENDING run(wallet, PaymentRequest{"pay-3", "wallet", 75}) // pay-3 wallet EUR 75: DECLINED (insufficient balance) run(card, PaymentRequest{"pay-4", "card", 9000}) // pay-4 card EUR 9000: REJECTED (fraud: over EUR 5000 limit) run(fake, PaymentRequest{"pay-5", "fake", 10}) // pay-5 fake EUR 10: FAILED (capture failed: network error, authorization voided) fmt.Println("fake calls: " + strings.Join(fake.Calls, ", ")) // fake calls: authorize, capture, void
// Another server has claimed pay-6 and is still working on it. p.Idempotency.Claim("pay-6") run(card, PaymentRequest{"pay-6", "card", 20}) // pay-6 card EUR 20: IN_PROGRESS (another request with this key is running)
fmt.Println("ledger: " + strings.Join(p.Ledger, ", ")) // ledger: pay-1 CAPTURED, pay-2 PENDING, pay-3 DECLINED, pay-4 REJECTED, pay-5 FAILED fmt.Println("outbox: " + strings.Join(p.Outbox, ", ")) // outbox: pay-1 receipt, pay-2 transfer started (1-2 business days) fmt.Printf("card charges: %d\n", card.Charges) // card charges: 1}type Status = 'CAPTURED' | 'PENDING' | 'DECLINED' | 'REJECTED' | 'FAILED' | 'IN_PROGRESS';
interface PaymentRequest { key: string; method: string; amount: number;}interface Result { status: Status; detail: string;}interface Auth { approved: boolean; id: string; reason: string;}interface Span { step: string; owner: string; ms: number;}
class FakeClock { private t = 0; now(): number { return this.t; } advance(ms: number): void { this.t += ms; }}
// Remembers every idempotency key. JavaScript runs one callback at a time, so claim() is// atomic as long as it doesn't await between the check and the store. If the steps were// async, the claim would still have to happen before the first await.class IdempotencyStore { private static readonly CLAIMED: Result = { status: 'IN_PROGRESS', detail: 'another request with this key is running' }; private readonly results = new Map<string, Result>();
claim(key: string): Result | undefined { const seen = this.results.get(key); if (seen === undefined) this.results.set(key, IdempotencyStore.CLAIMED); return seen; } complete(key: string, result: Result): void { this.results.set(key, result); }}
// Shared by every flow: the clock, the idempotency store, the ledger and the outbox.class Platform { readonly clock = new FakeClock(); readonly idempotency = new IdempotencyStore(); readonly ledger: string[] = []; readonly outbox: string[] = [];}
// 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 readonly FRAUD_LIMIT = 5000; lastTrace: Span[] = [];
constructor(protected readonly platform: Platform) { // TypeScript has no `final`; reject an override of process() when the flow is created. if (this.process !== PaymentFlow.prototype.process) { throw new TypeError(`${new.target.name} must not override process()`); } }
// The template method. No team can reorder the steps, skip idempotency or skip the // audit record, and every step is timed and traced the same way. process(req: PaymentRequest): Result { const trace: Span[] = []; this.lastTrace = trace; // 1. Idempotency comes first, before any money moves. A duplicate returns the stored // result and is not charged, recorded or notified again. const seen = this.timed(trace, 'idempotency', 'PaymentFlow', () => { this.platform.clock.advance(2); return this.platform.idempotency.claim(req.key); }); if (seen !== undefined) { return seen.status === 'IN_PROGRESS' ? seen : { status: seen.status, detail: 'duplicate, stored result' }; } let result: Result | undefined; try { result = this.charge(req, trace); } catch (e) { result = { status: 'FAILED', detail: (e as Error).message }; } finally { // 5. The audit record runs however the steps ended. A subclass can't skip it. const outcome: Result = result ?? { status: 'FAILED', detail: 'unknown' }; this.timed(trace, 'record', 'PaymentFlow', () => { this.platform.clock.advance(5); this.platform.ledger.push(`${req.key} ${outcome.status}`); this.platform.idempotency.complete(req.key, outcome); }); } // 6. Notify only when money moved. The message is a hook with a default. const done = result; if (done.status === 'CAPTURED' || done.status === 'PENDING') { this.timed(trace, 'notify', this.owner('notify'), () => { this.platform.clock.advance(10); this.platform.outbox.push(this.notifyMessage(req, done)); }); } return done; }
private charge(req: PaymentRequest, trace: Span[]): Result { // 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. const risk = this.timed(trace, 'fraud', 'PaymentFlow', () => { this.platform.clock.advance(15); return req.amount > PaymentFlow.FRAUD_LIMIT ? `over EUR ${PaymentFlow.FRAUD_LIMIT} limit` : undefined; }); if (risk !== undefined) return { status: 'REJECTED', detail: `fraud: ${risk}` }; // 3. Authorize: reserve the money. The subclass's step. const auth = this.timed(trace, 'authorize', this.owner('authorize'), () => this.authorize(req)); if (!auth.approved) return { status: 'DECLINED', detail: 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 { const status = this.timed(trace, 'capture', this.owner('capture'), () => this.capture(req, auth)); return { status, detail: '' }; } catch (e) { this.timed(trace, 'void', this.owner('void'), () => this.voidAuthorization(auth)); return { status: 'FAILED', detail: `capture failed: ${(e as Error).message}, 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 timed<T>(trace: Span[], step: string, owner: string, body: () => T): T { const start = this.platform.clock.now(); try { return body(); } finally { trace.push({ step, owner, ms: this.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 owner(step: string): string { if (step !== 'notify' || this.notifyMessage !== PaymentFlow.prototype.notifyMessage) return this.constructor.name; return 'PaymentFlow'; }
// Abstract steps: what differs per payment method. protected abstract authorize(req: PaymentRequest): Auth; protected abstract capture(req: PaymentRequest, auth: Auth): Status; protected abstract voidAuthorization(auth: Auth): void;
// Hook with a default. Bank transfers override it; cards and wallets don't need to. protected notifyMessage(req: PaymentRequest, _result: Result): string { return `${req.key} receipt`; }}
class CardFlow extends PaymentFlow { charges = 0; protected authorize(req: PaymentRequest): Auth { this.platform.clock.advance(120); return { approved: true, id: `auth-${req.key}`, reason: '' }; } protected capture(): Status { this.platform.clock.advance(80); this.charges++; return 'CAPTURED'; } protected voidAuthorization(): void { this.platform.clock.advance(40); }}
class BankTransferFlow extends PaymentFlow { protected authorize(req: PaymentRequest): Auth { this.platform.clock.advance(60); // check the customer's direct debit mandate return { approved: true, id: `mandate-${req.key}`, reason: '' }; } protected capture(): Status { this.platform.clock.advance(200); // start the transfer; it settles in a day or two return 'PENDING'; } protected voidAuthorization(): void { this.platform.clock.advance(40); } protected override notifyMessage(req: PaymentRequest): string { return `${req.key} transfer started (1-2 business days)`; }}
class WalletFlow extends PaymentFlow { private balance = 50; protected authorize(req: PaymentRequest): Auth { this.platform.clock.advance(30); if (req.amount > this.balance) return { approved: false, id: '', reason: 'insufficient balance' }; return { approved: true, id: `hold-${req.key}`, reason: '' }; } protected capture(req: PaymentRequest): Status { this.platform.clock.advance(20); this.balance -= req.amount; return 'CAPTURED'; } protected voidAuthorization(): void { this.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 { readonly calls: string[] = []; protected authorize(): Auth { this.calls.push('authorize'); this.platform.clock.advance(10); return { approved: true, id: 'fake', reason: '' }; } protected capture(): Status { this.calls.push('capture'); this.platform.clock.advance(10); throw new Error('network error'); } protected voidAuthorization(): void { this.calls.push('void'); this.platform.clock.advance(5); }}
function show(flow: PaymentFlow, req: PaymentRequest): void { const r = flow.process(req); console.log(`${req.key} ${req.method} EUR ${req.amount}: ${r.status}${r.detail ? ` (${r.detail})` : ''}`);}
const platform = new Platform();const card = new CardFlow(platform);const bank = new BankTransferFlow(platform);const wallet = new WalletFlow(platform);const fake = new FakeFlow(platform);
show(card, { key: 'pay-1', method: 'card', amount: 49 });for (const s of card.lastTrace) console.log(` ${s.step.padEnd(12)}${String(s.ms).padStart(4)} 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, { key: 'pay-1', method: 'card', amount: 49 });// pay-1 card EUR 49: CAPTURED (duplicate, stored result)show(bank, { key: 'pay-2', method: 'bank', amount: 900 });// pay-2 bank EUR 900: PENDINGshow(wallet, { key: 'pay-3', method: 'wallet', amount: 75 });// pay-3 wallet EUR 75: DECLINED (insufficient balance)show(card, { key: 'pay-4', method: 'card', amount: 9000 });// pay-4 card EUR 9000: REJECTED (fraud: over EUR 5000 limit)show(fake, { key: 'pay-5', method: 'fake', amount: 10 });// pay-5 fake EUR 10: FAILED (capture failed: network error, authorization voided)console.log('fake calls: ' + fake.calls.join(', '));// fake calls: authorize, capture, void
// Another server has claimed pay-6 and is still working on it.platform.idempotency.claim('pay-6');show(card, { key: 'pay-6', method: 'card', amount: 20 });// pay-6 card EUR 20: IN_PROGRESS (another request with this key is running)
console.log('ledger: ' + platform.ledger.join(', '));// ledger: pay-1 CAPTURED, pay-2 PENDING, pay-3 DECLINED, pay-4 REJECTED, pay-5 FAILEDconsole.log('outbox: ' + platform.outbox.join(', '));// outbox: pay-1 receipt, pay-2 transfer started (1-2 business days)console.log(`card charges: ${card.charges}`);// card charges: 1What to notice
Section titled “What to notice”- Invariants live in the template, where no subclass can skip them. Idempotency is the
first step and recording runs in
finally. A team’scapturecan 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.
claimdoes it in one step:putIfAbsentin Java,TryAddin C#, a lock in Python and Go. TypeScript runs one callback at a time. So it’s atomic, as long as there’s noawaitbetween the check and the store. A second copy that arrives while the first is running getsIN_PROGRESS, and should retry later.pay-6shows 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.
timedwraps 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 thatauthorizeinCardFlowtook 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.
FakeFlowscripts a capture failure, and records the calls it receives:authorize, capture, void, then aFAILEDrow 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 |
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.
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.
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.
Tradeoffs
Section titled “Tradeoffs”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.
In each language
Section titled “In each language”| 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.
Commonly confused with
Section titled “Commonly confused with”| 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. |
Where it shows up
Section titled “Where it shows up”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 “
tearDownnever throws”.
In real systems, you’ll find template methods in:
- Test frameworks (
setUpandtearDownaround 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
AbstractListandcollections.abc.Mapping. - Build tools’ task lifecycles, and every background job or worker base class.
Interview questions
Section titled “Interview questions”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 |
Further reading
Section titled “Further reading”- 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
The designbeyondcode digest, every Saturday
New deep dives, interview tips and site news in one short email. Free. Leave with one click.
We never sell or share your email. We only use it to send this newsletter.