Prototype
Prototype makes new objects by copying a configured one and changing only what differs. This page builds Pedal's fare plans from a registered standard plan, and shows the bug every prototype risks: a shallow copy that edits the original through a shared list.
1. The pain
Pedal's standard fare plan has fourteen settings: unlock fee, price per minute, free minutes, two peak windows with their own prices, daily and monthly caps, and more. Every month the operations team adds a plan (student, weekend, corporate) that differs from the standard one in two or three of them.
Typing all fourteen again each time is slow, and every retyped setting is a chance for a mistake. What the team really means is "the standard plan, but…". The code should say that too.
2. The idea in one picture
Prototype creates new objects by copying an existing, fully configured one, then changing what differs. The object knows how to copy itself (a clone() method), so the caller doesn't need to know its class or its fields. A registry often keeps the ready-made prototypes by name.
The catch is the depth of the copy. A shallow copy makes a new outer object but shares the lists and maps inside it, so changing the copy's caps changes the original's too. A deep copy copies those as well. Section 6 lets you see the difference.
3. The roles
| Role | Its job | In Pedal |
|---|---|---|
| Prototype | Declares the copy operation. | FarePlan.clone(name) |
| Concrete prototype | A configured object that copies itself, deeply enough to be independent. | The standard plan |
| Registry (optional) | Holds prototypes by name and hands out copies, never the original. | PlanRegistry |
| Client | Asks for a copy and adjusts it. | The ops tool that creates the student plan |
4. Before and after
Creating the student plan both ways (Python sketch; the full program is in section 5).
Without the pattern
student = FarePlan(
"student", unlock=0, per_minute=10,
free_minutes=0, peak_am=120,
peak_pm=None, # forgotten
daily_cap=600, monthly_cap=4000,
# ... seven more settings,
# all copied by eye
)With it
student = registry.clone("standard",
"student")
student.unlock = 0
student.peak["07-09"] = 120
student.caps[0] = ("daily", 600)| The next change | Without the pattern | With it |
|---|---|---|
| A weekend plan | Another fourteen settings typed by hand | Clone the standard plan, change two |
| The standard plan gains a setting | Every plan's creation code must add it | Clones pick it up automatically |
| Plans created by users in an admin screen | The screen must know every field | "Duplicate" calls clone() |
Copying keeps the shared settings in one place: the prototype. The copy states only what's different.
5. The code
A registry holds the standard plan; the student plan is a deep copy with three changes, and the standard plan is unchanged. Then a shallow copy adds a cap, and the standard plan gets it too.
from __future__ import annotations
import copy
class FarePlan:
"""A configured object that is easier to copy and tweak than to build from scratch."""
def __init__(self, name: str, unlock: int, per_minute: int, peak: dict[str, int], caps: list[tuple[str, int]]) -> None:
self.name = name
self.unlock = unlock
self.per_minute = per_minute
self.peak = peak # hours -> price as a percentage of normal
self.caps = caps # (period, most a rider pays in that period)
def clone(self, name: str) -> FarePlan:
twin = copy.deepcopy(self) # new dict and list: the copy shares nothing that can change
twin.name = name
return twin
def shallow_copy(self, name: str) -> FarePlan:
twin = copy.copy(self) # new object, but the same dict and list inside it
twin.name = name
return twin
def describe(self) -> str:
peak = ", ".join(f"{h} {p}%" for h, p in self.peak.items())
caps = ", ".join(f"{n} {p}p" for n, p in self.caps)
return f"{self.name}: unlock {self.unlock}p, {self.per_minute}p/min, peak {peak}, caps {caps}"
class PlanRegistry:
"""Keeps ready-made prototypes by name; new plans start as copies of them."""
def __init__(self) -> None:
self.prototypes: dict[str, FarePlan] = {}
def add(self, plan: FarePlan) -> None:
self.prototypes[plan.name] = plan
def clone(self, key: str, new_name: str) -> FarePlan:
if key not in self.prototypes:
raise KeyError(f"no prototype called '{key}'")
return self.prototypes[key].clone(new_name)
if __name__ == "__main__":
registry = PlanRegistry()
standard = FarePlan("standard", 100, 10, {"07-09": 150, "17-19": 150}, [("daily", 1200)])
registry.add(standard)
print(standard.describe())
student = registry.clone("standard", "student")
student.unlock = 0
student.peak["07-09"] = 120
student.caps[0] = ("daily", 600)
print(student.describe())
print(f"after the deep copy -> {standard.describe()}")
weekend = standard.shallow_copy("weekend")
weekend.caps.append(("weekend", 800))
print(f"after the shallow copy -> {standard.describe()}")
try:
registry.clone("night", "late")
except KeyError as e:
print(f"rejected: {e.args[0]}")
type Cap = readonly [string, number];
// A configured object that is easier to copy and tweak than to build from scratch.
class FarePlan {
constructor(
public name: string,
public unlock: number,
public perMinute: number,
public peak: Map<string, number>, // hours -> price as a percentage of normal
public caps: Cap[], // (period, most a rider pays in that period)
) {}
clone(name: string): FarePlan {
// new map and array: the copy shares nothing that can change
return new FarePlan(name, this.unlock, this.perMinute, new Map(this.peak), [...this.caps]);
}
shallowCopy(name: string): FarePlan {
// new object, but the same map and array inside it
return new FarePlan(name, this.unlock, this.perMinute, this.peak, this.caps);
}
describe(): string {
const peak = [...this.peak].map(([h, p]) => `${h} ${p}%`).join(", ");
const caps = this.caps.map(([n, p]) => `${n} ${p}p`).join(", ");
return `${this.name}: unlock ${this.unlock}p, ${this.perMinute}p/min, peak ${peak}, caps ${caps}`;
}
}
// Keeps ready-made prototypes by name; new plans start as copies of them.
class PlanRegistry {
private readonly prototypes = new Map<string, FarePlan>();
add(plan: FarePlan): void { this.prototypes.set(plan.name, plan); }
clone(key: string, newName: string): FarePlan {
const proto = this.prototypes.get(key);
if (!proto) throw new Error(`no prototype called '${key}'`);
return proto.clone(newName);
}
}
const registry = new PlanRegistry();
const standard = new FarePlan("standard", 100, 10, new Map([["07-09", 150], ["17-19", 150]]), [["daily", 1200]]);
registry.add(standard);
console.log(standard.describe());
const student = registry.clone("standard", "student");
student.unlock = 0;
student.peak.set("07-09", 120);
student.caps[0] = ["daily", 600];
console.log(student.describe());
console.log(`after the deep copy -> ${standard.describe()}`);
const weekend = standard.shallowCopy("weekend");
weekend.caps.push(["weekend", 800]);
console.log(`after the shallow copy -> ${standard.describe()}`);
try {
registry.clone("night", "late");
} catch (e) {
console.log(`rejected: ${(e as Error).message}`);
}import java.util.ArrayList;
import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
public class Main {
record Cap(String period, int pence) {}
// A configured object that is easier to copy and tweak than to build from scratch.
static class FarePlan {
String name;
int unlock, perMinute;
final Map<String, Integer> peak; // hours -> price as a percentage of normal
final List<Cap> caps; // (period, most a rider pays in that period)
FarePlan(String name, int unlock, int perMinute, Map<String, Integer> peak, List<Cap> caps) {
this.name = name; this.unlock = unlock; this.perMinute = perMinute; this.peak = peak; this.caps = caps;
}
// A copy method rather than Object.clone(): explicit about what is copied.
FarePlan copy(String newName) {
// new map and list: the copy shares nothing that can change
return new FarePlan(newName, unlock, perMinute, new LinkedHashMap<>(peak), new ArrayList<>(caps));
}
FarePlan shallowCopy(String newName) {
// new object, but the same map and list inside it
return new FarePlan(newName, unlock, perMinute, peak, caps);
}
String describe() {
String p = peak.entrySet().stream().map(e -> e.getKey() + " " + e.getValue() + "%").collect(Collectors.joining(", "));
String c = caps.stream().map(x -> x.period() + " " + x.pence() + "p").collect(Collectors.joining(", "));
return name + ": unlock " + unlock + "p, " + perMinute + "p/min, peak " + p + ", caps " + c;
}
}
// Keeps ready-made prototypes by name; new plans start as copies of them.
static class PlanRegistry {
private final Map<String, FarePlan> prototypes = new HashMap<>();
void add(FarePlan plan) { prototypes.put(plan.name, plan); }
FarePlan copy(String key, String newName) {
FarePlan proto = prototypes.get(key);
if (proto == null) throw new IllegalArgumentException("no prototype called '" + key + "'");
return proto.copy(newName);
}
}
public static void main(String[] args) {
PlanRegistry registry = new PlanRegistry();
Map<String, Integer> peak = new LinkedHashMap<>();
peak.put("07-09", 150);
peak.put("17-19", 150);
FarePlan standard = new FarePlan("standard", 100, 10, peak, new ArrayList<>(List.of(new Cap("daily", 1200))));
registry.add(standard);
System.out.println(standard.describe());
FarePlan student = registry.copy("standard", "student");
student.unlock = 0;
student.peak.put("07-09", 120);
student.caps.set(0, new Cap("daily", 600));
System.out.println(student.describe());
System.out.println("after the deep copy -> " + standard.describe());
FarePlan weekend = standard.shallowCopy("weekend");
weekend.caps.add(new Cap("weekend", 800));
System.out.println("after the shallow copy -> " + standard.describe());
try {
registry.copy("night", "late");
} catch (IllegalArgumentException e) {
System.out.println("rejected: " + e.getMessage());
}
}
}Output (the same in all three languages)
standard: unlock 100p, 10p/min, peak 07-09 150%, 17-19 150%, caps daily 1200p student: unlock 0p, 10p/min, peak 07-09 120%, 17-19 150%, caps daily 600p after the deep copy -> standard: unlock 100p, 10p/min, peak 07-09 150%, 17-19 150%, caps daily 1200p after the shallow copy -> standard: unlock 100p, 10p/min, peak 07-09 150%, 17-19 150%, caps daily 1200p, weekend 800p rejected: no prototype called 'night'
6. Try it
Shallow or deep?
Make a copy of the standard plan, then change the copy. Watch what happens to the original.
Dotted underline: a map or list the two plans share. Red: a value in the original that changed.
7. When not to use it
What you get
- Less to type, less to get wrong: copies state only what differs.
- No class knowledge needed: the object copies itself.
- Cheap creation: copying beats rebuilding when setup is slow (loading, parsing, computing).
- Runtime variety: users can duplicate and edit plans without new classes.
What you pay
- Copy depth is a trap: shared inner lists and maps leak changes back.
- Identity must be reset: ids, database keys and timestamps shouldn't be copied blindly.
- Cycles and resources: objects holding connections, files or back-references are hard to copy.
- Java's
clone()is awkward: prefer a copy constructor or copy method.
| If… | Use instead |
|---|---|
| New objects need many optional parts set from scratch | 03 Builder |
| The variations are fixed and known in advance | Named constructors or factory functions (FarePlan.student()) |
| The object is immutable | Copy-with-changes (dataclasses.replace, records with with-style methods) |
| Only one object should exist | 05 Singleton |
8. How it goes wrong
clone().Object.clone() is shallow by default and bypasses constructors. A copy method or copy constructor is clearer.9. Remember it and say it
How this hook works: what “Copy the signed contract” means
What the slogan means, phrase by phrase
- “Copy”
clone(): the object copies itself.- “the signed contract”
- A fully configured prototype.
- “shares nothing”
- A deep enough copy.
Why this picture fits: Nobody drafts a contract from a blank page when a good one already exists.
The story → the concept
| The office | The code |
|---|---|
| Last year's contract | The standard plan |
| The filing cabinet | PlanRegistry |
| A photocopy | A deep copy |
| Two people editing the same sheet | A shallow copy's shared list |
Watch it on an example
- Ops clones the standard plan as "student".
- They change the unlock fee, the morning peak and the daily cap.
- The standard plan is unchanged, because the copy's maps and lists are its own.
The trap it saves you from
A shallow copy that quietly edits the original through a shared list.
It still works when…
Yes: "Duplicate" buttons, game entity templates and test fixtures all work this way.
In an interview or code review
- Say what's expensive to build: "Plans have fourteen settings; new plans are small variations, so we clone a registered prototype."
- Raise the depth question yourself: "
clone()copies the peak map and caps list, so a copy can't change the original." - Mention identity: a clone gets a new id; audit fields are reset.
- In Java, say you'd use a copy constructor or copy method rather than
Cloneable; in Python,copy.deepcopyordataclasses.replace.
10. Check yourself
What does prototype create new objects from?
After a shallow copy, the copy appends a cap. What happens to the original?
Why should a registry hand out copies rather than the prototype?
What should clone() usually reset?
Your fare plan objects are immutable. What replaces clone()?
11. Go deeper
| Resource | Why |
|---|---|
| 03 Builder | The other way to make complex objects: assemble instead of copy. |
Python copy module | Shallow and deep copies, and how classes customise them. |
MDN: structuredClone() | Deep copying in JavaScript and TypeScript. |
| Wikipedia: Prototype pattern | Structure and language notes. |