Skip to content

A specification is one rule with two readers

Write the rule once, as an object two readers can interpret; keep it a function until a second reader needs it.

Predict first

Before you read: predict

ExamHall shows a "needs review" badge on each attempt and lists the same attempts in a review queue. Each screen has its own copy of the rule, and an MR has just added "or late" to both copies. With the rows below, the badges read "needs review" on attempts 1 and 2. main runs once against a test database; the files are below.

What does main print?

Code for this question
// attempt.tsexport interface Attempt { id: number; exam_code: string; status: string; flagged: boolean; minutes_late: number } // badge.ts (the badge's copy of the rule)import type { Attempt } from './attempt';export const needsReview = (a: Attempt) =>  a.exam_code === 'ECON-201' && a.status === 'submitted' && (a.flagged || a.minutes_late > 0); // queue.ts (the review queue's copy of the rule)export const QUEUE_SQL = `SELECT id FROM attempts  WHERE exam_code = 'ECON-201' AND status = 'submitted' AND flagged OR minutes_late > 0  ORDER BY id`; // db.ts (a client for ExamHall's PostgreSQL database)export interface Db { exec(sql: string): Promise<void>; query<R>(text: string, values?: unknown[]): Promise<{ rows: R[] }> } // main.tsimport type { Db } from './db';import { QUEUE_SQL } from './queue';export async function main(db: Db) {  await db.exec(`CREATE TABLE attempts (id int, exam_code text, status text, flagged boolean, minutes_late int);    INSERT INTO attempts VALUES (1, 'ECON-201', 'submitted', true, 0), (2, 'ECON-201', 'submitted', false, 4),      (3, 'ECON-201', 'submitted', false, 0), (4, 'ECON-201', 'started', false, 6), (5, 'STAT-110', 'submitted', false, 2);`);  const queue = (await db.query<{ id: number }>(QUEUE_SQL)).rows.map((r) => r.id);  console.log(`queue: ${queue.join(', ')}`);}
Pick one

The 30-second version

A specification is one business rule as an object: isSatisfiedBy(x) says whether x meets it, and and, or and not build bigger rules into a tree. The same tree can also be read as a query filter, so a screen and a query share one definition. A rule with one reader that is never combined stays a plain function.

Write the rule once, as an object two readers can interpret; keep it a function until a second reader needs it.

How it works

Step 1 of 12

A rule as an object. A specification holds one business rule, such as "needs review", and answers isSatisfiedBy(candidate) with true or false.
In the kitchen
  • she writes one brief for the doors she needs: four panels, pine or oak, not stripped → a rule as an object (step 1)
  • she holds a door leaning on the yard wall against her brief → isSatisfiedBy (step 1)

Analogy: An architectural salvage yard and a restorer's door brief

A restorer is fitting out an old house with reclaimed doors.

At the salvage yard In code
she writes one brief for the doors she needs: four panels, pine or oak, not stripped a rule as an object (step 1)
"pine or oak" and "not stripped" are parts of the brief, read together leaves and composites (step 2)
she holds a door leaning on the yard wall against her brief isSatisfiedBy (step 1)
the clerk types the same brief into the yard's stock system to list doors in the sheds a second reader (step 4)
a yard hand searching from his own scribbled copy of the brief brings out doors she would turn away two copies are two rules (step 5)

In real terms: one brief, read by the restorer at the wall and by the clerk at the stock system; when the two readings disagree, look at how each reads it, not only at the brief.

Breaks down when: the restorer can bend her brief for a door she loves; a specification gives the same answer every time.

Contrasting case: someone picking one door at a car-boot sale needs no written brief; one look decides (step 12).

Examples, run for real

4.1 ExamHall: one review rule, two readers

EXH-4900: as in t1, the badge and the review queue each carry their own copy of ExamHall's review rule, and they disagree. MR !5200 writes the rule once:

