← Design patterns
Behavior A shared algorithm with deliberate extension points

Template method

Keep the sequence in one place.

Two feeds import the same catalog. One puts the ID first; the other puts the title first. Some feeds need outer spaces removed. Every feed must still validate the entire batch before replacing the saved catalog.

Let the format supply the steps that vary. Give the order—and the decision to stop—one owner.

TypeScriptGoSame batch contract · distinct language mechanisms

01 / The idea

The second importer should not invent another order.

A pair of straightforward import functions can be easy to read. Each decodes a feed, prepares the values, checks them, and saves the result. While those functions stay small and independent, the duplication costs little.

The pressure appears when they are supposed to share a rule. A new importer checks duplicates before trimming, while the original checks afterward. Another writes each row as it goes, leaving earlier rows saved when a later one fails. The workflows now mean different things even though their callers expect the same batch behavior.

Template Method defines an algorithm in a shared method and lets subclasses supply selected steps. In the classic form, the base class calls required operations and optional hooks in the intended order. A hook is an extension point with default behavior; a required operation has to be supplied.

The same design intent can be expressed through interfaces, trait defaults, or functions receiving collaborators. Those mechanisms have different guarantees. We will keep the import contract stable while examining the differences.

The template

Own order and stopping.

Decode, normalize, validate the complete batch, then make one commit attempt.

The supplied steps

Know the feed.

Interpret its column order and choose whether to preserve or trim field values.

The caller and store

Own the import’s context.

Select the importer, supply the existing catalog, and handle the reported outcome.

02 / See the shape

Change decoding without rewriting the batch rules.

The required decode step accepts already separated cell arrays. Each row must contain exactly two strings. It maps either ID → title or title → ID into the same record shape.

The default normalization hook preserves field values. The trimming variants remove only outer ASCII spaces and tabs. The shared validator then checks IDs, nonempty titles, and duplicate IDs across the normalized batch. Only a fully valid batch reaches commit.

The basic view excerpts the sequence and hook contract. The practical view supplies the format implementations, validation, reports, and memory store. Follow the call site to see a successful batch survive a later rejected import.

The shared sequence and its hook contract, excerpted from the complete source. TypeScript uses a base-class method and Go uses a function receiving an interface.

TypeScriptReading
importer.ts
export abstract class ImportTemplate {
	protected abstract decode(rows: Rows): Decoded;
	protected normalize(items: readonly Item[]): Item[] {
		return copy(items);
	}
	run(rows: Rows, catalog: MemoryCatalog): Report {
		const trace: Stage[] = ['decode'];
		const decoded = this.decode(rows);
		if (decoded.row)
			return report({ outcome: 'invalid-row', row: decoded.row }, trace, [], catalog);
		trace.push('normalize');
		const candidate = this.normalize(decoded.items);
		trace.push('validate');
		const check = validate(candidate);
		if (check.outcome !== 'valid')
			return report({ outcome: check.outcome, row: check.row }, trace, candidate, catalog);
		trace.push('commit');
		const outcome = catalog.replace(candidate) ? 'imported' : 'write-failed';
		return report({ outcome, row: 0 }, trace, candidate, catalog);
	}
}
GoAlongside
importer.go
type ImportSteps interface {
	Decode(Rows) Decoded
	Normalize([]Item) []Item
}

// A shared function owns the sequence. Go has no subclass template method.
func RunImport(rows Rows, catalog *MemoryCatalog, steps ImportSteps) Report {
	trace := []string{"decode"}
	decoded := steps.Decode(rows)
	if decoded.Row != 0 {
		return report(Check{"invalid-row", decoded.Row}, trace, nil, catalog)
	}
	trace = append(trace, "normalize")
	candidate := steps.Normalize(decoded.Items)
	trace = append(trace, "validate")
	check := Validate(candidate)
	if check.Outcome != "valid" {
		return report(check, trace, candidate, catalog)
	}
	trace = append(trace, "commit")
	outcome := "imported"
	if !catalog.Replace(candidate) {
		outcome = "write-failed"
	}
	return report(Check{outcome, 0}, trace, candidate, catalog)
}
Reading the TypeScriptA required method and a default hook on an abstract class

