Factory, Builder, Singleton and Command
Four patterns that show up constantly, and the honest answer to when each one is overkill.
- 1 placeWhere the choice of class to create lives
- 2 stacksHistory keeps done and undone, for undo and redo
- 10 millionCharacters a snapshot undo stores for 1,000 edits
Show what to say
“I decide which class to create in one place, a factory or registry, so a new kind is one register call. I skip the Builder when an options object will do, and I replace a Singleton with one instance created at startup and passed in. For undo and redo I model each action as a command with do and undo, on two stacks.”
Trade-offs
- Registry vs one switch: a registry adds kinds without editing old code; for few fixed kinds, one switch in one place is enough.
- Singleton vs injected instance: both give one in production; injection makes the dependency visible and testable.
- Snapshot undo vs commands: snapshots are simplest but cost document size times edits; commands store less but each needs a correct undo.
Bring it up before they ask
Clear the redo stack when a new edit follows an undo, and make each command remember exactly what it changed, so undo restores exactly that.
Decide which class to create in one place. Skip the Builder when an options object will do, and pass one instance in instead of using a Singleton. Model undo as commands with do and undo.
Create it by name
Think of a vending machine: press B4 and the snack drops. That is a factory.
Read more · 93 words
A string like "sms" arrives from config. How many places know how to turn it into an object?
Add push and you must find every switch on the channel; miss one and push users fail in production. Put the choice in one place.
Callers say channels.create(kind). A new kind is one register call and no edit to old code.
| Tool | Use it when | Example |
|---|---|---|
| Constructor | You know the type | new Sms() |
| Static factory | Creation needs a name or check | Duration.ofMinutes(5) |
| Registry | New kinds arrive as data | channels.create(kind) |
// The same switch, pasted wherever the channel matters.
export function send(channel, to, text) {
switch (channel) {
case 'email': return `email to ${to}: ${text}`;
case 'sms': return `sms to ${to}: ${text.slice(0, 160)}`;
default: throw new Error(`unknown channel: ${channel}`);
}
}
export function costInCents(channel) {
switch (channel) {
case 'email': return 0;
case 'sms': return 4;
default: throw new Error(`unknown channel: ${channel}`);
}
}
// Adding 'push' means finding every switch like these, in every file, and editing each one.class Email {
send(to, text) { return `email to ${to}: ${text}`; }
get costInCents() { return 0; }
}
class Sms {
send(to, text) { return `sms to ${to}: ${text.slice(0, 160)}`; }
get costInCents() { return 4; }
}
// The one place that knows which kinds exist. A new kind is one register() call.
export class ChannelRegistry {
#makers = new Map();
register(kind, make) {
this.#makers.set(kind, make);
return this;
}
create(kind) {
const make = this.#makers.get(kind);
if (!make) throw new Error(`unknown channel: ${kind}`);
return make();
}
}
export const defaultChannels = () =>
new ChannelRegistry().register('email', () => new Email()).register('sms', () => new Sms());Builder, and when it is overkill
A constructor with seven parameters, four optional, gives new Email(a, b, null, null, true). Nobody can read that.
Read more · 93 words
A Builder collects the parts one at a time and creates the object at the end, checking the whole combination once.
Reach for it when:
- Many parts are optional.
- A rule spans several fields and must hold before any object exists.
- The result is immutable, but the parts arrive over time.
In JavaScript and Python you need one less often. An options object does the job in one line:
Add a validity check to the constructor and you have most of a builder. Do not write one for a class with three fields.
export class EmailBuilder {
#to = [];
#cc = [];
#subject = '';
#body = '';
to(address) { this.#to.push(address); return this; }
cc(address) { this.#cc.push(address); return this; }
subject(text) { this.#subject = text; return this; }
body(text) { this.#body = text; return this; }
// Every rule about a valid combination lives here. The result is frozen, and
// it holds copies, so changing the builder later cannot change an email already built.
build() {
if (this.#to.length === 0) throw new Error('an email needs a recipient');
if (this.#subject === '') throw new Error('an email needs a subject');
return Object.freeze({
to: Object.freeze([...this.#to]),
cc: Object.freeze([...this.#cc]),
subject: this.#subject,
body: this.#body,
});
}
}new Email({ to: ['ann@x.com'], subject: 'Lunch', cc: ['bob@x.com'] })Email(to=("ann@x.com",), subject="Lunch", cc=("bob@x.com",))Singleton: one instance, no global
One town clock everyone checks sounds neat until a test needs its own.
Read more · 60 words
That is a Singleton: TicketNumbers.get().next() is a global with a nicer name.
- Hidden dependency.
issueTicket(vehicle)does not say it uses shared state. - Tests leak. A test passes alone and fails in a suite.
- You cannot have two.
Instead, create it once near main and hand it in (dependency injection). Production has one; a test builds its own.
// One shared instance, reachable from anywhere.
export class TicketNumbers {
static #instance;
#last = 0;
static get() {
return (TicketNumbers.#instance ??= new TicketNumbers());
}
next() { return ++this.#last; }
}
// A hidden dependency: nothing in the signature says this function touches shared state.
export const issueTicket = (vehicle) => ({ vehicle, number: TicketNumbers.get().next() });Command: an action as an object
Saving a full copy before every edit is simple, but 1,000 edits of a 10,000-character text store 10 million characters.
Read more · 58 words
The Command pattern stores the action instead, and each command can do and undo itself.
Eraseremembers what it removed, so undo restores exactly that.Historyhas two stacks.undomoves a command fromdonetoundone;redomoves it back.- A new edit clears the redo stack.
A new Paste is a new class; History is untouched.
export class Doc {
text = '';
}
// A command knows how to do and undo itself. It keeps what it needs to reverse the change.
export class Type {
constructor(doc, text) { this.doc = doc; this.text = text; }
do() { this.doc.text += this.text; }
undo() { this.doc.text = this.doc.text.slice(0, this.doc.text.length - this.text.length); }
}
export class Erase {
constructor(doc, count) { this.doc = doc; this.count = count; }
do() {
const keep = Math.max(0, this.doc.text.length - this.count);
this.removed = this.doc.text.slice(keep); // may be fewer than count characters
this.doc.text = this.doc.text.slice(0, keep);
}
undo() { this.doc.text += this.removed; }
}
// Two stacks. History never looks inside a command, so a new kind of edit needs no change here.
export class History {
#done = [];
#undone = [];
run(cmd) {
cmd.do();
this.#done.push(cmd);
this.#undone.length = 0; // a new edit ends the old future
}
undo() {
const cmd = this.#done.pop();
if (!cmd) return false;
cmd.undo();
this.#undone.push(cmd);
return true;
}
redo() {
const cmd = this.#undone.pop();
if (!cmd) return false;
cmd.do();
this.#done.push(cmd);
return true;
}
}Watch the design grow
Step through: each stage adds classes without changing the old ones.
An Editor owns a Doc and a History. Type and Erase are commands that implement Command, so History can run, undo and redo any of them. Macro is also a Command and wraps many, so a group of edits undoes in one step.
- ◆ composition (owns)
- ◇ aggregation
- △ inheritance / implements (dashed)
- → association · ⇢ dependency
One undo step for many edits
"Replace all" changes forty places, but one Ctrl+Z should undo it. A group of commands is itself a command.
Read more · 26 words
Macro undoes its steps in reverse, and History cannot tell it from a single edit. Add a third command and watch one undo remove all three.
// Several commands that undo as one. It is a command too, so History needs no change.
export class Macro {
constructor(commands) { this.commands = commands; }
do() {
for (const cmd of this.commands) cmd.do();
}
// Reverse order matters: the last change sits on top of the earlier ones.
undo() {
for (const cmd of [...this.commands].reverse()) cmd.undo();
}
}// Change the edits, or add a second Macro, and watch what one undo does.
const doc = new Doc();
const history = new History();
history.run(new Type(doc, 'hello world'));
history.run(new Macro([new Erase(doc, 5), new Type(doc, 'there')]));
console.log(doc.text); // hello there
history.undo();
console.log(doc.text); // hello world (the whole macro, in one step)
history.undo();
console.log(JSON.stringify(doc.text)); // ""
history.redo();
console.log(doc.text); // hello world
history.run(new Type(doc, '!'));
console.log(history.redo()); // false: a new edit clears the redo stackWhich one, when
Each pattern answers one question. Not asking it? Skip the pattern.
Read more · 76 words
| Pattern | Reach for it when | Skip it when |
|---|---|---|
| Factory or registry | The class depends on data | The type is known at the call |
| Builder | Many optional parts | An options object will do |
| Singleton | Never as a global | Always, for state |
| Command | Undo, queues, logs | Nothing reverses or records it |
The over-engineering trap
Read more · 37 words
Practise with the text editor exercise. Last lesson: the LLD playbook.
Follow-up questions
Is a Singleton ever the right answer?
One instance is often right. A global way to reach it rarely is. Create it at startup and pass it in.
What about an action that cannot be undone, such as sending an email?
Keep it off the undo stack; for a reversal, add a compensating action, like a correction email.
Check yourself
Question 1 of 3Score 2 out of 3 or better to mark this page complete.
Code it yourself
Write the class in the browser editor, in JavaScript or Python. Each exercise runs against hidden tests, and the step-by-step solution unlocks after a wrong submission.