// spec.ts (MR !5200)
export class Spec<T> {
  constructor(readonly test: (c: T) => boolean, readonly where: (values: unknown[]) => string) {}
  isSatisfiedBy(c: T): boolean { return this.test(c); }
  toSql() { const values: unknown[] = []; return { text: this.where(values), values }; }
  and(o: Spec<T>): Spec<T> { return new Spec<T>((c) => this.test(c) && o.test(c), (v) => `(${this.where(v)} AND ${o.where(v)})`); }
  or(o: Spec<T>): Spec<T> { return new Spec<T>((c) => this.test(c) || o.test(c), (v) => `(${this.where(v)} OR ${o.where(v)})`); }
}
export const param = (values: unknown[], x: unknown) => `$${values.push(x)}`;

// review-rule.ts (MR !5200)
import { Spec, param } from './spec';
import type { Attempt } from './attempt';
const inExam = (code: string) => new Spec<Attempt>((a) => a.exam_code === code, (v) => `exam_code = ${param(v, code)}`);
const submitted = new Spec<Attempt>((a) => a.status === 'submitted', () => `status = 'submitted'`);
const flagged = new Spec<Attempt>((a) => a.flagged, () => 'flagged');
const late = new Spec<Attempt>((a) => a.minutes_late > 0, () => 'minutes_late > 0');
export const needsReview = inExam('ECON-201').and(submitted).and(flagged.or(late));

The badge calls needsReview.isSatisfiedBy(attempt); the queue sends SELECT id FROM attempts WHERE ${text} ORDER BY id with needsReview.toSql()'s values. On t1's five rows:

4.1 EXH-4900 as shipped (two copies): badge 1, 2 · queue 1, 2, 4, 5
4.1 MR !5200 toSql: ((exam_code = $1 AND status = 'submitted') AND (flagged OR minutes_late > 0)) · values ["ECON-201"]
4.1 MR !5200 (one rule): badge 1, 2 · queue 1, 2 · tsc no diagnostics

Before the MR, each screen read its own copy and the queue held two attempts the badge did not. After it, both read one tree: the badge walks it in memory and the queue sends its query text, so a later edit to the rule reaches both (steps 4–5).

4.2 What the MR leaves as a function

ExamHall's "may start now" check runs only when a candidate presses Start, joins no other rule and is never queried, so MR !5200 leaves it a plain function of the attempt and the time (step 12).

Try it

Answer first, then check. Nothing is saved.

Question 1 of 7

A ledger retries the failed euro payments that a specification selects; its two leaves were written by two people. The retry screen, which checks each loaded payment with isSatisfiedBy, lists payments 1 and 4. main runs once against a test database; the files are below.

What does main print?

Code for this question
// spec.ts (the ledger's specification library)export class Spec<T> {  constructor(readonly test: (c: T) => boolean, readonly where: (values: unknown[]) => string) {}  isSatisfiedBy(c: T): boolean { return this.test(c); }  toSql() { const values: unknown[] = []; return { text: this.where(values), values }; }  and(o: Spec<T>): Spec<T> { return new Spec<T>((c) => this.test(c) && o.test(c), (v) => `(${this.where(v)} AND ${o.where(v)})`); }}export const param = (values: unknown[], x: unknown) => `$${values.push(x)}`; // payment.tsimport { Spec, param } from './spec';export interface Payment { id: number; currency: string; status: string }export const inCurrency = (c: string) => new Spec<Payment>((p) => p.currency === c, (v) => `currency = ${param(v, c)}`);export const withStatus = (s: string) => new Spec<Payment>((p) => p.status === s, () => 'status = $1'); // db.ts (a client for the ledger's PostgreSQL database)export interface Db { exec(sql: string): Promise<void>; query<R>(text: string, values?: unknown[]): Promise<{ rows: R[] }> } // main.tsimport type { Db } from './db';import { inCurrency, withStatus } from './payment';export async function main(db: Db) {  await db.exec(`CREATE TABLE payments (id int, currency text, status text);    INSERT INTO payments VALUES (1, 'EUR', 'failed'), (2, 'EUR', 'settled'), (3, 'GBP', 'failed'), (4, 'EUR', 'failed');`);  const { text, values } = inCurrency('EUR').and(withStatus('failed')).toSql();  try {    const rows = (await db.query<{ id: number }>(`SELECT id FROM payments WHERE ${text} ORDER BY id`, values)).rows;    console.log(`database: ${rows.map((r) => r.id).join(', ') || 'none'}`);  } catch (e) { console.log(`database: error ${(e as { code?: string }).code}`); }}
Pick one