ImportTemplate cannot be constructed directly. Its abstract decode method must be implemented by a concrete subclass. Normalize already has a default body, so a subclass can inherit it or replace it. The shared run method calls both through this.

Abstract and protected are compile-time rules. TypeScript reports a subclass that leaves out decode, but when Node strips the types without checking them, that subclass runs until run reaches decode and then throws “TypeError: this.decode is not a function.” Run is overridable too: the supplied subclasses preserve it, but the language does not mark that sequence final. The TypeScript class documentation shows the same base-method-to-abstract-method relationship.

Reading the GoAn interface supplies steps; a function owns the sequence

Go has no subclass template method. RunImport receives an ImportSteps interface and explicitly calls Decode and Normalize. Embedding KeepValues supplies a reusable normalization method; TrimmedImporter delegates decoding and supplies a different normalization method. The compiler checks both steps: passing a type without Normalize to RunImport fails with “does not implement ImportSteps (missing method Normalize).”

Embedding does not make an inner method dispatch back to the outer type as though it were a subclass. Effective Go says that when promoted methods are invoked, “the receiver of the method is the inner type, not the outer one.” If the sequence were a Run method on an embedded base type, its call to Normalize would always reach the base’s Normalize, even on an outer type that declares its own. Passing the step implementation to the shared function makes the dispatch explicit.

03 / Follow the sequence

Normalization can change what validation must see.

Start with the spaced fields and trimming enabled. The resulting IDs should be valid. Turn trimming off and predict whether the catalog will be replaced.

Then turn trimming back on and choose the collision scenario. Before trimming, “same” and “ same ” are different strings. After trimming, they identify the same record. Finally, make the store reject a valid batch: reaching commit and successfully saving are separate observations.

Keep the order, vary the steps

Predict whether commit will be attempted. Change the layout or normalization hook, then inspect the candidate batch and the saved catalog.

Incoming cells

Changing layout rearranges the two cells while preserving their values. You can edit either field. Outer spaces are significant when trimming is off.

RowIDTitle
1
2
  1. 01 / Required stepDecodeWaiting
  2. 02 / Default or custom hookNormalizeWaitingTrim ASCII spaces and tabs
  3. 03 / Shared batch ruleValidateWaiting
  4. 04 / Shared final stepCommitWaiting

Normalized candidates

These values are still a candidate batch until every shared check passes.

Run the import to inspect its candidates.

Saved catalog

Each experiment begins with “Already here.” A successful commit replaces the whole batch.

  1. "existing"→"Already here"
This runs the displayed TypeScript with already separated cells and a fresh memory catalog for each run. The template and composed versions share the behavior contract. Go is verified separately.

04 / Try a decision

Which method actually needs to change?

A new column order changes the decoder. It does not change what makes an ID valid or whether half a batch is an acceptable result. A proposed override should be assessed against that responsibility.

A new feed reverses the two columns.

A teammate proposes overriding the entire run method to get the new importer working quickly. Which extension preserves the existing batch contract?

Reasoning and a stopping pointAlso available without JavaScript

The intended sequence is decode → normalize → validate → commit. Decoding varies with the feed. The shared workflow validates all normalized candidates before making one replacement call.

TypeScript subclasses can replace run. A function receiving step implementations keeps the entry point outside those implementations, although supplied code can still have side effects of its own.

If the new feed requires incremental commits, it needs a different failure and recovery policy. Do not hide that change behind a hook intended only to decode a format.

Which part of your workflow varies? Which ordering rule must stay together? What change would deserve a different workflow?

This reflection is not saved or automatically assessed.

05 / Change the boundary

A function can receive the steps it needs.

