Template method, framework hooks, and the step passed as a function
The base owns the order and calls your step; in your own code, hand the step in as a function.
Predict first
Before you read: predict
A payments ledger's back office logs its staff in through @nestjs/passport and passport-local:
A client posts {"username":"ana"} to /login, with no password field.
What status does it get?
// staff.strategy.tsimport { Injectable } from '@nestjs/common';import { PassportStrategy } from '@nestjs/passport';import { Strategy } from 'passport-local';import { createHash } from 'node:crypto'; const digest = (s: string): string => createHash('sha256').update(s).digest('hex');const staff: Record<string, string> = { ana: digest('ledger-pin-7') }; @Injectable()export class StaffStrategy extends PassportStrategy(Strategy) { async validate(username: string, password: string) { return staff[username] === digest(password) ? { username } : null; }} // login.controller.tsimport { Controller, HttpCode, Post, Req, UseGuards } from '@nestjs/common';import { AuthGuard } from '@nestjs/passport'; @Controller()export class LoginController { @Post('login') @HttpCode(200) @UseGuards(AuthGuard('local')) login(@Req() req: { user: { username: string } }) { return { welcome: req.user.username }; }} // app.module.tsimport { Module } from '@nestjs/common';import { LoginController } from './login.controller';import { StaffStrategy } from './staff.strategy'; @Module({ controllers: [LoginController], providers: [StaffStrategy] })export class AppModule {}The 30-second version
A template method is a base-class method that runs an algorithm's steps in a fixed order, leaving some to subclasses. Frameworks use the shape: passport-local reads both fields from the request and calls your validate() only when both are present; the guard answers any failed login with 401. In your own code, pass the varying step as a function.
The base owns the order and calls your step; in your own code, hand the step in as a function.
How it works
Step 1 of 12
In the kitchen
- the law fixes a signing's order: check each signer's identity, read the deed aloud, the parties sign, the notary seals → the template method's fixed skeleton (step 1)
Analogy: A notary's signing appointment (legal services)
| At the notary's office | In code |
|---|---|
| the law fixes a signing's order: check each signer's identity, read the deed aloud, the parties sign, the notary seals | the template method's fixed skeleton (step 1) |
| the client's lawyer must draft the deed's terms; the attestation clause has standard wording unless the lawyer writes one | a required step and a hook with a default (step 2) |
| the client never tells the notary when to read or seal; the notary calls each party in at its turn | the base calls the subclass's step (steps 3, 5) |
| for a note between two neighbours, they write it and sign it at the kitchen table | the step passed as a function, with no base to extend (step 10) |
In real terms: the code that owns the order calls yours at fixed points; whatever you write runs only when, and only if, that code reaches its point.
Breaks down when: a notary can refuse a clause she judges unlawful. A base class runs whatever step a subclass gives it.
Contrasting case: a shopping list you write and work through yourself. Nobody else owns the order, so there is nothing to extend: call your own functions.
Examples, run for real
4.1 ExamHall EXH-4860: three importers as subclasses
ExamHall's question bank took exams from CSV, QTI and Moodle files. Each importer was a subclass of one base whose importExam ran parse → validate → dedupe → save:
// exam-importer.ts
export interface Question { id: string; stem: string }
export abstract class ExamImporter {
readonly saved: Question[] = [];
importExam(source: string): number {
const valid = this.parse(source).filter((q) => q.stem.trim() !== '');
const unique = [...new Map(valid.map((q) => [q.id, q])).values()];
this.saved.push(...unique);
return unique.length;
}
protected abstract parse(source: string): Question[];
}
export class CsvImporter extends ExamImporter {
protected parse(source: string): Question[] {
return source.trim().split('\n').map((row) => { const [id, stem] = row.split(';'); return { id, stem }; });
}
}
4.1 EXH-4860 csv 4 parsed → 2 saved · qti 3 parsed → 2 saved · moodle 3 parsed → 2 saved · 4 classes (1 base, 3 subclasses) · tsc no diagnostics
Each subclass wrote only parse; the base ran the same four steps in the same order for all three formats (steps 1 and 3).
4.2 MR !5160: one function, the parse step passed in
// import-exam.ts (MR !5160)
export interface Question { id: string; stem: string }
export function importExam(source: string, { parse }: { parse: (source: string) => Question[] }, saved: Question[]): number {
const valid = parse(source).filter((q) => q.stem.trim() !== '');
const unique = [...new Map(valid.map((q) => [q.id, q])).values()];
saved.push(...unique);
return unique.length;
}
// importExam(file, { parse: parseCsv }, bank)
4.2 MR !5160 importExam(source, { parse }, bank): the same questions saved for 3 of 3 formats · classes in import-exam.ts 0 · tsc no diagnostics
The function owns the order the base owned; each format hands in its parser, and no class is left to extend (step 10).
Try it
Answer first, then check. Nothing is saved.
Question 1 of 7
A video platform publishes every upload through one base class:
The four files are compiled together with strict on.
In which files does tsc report an error?
// publish-job.tsexport abstract class PublishJob { run(video: string): string[] { const steps = [this.encode(video)]; if (this.needsCaptions(video)) steps.push(`captions ${video}`); steps.push(this.upload(video)); return steps; } protected abstract encode(video: string): string; protected needsCaptions(video: string): boolean { return true; } protected abstract upload(video: string): string;} // trailer-job.tsimport { PublishJob } from './publish-job';export class TrailerJob extends PublishJob { protected encode(video: string): string { return `encode ${video} 1080p`; } protected needsCaptions(video: string): boolean { return false; }} // episode-job.tsimport { PublishJob } from './publish-job';export class EpisodeJob extends PublishJob { protected encode(video: string): string { return `encode ${video} 4k`; } protected upload(video: string): string { return `upload ${video} to cdn`; }} // clip-job.tsimport { PublishJob } from './publish-job';export class ClipJob extends PublishJob { protected encode(video: string): string { return `encode ${video} 720p`; } protected needsCaptions(video: string): boolean { return false; } protected upload(video: string): string { return `upload ${video} to edge`; }}Question 2 of 7
A payments ledger imports bank statements. An MR replaces the base class and its two subclasses with one function:
The callers become importAll(csv, { parse: parseCsv(';') }) and importAll(camt, { parse: parseCamt }).
Which review comment names a defect this MR introduces?
// before/statement.tsexport interface Line { date: string; ref: string; amount: number }export abstract class StatementImport { importAll(raw: string): string[] { const seen = new Set<string>(); const out: string[] = []; for (const l of this.parse(raw)) { const cents = this.cents(l); if (cents === 0 || seen.has(l.ref)) continue; seen.add(l.ref); out.push(`${l.date} ${l.ref} ${cents}`); } return out; } protected abstract parse(raw: string): Line[]; protected cents(l: Line): number { return l.amount; }} // before/csv-import.tsimport { StatementImport, type Line } from './statement';export class CsvImport extends StatementImport { constructor(private sep = ';') { super(); } protected parse(raw: string): Line[] { return raw.trim().split('\n').map((row) => { const [date, ref, amount] = row.split(this.sep); return { date, ref, amount: Number(amount) }; }); }} // before/camt-import.tsimport { StatementImport, type Line } from './statement';export class CamtImport extends StatementImport { protected parse(raw: string): Line[] { return JSON.parse(raw).entries.map((e: { d: string; id: string; eur: string }) => ({ date: e.d, ref: e.id, amount: Number(e.eur) })); } protected cents(l: Line): number { return Math.round(l.amount * 100); }} // mr/statement.tsexport interface Line { date: string; ref: string; amount: number }export function importAll(raw: string, steps: { parse: (raw: string) => Line[] }): string[] { const seen = new Set<string>(); const out: string[] = []; for (const l of steps.parse(raw)) { if (l.amount === 0 || seen.has(l.ref)) continue; seen.add(l.ref); out.push(`${l.date} ${l.ref} ${l.amount}`); } return out;}export const parseCsv = (sep: string) => (raw: string): Line[] => raw.trim().split('\n').map((row) => { const [date, ref, amount] = row.split(sep); return { date, ref, amount: Number(amount) }; });export const parseCamt = (raw: string): Line[] => JSON.parse(raw).entries.map((e: { d: string; id: string; eur: string }) => ({ date: e.d, ref: e.id, amount: Number(e.eur) }));Question 3 of 7
A chat service logs every error through one filter:
A client sends GET /rooms/r1.
What does logged hold, and what does the client receive?
// log-errors.filter.tsimport { ArgumentsHost, Catch } from '@nestjs/common';import { BaseExceptionFilter } from '@nestjs/core'; export const logged: string[] = []; @Catch()export class LogErrors extends BaseExceptionFilter { catch(exception: unknown, host: ArgumentsHost) { logged.push(`logged: ${(exception as Error).message}`); }} // rooms.controller.tsimport { Controller, Get, Param } from '@nestjs/common'; @Controller('rooms')export class RoomsController { @Get(':id') find(@Param('id') id: string) { throw new Error(`room ${id} store down`); }} // app.module.tsimport { Module } from '@nestjs/common';import { APP_FILTER } from '@nestjs/core';import { LogErrors } from './log-errors.filter';import { RoomsController } from './rooms.controller'; @Module({ controllers: [RoomsController], providers: [{ provide: APP_FILTER, useClass: LogErrors }] })export class AppModule {}Question 4 of 7
A box office opens its tills with a cashier's PIN:
Kai posts his right PIN, {"username":"kai","password":"4417"}, and Zoe posts a wrong one, {"username":"zoe","password":"0000"}, to /till/open.
What do the two requests get?
// cashier.strategy.tsimport { Injectable } from '@nestjs/common';import { PassportStrategy } from '@nestjs/passport';import { Strategy } from 'passport-local'; const cashiers = [{ name: 'kai', pin: '4417' }, { name: 'zoe', pin: '9021' }]; @Injectable()export class CashierStrategy extends PassportStrategy(Strategy) { async validate(username: string, password: string) { return cashiers.findIndex((c) => c.name === username && c.pin === password); }} // till.controller.tsimport { Controller, HttpCode, Post, Req, UseGuards } from '@nestjs/common';import { AuthGuard } from '@nestjs/passport'; @Controller()export class TillController { @Post('till/open') @HttpCode(200) @UseGuards(AuthGuard('local')) open(@Req() req: { user: unknown }) { return { cashier: req.user }; }} // app.module.tsimport { Module } from '@nestjs/common';import { CashierStrategy } from './cashier.strategy';import { TillController } from './till.controller'; @Module({ controllers: [TillController], providers: [CashierStrategy] })export class AppModule {}Question 5 of 7
The interviewer says: "ExamHall's results export used a base class with header(), line(row) and an optional footer(count) hook. Write it as one function instead: exportResults(rows, steps)." It returns an array of strings: steps.header() first, then steps.line(row) for each row in order, then steps.footer(rows.length) last when a footer is given. footer is optional; when line is missing, it throws a TypeError before any step runs. You have 15 minutes and a plain editor.
Say your test list first, then write it.
Question 6 of 7
A chat app's moderators log in with a PIN:
Every moderator login, right PIN or wrong, gets 500.
Which change makes a right PIN get 200 and a wrong one 401?
// moderator.strategy.tsimport { Injectable } from '@nestjs/common';import { PassportStrategy } from '@nestjs/passport';import { Strategy } from 'passport-local'; const pins: Record<string, string> = { mod1: '5150' }; @Injectable()export class ModeratorStrategy extends PassportStrategy(Strategy) { async validate(username: string, password: string) { return pins[username] === password ? { username } : null; }} // moderation.controller.tsimport { Controller, HttpCode, Post, Req, UseGuards } from '@nestjs/common';import { AuthGuard } from '@nestjs/passport'; @Controller('moderation')export class ModerationController { @Post('login') @HttpCode(200) @UseGuards(AuthGuard('local')) login(@Req() req: { user: { username: string } }) { return { moderator: req.user.username }; }} // app.module.tsimport { Module } from '@nestjs/common';import { ModerationController } from './moderation.controller'; @Module({ controllers: [ModerationController] })export class AppModule {}Question 7 of 7
A video platform logs every error with a filter that hands each one back to Nest:
setup(app) runs right after NestFactory.create.
Which argument to new LogErrors(…) makes GET /videos/v9/manifest log logged: no renditions for v9 and answer 500 {"statusCode":500,"message":"Internal server error"}?
// log-errors.filter.tsimport { ArgumentsHost, Catch } from '@nestjs/common';import { BaseExceptionFilter } from '@nestjs/core'; export const logged: string[] = []; @Catch()export class LogErrors extends BaseExceptionFilter { catch(exception: unknown, host: ArgumentsHost) { logged.push(`logged: ${(exception as Error).message}`); super.catch(exception, host); }} // videos.controller.tsimport { Controller, Get, Param } from '@nestjs/common'; @Controller('videos')export class VideosController { @Get(':id/manifest') manifest(@Param('id') id: string) { throw new Error(`no renditions for ${id}`); }} // app.module.tsimport { Module } from '@nestjs/common';import { VideosController } from './videos.controller'; @Module({ controllers: [VideosController] })export class AppModule {} // main.tsimport { INestApplication } from '@nestjs/common';import { HttpAdapterHost } from '@nestjs/core';import { LogErrors } from './log-errors.filter'; export function setup(app: INestApplication): void { // app.useGlobalFilters(new LogErrors(<the argument the team must choose>));}Go deeper
Pro tips
- If a subclass overrides more than one or two steps, or calls
superin the middle of one, the skeleton is wrong: pass functions instead. When: reviewing a hierarchy where every subclass rewrites most of the base. Verify: nothing to run; this is the curriculum row's design advice, and it keys nothing. Status: ADVICE. PassportStrategy(Strategy, 'staff')registers the strategy under the name you give, so two passport-local logins can live in one app. When: staff and customers log in with different user stores. Verify (RAN,tips.mjs):
tip 2 PassportStrategy(Strategy, 'staff') and (Strategy, 'customer') in one app: staff login 200 {"username":"ana","via":"staff"} · customer login 200 {"username":"bo","via":"customer"} · a staff user at the customer route 401
Source
Every graded behaviour is executed, so no item is keyed on these pages; they back the teaching. Valid as of 2026-10-17.
- NestJS docs, Passport (authentication) recipe (the row's second source), https://raw.githubusercontent.com/nestjs/docs.nestjs.com/fcc82097df56e6f318960d634c17c2177e17769e/content/recipes/passport.md (
nestjs-passport.txt). "With@nestjs/passport, you configure a Passport strategy by extending thePassportStrategyclass." and "You provide the verify callback (item 2 above) by implementing avalidate()method in your subclass." (steps 5–6); "the guard takes the value returned from thevalidate()method and assigns it to theRequestobject asreq.user" (step 7); "When we extended the passport-local strategy,@nestjs/passportregistered ourLocalStrategywith Passport under that default name" (step 9). - NestJS docs, Exception filters, section "Inheritance" (not named by the row; teaching only), https://raw.githubusercontent.com/nestjs/docs.nestjs.com/fcc82097df56e6f318960d634c17c2177e17769e/content/exception-filters.md (
nestjs-exception-filters.txt). "To delegate exception processing to the base filter, extendBaseExceptionFilterand call the inheritedcatch()method." (step 8); "The first is to pass theHttpAdapterreference to the constructor when instantiating the custom global filter" (step 9, card 10). - The row's first source, Gamma, Helm, Johnson and Vlissides, Design Patterns (1994), Template Method, is a book that cannot be fetched as text, and keys nothing.
Related lessons
- Read first: Composition over inheritance, and the fragile base class
- Related: Open/closed through the strategy pattern
- Related: The request pipeline: middleware, guards, interceptors, pipes, handler, filters
- Related: Dependency inversion and NestJS dependency injection
- Related: NestJS modules, custom providers and why DI makes testing cheap
Checked against the tools and docs as of 2026-10-17.