Question 2 of 7

A ledger settles the captured payments that are not disputed. A payment's disputed flag stays null until the card network answers. main runs once against a test database; the files are below.

What does main print?

Code for this question
// spec.ts (the ledger's specification library)export class Spec<T> {  constructor(readonly test: (c: T) => boolean, readonly where: (values: unknown[]) => string) {}  isSatisfiedBy(c: T): boolean { return this.test(c); }  toSql() { const values: unknown[] = []; return { text: this.where(values), values }; }  and(o: Spec<T>): Spec<T> { return new Spec<T>((c) => this.test(c) && o.test(c), (v) => `(${this.where(v)} AND ${o.where(v)})`); }}export const not = <T>(s: Spec<T>) => new Spec<T>((c) => !s.test(c), (v) => `(NOT ${s.where(v)})`); // payment.tsimport { Spec } from './spec';export interface Payment { id: number; status: string; disputed: boolean | null }export const captured = new Spec<Payment>((p) => p.status === 'captured', () => `status = 'captured'`);export const disputed = new Spec<Payment>((p) => p.disputed === true, () => 'disputed'); // db.ts (a client for the ledger's PostgreSQL database)export interface Db { exec(sql: string): Promise<void>; query<R>(text: string, values?: unknown[]): Promise<{ rows: R[] }> } // main.tsimport type { Db } from './db';import { not } from './spec';import { captured, disputed, type Payment } from './payment';export async function main(db: Db) {  await db.exec(`CREATE TABLE payments (id int, status text, disputed boolean);    INSERT INTO payments VALUES (1, 'captured', false), (2, 'captured', null), (3, 'captured', true), (4, 'refunded', false);`);  const settleable = captured.and(not(disputed));  const all = (await db.query<Payment>('SELECT * FROM payments ORDER BY id')).rows;  const { text, values } = settleable.toSql();  const found = (await db.query<{ id: number }>(`SELECT id FROM payments WHERE ${text} ORDER BY id`, values)).rows;  const ids = (xs: { id: number }[]) => xs.map((x) => x.id).join(', ') || 'none';  console.log(`memory: ${ids(all.filter((p) => settleable.isSatisfiedBy(p)))} · database: ${ids(found)}`);}
Pick one

Question 3 of 7

A ledger's settlement job lists the payments that settleable selects. spec.ts, payment.ts, db.ts and main.ts are the same in every version; settlement-repo.ts comes in three versions. main runs once with each version, each time against a fresh test database. The files are below.

What does rows sent read in the runs with versions 1, 2 and 3, in that order?