The TypeScript class example already needs trimmed and untrimmed variants of both layouts. That is manageable here, but more independent choices can multiply subclasses. Supplying decoding and normalization separately lets setup combine them directly.

The composed version keeps the sequence in a function and receives an ImportSteps object. A step provider does not override that function. It supplies behavior only at the calls the function makes. The lab runs either TypeScript form against the same inputs; the shared tests compare their results.

Read the composed TypeScript sequenceExtracted from the same complete source
composed-import.ts
export interface ImportSteps {
	decode(rows: Rows): Decoded;
	normalize(items: readonly Item[]): Item[];
}
export function runComposed(rows: Rows, catalog: MemoryCatalog, steps: ImportSteps): Report {
	const trace: Stage[] = ['decode'];
	const decoded = steps.decode(rows);
	if (decoded.row) return report({ outcome: 'invalid-row', row: decoded.row }, trace, [], catalog);
	trace.push('normalize');
	const candidate = steps.normalize(decoded.items);
	trace.push('validate');
	const check = validate(candidate);
	if (check.outcome !== 'valid')
		return report({ outcome: check.outcome, row: check.row }, trace, candidate, catalog);
	trace.push('commit');
	const outcome = catalog.replace(candidate) ? 'imported' : 'write-failed';
	return report({ outcome, row: 0 }, trace, candidate, catalog);
}
export function createSteps(layout: Layout, trim: boolean): ImportSteps {
	const reversed = titleFirst(layout);
	return {
		decode: (rows) => decodeColumns(rows, reversed),
		normalize: trim ? trimFields : copy
	};
}

In an application, choose one owner for the sequence instead of maintaining both copies. Both fields are required, so TypeScript rejects a setup object that leaves one out.

Keep the class form when a framework already provides a base algorithm and a small, coherent protected surface. Prefer supplied steps when their combinations vary independently or runtime configuration makes subclass selection awkward. The decision is about where variation belongs, not whether a class appears in the file.

06 / Give it a real job

Validate the whole candidate before one replacement.

Decoding checks row shape across the batch before normalization begins. Validation then rejects an empty batch. In row order, it checks the ID, the title, and then whether that ID appeared earlier. IDs use 1–24 ASCII characters: a lowercase letter first, followed by lowercase letters, digits, or hyphens. Titles only reject the empty string.

A report identifies the failing row when one exists and lists the stages that were attempted. Normalized candidates remain visible after a validation failure; they have not become saved records. An importer can be reused because each run keeps its own candidates and trace.

The MemoryCatalog copies incoming and outgoing records. Its controlled write failure happens before mutation, so the earlier catalog survives. Success replaces the entire catalog rather than merging into it.

Where each new import requirement is decided
New requirementWhere to decide it
A feed swaps columnsSupply a decoder for that representation. Keep the shared record shape and sequence.
Unicode normalization or case foldingDefine the normalization policy and validate its output. Revisit identity collisions deliberately.
Stream millions of rowsChoose buffering, partial commits, recovery, and duplicate detection. This may require another workflow.
Save to a real databaseDefine constraints, transactions, concurrency, and retry semantics at the storage boundary.
What the shared sequence protectsFailures, side effects, and other writers

The supplied hooks are synchronous transformations. Unexpected exceptions or panics propagate instead of becoming a successful report, and nothing undoes side effects a new hook performs before failing.

The shared sequence protects this import path, but the memory store does not validate direct writes, so an override or another caller can bypass it. Real storage must enforce any constraints that need to hold across all callers.

Build UIs?The browser runs a custom element’s lifecycle and calls the steps your class supplies. A few elements of your own make that order yours to keep.

Where it already is in your components

If you have moved code out of a custom element’s constructor because this.getAttribute('label') came back null, you already follow a rule that comes from this pattern. The browser owns the element’s lifecycle and calls the steps your class supplies: the constructor when the element is created or upgraded, attributeChangedCallback when an attribute changes, connectedCallback when it is inserted, and disconnectedCallback when it is removed. A callback your class leaves out is not called, and attributeChangedCallback runs only for the names in static observedAttributes.