Code for this question
// spec.ts (the ledger's specification library)export class Spec<T> {  constructor(readonly test: (c: T) => boolean, readonly where: (values: unknown[]) => string) {}  isSatisfiedBy(c: T): boolean { return this.test(c); }  toSql() { const values: unknown[] = []; return { text: this.where(values), values }; }  and(o: Spec<T>): Spec<T> { return new Spec<T>((c) => this.test(c) && o.test(c), (v) => `(${this.where(v)} AND ${o.where(v)})`); }}export const param = (values: unknown[], x: unknown) => `$${values.push(x)}`; // payment.tsimport { Spec, param } from './spec';export interface Payment { id: number; status: string; currency: string }const captured = new Spec<Payment>((p) => p.status === 'captured', () => `status = 'captured'`);const inCurrency = (c: string) => new Spec<Payment>((p) => p.currency === c, (v) => `currency = ${param(v, c)}`);export const settleable = captured.and(inCurrency('EUR')); // db.ts (a client for the ledger's PostgreSQL database; rowsSent() counts every row the database has sent back)export interface Db { exec(sql: string): Promise<void>; query<R>(text: string, values?: unknown[]): Promise<{ rows: R[] }>; rowsSent(): number } // settlement-repo.ts (version 1)import type { Db } from './db';import { settleable, type Payment } from './payment';export async function findSettleable(db: Db): Promise<Payment[]> {  const all = (await db.query<Payment>('SELECT * FROM payments ORDER BY id')).rows;  return all.filter((p) => settleable.isSatisfiedBy(p));} // settlement-repo.ts (version 2)import type { Db } from './db';import { settleable, type Payment } from './payment';export async function findSettleable(db: Db): Promise<Payment[]> {  const { text, values } = settleable.toSql();  return (await db.query<Payment>(`SELECT * FROM payments WHERE ${text} ORDER BY id`, values)).rows;} // settlement-repo.ts (version 3)import type { Db } from './db';import { settleable, type Payment } from './payment';export async function findSettleable(db: Db): Promise<Payment[]> {  const rows = (await db.query<Payment>(`SELECT * FROM payments WHERE status = 'captured' ORDER BY id`)).rows;  return rows.filter((p) => settleable.isSatisfiedBy(p));} // main.tsimport type { Db } from './db';import { findSettleable } from './settlement-repo';export async function main(db: Db) {  await db.exec(`CREATE TABLE payments (id int, status text, currency text);    INSERT INTO payments VALUES (1, 'captured', 'EUR'), (2, 'captured', 'GBP'), (3, 'captured', 'EUR'),      (4, 'refunded', 'EUR'), (5, 'captured', 'USD'), (6, 'pending', 'EUR');`);  const found = await findSettleable(db);  console.log(`settle: ${found.map((p) => p.id).join(', ')} · rows sent: ${db.rowsSent()}`);}
Pick one

Question 4 of 7

Finance's rule: a refund over 500.00 EUR needs a second approver. An MR adds the leaf below and three tests; CI runs each test once, bothWays against an empty test database. The files are below.

Which of the MR's three tests fails?

Code for this question
// spec.ts (the ledger's specification library)export class Spec<T> {  constructor(readonly test: (c: T) => boolean, readonly where: (values: unknown[]) => string) {}  isSatisfiedBy(c: T): boolean { return this.test(c); }  toSql() { const values: unknown[] = []; return { text: this.where(values), values }; }}export const param = (values: unknown[], x: unknown) => `$${values.push(x)}`; // refund.ts (the MR's leaf)import { Spec, param } from './spec';export interface Refund { id: number; amount_cents: number }export const over = (cents: number) =>  new Spec<Refund>((r) => r.amount_cents > cents, (v) => `amount_cents >= ${param(v, cents)}`);export const needsSecondApprover = over(50000); // db.ts (a client for the ledger's PostgreSQL database)export interface Db { exec(sql: string): Promise<void>; query<R>(text: string, values?: unknown[]): Promise<{ rows: R[] }> } // refund.test.ts (the MR's three tests; each returns true when it passes)import type { Db } from './db';import { needsSecondApprover as rule, type Refund } from './refund';export function inMemory(): boolean {  return rule.isSatisfiedBy({ id: 1, amount_cents: 60000 }) && !rule.isSatisfiedBy({ id: 2, amount_cents: 50000 });}export function sqlSnapshot(): boolean {  const { text, values } = rule.toSql();  return text === 'amount_cents >= $1' && values[0] === 50000;}export async function bothWays(db: Db): Promise<boolean> {  await db.exec('CREATE TABLE refunds (id int, amount_cents int); INSERT INTO refunds VALUES (1, 40000), (2, 50000), (3, 60000);');  const all = (await db.query<Refund>('SELECT * FROM refunds ORDER BY id')).rows;  const { text, values } = rule.toSql();  const fromDb = (await db.query<{ id: number }>(`SELECT id FROM refunds WHERE ${text} ORDER BY id`, values)).rows;  return all.filter((r) => rule.isSatisfiedBy(r)).map((r) => r.id).join() === fromDb.map((r) => r.id).join();}
Pick one

Question 5 of 7

ExamHall's specification module is below: kata/spec.mjs holds Spec and param, and kata/leaves.mjs three leaves. A proctor's flagged is null until a proctor has looked at the attempt, and the exam board counts null as not flagged. From a blank file that imports Spec from ./spec.mjs, export or(a, b) and not(s) so that, for any rule built from these leaves with and, or and not, the attempts isSatisfiedBy accepts and the rows toSql() selects are the same, and match the board's reading.

Write the file that exports or and not, in full.

Code for this question
// kata/spec.mjsexport class Spec {  constructor(test, where) { this.test = test; this.where = where; }  isSatisfiedBy(c) { return this.test(c); }  toSql() { const values = []; return { text: this.where(values), values }; }  and(o) { return new Spec((c) => this.test(c) && o.test(c), (v) => `(${this.where(v)} AND ${o.where(v)})`); }}export const param = (values, x) => `$${values.push(x)}`; // kata/leaves.mjsimport { Spec, param } from './spec.mjs';export const inExam = (code) => new Spec((a) => a.exam_code === code, (v) => `exam_code = ${param(v, code)}`);export const flagged = new Spec((a) => a.flagged === true, () => 'flagged');export const late = new Spec((a) => a.minutes_late > 0, () => 'minutes_late > 0');

Question 6 of 7

A chat app's moderators filter messages by ticking boxes, and the app builds one specification from the ticked boxes. The review screen, which checks loaded messages with isSatisfiedBy, shows all 5 messages when no box is ticked. A moderator opens the filter and ticks no box. main runs once against a test database; the files are below.

What does main print?

Code for this question
// spec.ts (the app's specification library)export class Spec<T> {  constructor(readonly test: (c: T) => boolean, readonly where: (values: unknown[]) => string) {}  isSatisfiedBy(c: T): boolean { return this.test(c); }  toSql() { const values: unknown[] = []; return { text: this.where(values), values }; }}export const allOf = <T>(specs: Spec<T>[]) =>  new Spec<T>((c) => specs.every((s) => s.test(c)), (v) => `(${specs.map((s) => s.where(v)).join(' AND ')})`); // message.tsimport { Spec } from './spec';export interface Message { id: number; room: string; reported: boolean; has_link: boolean }export const BOXES: Record<string, Spec<Message>> = {  reported: new Spec<Message>((m) => m.reported, () => 'reported'),  links: new Spec<Message>((m) => m.has_link, () => 'has_link'),  lobby: new Spec<Message>((m) => m.room === 'lobby', () => `room = 'lobby'`),}; // db.ts (a client for the app's PostgreSQL database)export interface Db { exec(sql: string): Promise<void>; query<R>(text: string, values?: unknown[]): Promise<{ rows: R[] }> } // main.tsimport type { Db } from './db';import { allOf } from './spec';import { BOXES } from './message';export async function main(db: Db) {  await db.exec(`CREATE TABLE messages (id int, room text, reported boolean, has_link boolean);    INSERT INTO messages VALUES (1, 'lobby', true, false), (2, 'lobby', false, true), (3, 'help', false, false),      (4, 'help', true, true), (5, 'lobby', false, false);`);  const ticked: string[] = [];  const { text, values } = allOf(ticked.map((box) => BOXES[box])).toSql();  try {    const rows = (await db.query<{ id: number }>(`SELECT id FROM messages WHERE ${text} ORDER BY id`, values)).rows;    console.log(`database: ${rows.length} messages`);  } catch (e) { console.log(`database: error ${(e as { code?: string }).code}`); }}
Pick one