The constructor is the first step, so it runs before anything else has happened to the element. The HTML Standard says: “The element's attributes and children must not be inspected, as in the non-upgrade case none will be present, and relying on upgrades makes the element less usable.” It also says: “The element must not gain any attributes or children, as this violates the expectations of consumers who use the createElement or createElementNS methods.”

Frameworks take the non-upgrade case. React 19.1.0 creates the element with document.createElement and then sets label; Svelte 5.57 clones a template without the attribute and then sets it. In Chromium 153 both constructors saw label as null, then attributeChangedCallback received "Paid", then connectedCallback ran. A constructor that called this.setAttribute('role', 'status') broke only in React: Chromium reported “Failed to execute 'createElement' on 'Document': The result must not have attributes” and left an HTMLUnknownElement whose callbacks never ran. Svelte’s clone was upgraded without an error, which is the reliance on upgrades the Standard warns about. Read attributes in attributeChangedCallback, which the browser calls with each new value, and leave rendering to connectedCallback.

When you have to own it

Now picture a docs site that ships a few custom elements of its own: <copy-button>, <relative-time>, and <code-tabs>. Each needs the same order around its own details: it renders its children once, updates them when an observed attribute changes, starts listeners or timers when it connects, and stops them when it disconnects. Written separately, each element can get that order wrong in its own way.

The browser’s order has two surprises. When a framework creates the element, attributeChangedCallback runs before connectedCallback, so an update that looks for rendered children finds none: in our Chromium run it threw “Cannot set properties of null (setting 'textContent')”. And the Standard notes that “connectedCallback can be called more than once”: moving the element calls it again, so an element that rendered there without a guard had two copies of its children after one move.

That is the decision this lesson makes. A small base class can own the order in its lifecycle callbacks: render on the first connection only, skip attribute updates until then, and call the cleanup a listener step returned when the element disconnects. Each element supplies a required render step and optional update and listen hooks whose defaults do nothing. In the same run, a relative-time element built this way rendered once across a move, updated on each connection and on a later attribute change, and cleared its interval on each disconnect.

The import’s limits carry over. An element that overrides connectedCallback without calling super.connectedCallback() skips the base class’s steps; ours rendered nothing. In plain JavaScript a missing render fails only at runtime, where the browser reported “this.render is not a function” from the callback, while an abstract render() in TypeScript is a compile error. If one element needs a different order, give it its own lifecycle rather than another hook.

07 / Already in your toolbox

Recognize who owns the lifecycle.

Python’s unittest.TestCase provides a familiar base-class relationship: the runner invokes a test alongside setup and teardown behavior supplied by the test class. Setup and teardown have default implementations.

The details matter. The documented tearDown method runs after the test when setUp succeeded, including when the test raised an exception. Registered cleanup functions have their own rules. Recognizing a shared algorithm with hooks is useful; assuming every hook always runs would miss the framework’s actual contract.

08 / Take the idea with you

Which rule belongs to the shared sequence?

Explain the design without saying “Template Method”: this owner calls these steps in this order, stops under these conditions, and lets each variant supply these particular decisions. Then name one requested change that would no longer fit that sequence.

Use Template Method when variants genuinely share an algorithm and its extension points can stay small. If a subclass needs a different order, examine whether it needs a different workflow.

Connections to follow nextRelated lessons

Strategy supplies an interchangeable policy. A template method fixes the surrounding algorithm and varies designated steps. A composed workflow can use strategies for those steps; the responsibilities can fit together.

Composition over inheritance explores separating independent variation. Parse, don’t validate develops boundaries that produce trustworthy values. Here, cell decoding establishes a record shape, while the later shared check establishes this batch’s domain rules.

Chain of responsibility lets handlers decide whether to handle or pass along a request. Our import sequence calls designated stages in a known order and stops according to their results. A list of calls alone does not make those designs equivalent.