Question 7 of 7

A streaming service imported its old catalogue, which keeps each title's running time as text, in minutes. main runs once against a test database; the files are below.

What does main print?

Code for this question
// spec.ts (the service's specification library)export class Spec<T> {  constructor(readonly test: (c: T) => boolean, readonly where: (values: unknown[]) => string) {}  isSatisfiedBy(c: T): boolean { return this.test(c); }  toSql() { const values: unknown[] = []; return { text: this.where(values), values }; }  and(o: Spec<T>): Spec<T> { return new Spec<T>((c) => this.test(c) && o.test(c), (v) => `(${this.where(v)} AND ${o.where(v)})`); }}export const param = (values: unknown[], x: unknown) => `$${values.push(x)}`; // title.tsimport { Spec, param } from './spec';export interface Title { id: number; kind: string; runtime: string }export const isFilm = new Spec<Title>((t) => t.kind === 'film', () => `kind = 'film'`);export const longerThan = (min: number) =>  new Spec<Title>((t) => Number(t.runtime) > min, (v) => `runtime::int > ${param(v, min)}`); // db.ts (a client for the service's PostgreSQL database)export interface Db { exec(sql: string): Promise<void>; query<R>(text: string, values?: unknown[]): Promise<{ rows: R[] }> } // main.tsimport type { Db } from './db';import { isFilm, longerThan, type Title } from './title';export async function main(db: Db) {  await db.exec(`CREATE TABLE titles (id int, kind text, runtime text);    INSERT INTO titles VALUES (1, 'film', '142'), (2, 'film', '88'), (3, 'film', 'tbc'), (4, 'series', '45'), (5, 'film', '155');`);  const longFilms = isFilm.and(longerThan(90));  const ids = (xs: { id: number }[]) => xs.map((x) => x.id).join(', ') || 'none';  let memory: string, database: string;  try { memory = ids((await db.query<Title>('SELECT * FROM titles ORDER BY id')).rows.filter((t) => longFilms.isSatisfiedBy(t))); }  catch { memory = 'error'; }  const { text, values } = longFilms.toSql();  try { database = ids((await db.query<{ id: number }>(`SELECT id FROM titles WHERE ${text} ORDER BY id`, values)).rows); }  catch (e) { database = `error ${(e as { code?: string }).code}`; }  console.log(`memory: ${memory} · database: ${database}`);}
Pick one

Go deeper

Pro tips
  1. Index the columns your leaves compare. A specification's query is only as fast as the database can find its rows; Evans and Fowler: "You will need to see how to index the candidates in some way to speed up the matching process." When: a rule's query runs over a table that grows (step 8).
  2. When a screen and a job disagree, print the query's text and values. Log toSql()'s text and values beside the ids isSatisfiedBy picks; most disagreements show in those two lines. When: a list built from a specification looks wrong (steps 4–7).
Source
  • Eric Evans and Martin Fowler, Specifications (the row's first source): https://martinfowler.com/apsupp/spec.pdf · fetched 2026-10-17 with curl. Teaching only; every key here is executed. "The central idea of Specification is to separate the statement of how to match a candidate, from the candidate object that it is matched against." (step 1); "The key, though is to return a new Specification." (step 3); "If you have to select from a lot of candidates on a database, then performance may well become an issue." (step 8); "This is essentially an application of Strategy" and "A Hard Coded Specification implements the specification as a code fragment, essentially treating the specification as a Strategy" (step 11); "The specification interpreter is also a lot more effort to build." (step 12); "You will need to see how to index the candidates in some way to speed up the matching process." (tip 1). Valid as of 2026-10-17.
  • The row's second source, Eric Evans, Domain-Driven Design (2003), is a book and keys nothing.

Checked against the tools and docs as of 2026-10-17.