Engineering Atlas › Low-Level Design

Prototype pattern

13 min read · last reviewed 2026-10-17

Open in the interactive app: practice, quiz, mock interview →

In one minute

How it works

The problem

A CI system lets teams define job templates: a base image, environment variables, steps, resource limits and caches. Building a "Python service" template means parsing 40 lines of YAML, validating them and resolving image digests from a registry, about 30 ms. Every pull request creates 6 jobs from it, each changing a field or two. Calling the constructor per job repeats that work, and the code creating jobs has to know every field the template sets. What the job runner wants is simpler: "take the validated template and give me a private copy I can tweak."

The shape

The template object knows how to copy itself, and a registry holds the named templates:

registry.register("py-service", load_and_validate("py-service.yaml"))

job = registry.create("py-service")   # a private deep copy
job.env["PYTHON"] = "3.13"
job.steps.append(Step("pytest -k shard_3"))

create returns whatever concrete type the template is, the same decoupling a factory gives (see the Factory Method lesson), except that the recipe is data held in an existing instance rather than code in a function. New kinds of object can be registered at runtime, for example from a config file, without writing a class.

Shallow, deep and structural sharing

There are three ways to copy, and choosing one is the real design decision.

A shallow copy duplicates the outer object and copies its fields by reference: Python's copy.copy and dict.copy(), JavaScript's {...obj}, struct assignment in Go. Immutable fields are safe to share. Mutable ones are not: job.env in the copy is the template's dict, so the first job's PYTHON=3.13 leaks into every later job.

A deep copy walks the graph and copies every reachable mutable object. Done naively it has two bugs: it turns one shared object into two (two steps that shared one cache config now have separate ones), and it recurses forever on a cycle (a step pointing back at its job). Python's copy.deepcopy fixes both with a memo keyed by id(obj), checked before copying and filled before recursing into children. The Practice builds exactly this.

Structural sharing avoids copying by making the template immutable. A "copy with one change" builds a new top-level object pointing at the unchanged parts of the old one. Python's dataclasses.replace and copy.replace (3.13) do this for one level; persistent collections (Clojure's vectors, Immutable.js) do it for whole trees, copying only the path to the change.

Language support, and where it breaks

When not to use it

Reviewing agent-written code

Asked to "create jobs from a template", an agent will often write const job = { ...template } and then job.steps.push(...). Tests that build one job pass; production breaks when the second job inherits the first job's step. Ask for a test that creates two objects from one template, mutates nested fields of the first, and asserts the template and the second are unchanged. A hand-written recursive clone without a memo is a stack overflow waiting for the first cyclic template.

The same shape elsewhere

Prototype is the duplicates problem shape seen from the other side: you want many copies, and the bugs are the parts that should not have been duplicated or shared. Infrastructure stamps out copies of a validated example constantly. A Kubernetes Deployment holds a pod template that the ReplicaSet controller copies into every pod; VMs boot from golden images; fork() copies a process; AWS Lambda SnapStart restores new environments from a snapshot of an initialised one. All share the shallow-copy failure mode: something that should be unique per copy (a random seed, a host key, a connection) was baked into the template and is now shared by every instance.

Down the stack

Layer 1The contract

clone() returns a new object of the same concrete type, equal to the original at the moment of copying. Afterwards, mutating any part the contract calls "owned" must not affect the original, and vice versa. The contract also names deliberately shared fields (immutable values, handles such as a logger) and how sharing and cycles are treated: a graph-preserving clone maps two references to one child onto one copied child, and a cycle onto a cycle. A registry promises to store a snapshot taken at registration, so later edits to the object passed in do not change future clones.

Layer 2The mechanism

CPython's copy.deepcopy looks up a copier in _deepcopy_dispatch by exact type (int and str return themselves; list, dict and tuple have dedicated functions), then tries __deepcopy__, then __reduce_ex__(4). It stores each new object in memo[id(original)] before copying children, and keeps originals alive in the memo so their id values cannot be reused mid-copy. structuredClone uses the same serialisation as postMessage: V8's ValueSerializer writes the graph to a buffer, turning repeat visits into back-references, and ValueDeserializer rebuilds it. Go's struct assignment is a memmove of the struct's bytes; a slice header (pointer, length, capacity: 24 bytes) is copied, its backing array is not. JavaScript's prototype chain copies nothing: Object.create(proto) sets the new object's [[Prototype]] slot, and property reads that miss walk up the chain.

Layer 3The machine

A shallow copy is one allocation plus a field copy: tens of nanoseconds. A deep copy allocates once per reachable mutable node, so a template with 2,000 nested dicts and lists costs 2,000 allocations and, in CPython, several milliseconds of per-node dispatch and memo inserts. That can exceed building from scratch, so measure before assuming cloning is cheap. The clone's nodes also land wherever the allocator has space, losing the original's locality.

The kernel uses the cheapest prototype of all. fork() copies page tables, not memory, and marks pages read-only in both processes; the first write to a page faults and copies that one 4 KiB page. Redis BGSAVE relies on this: the child snapshots the dataset while the parent keeps writing, and extra memory equals the pages touched during the save, which on a write-heavy instance approaches the whole dataset. Copy-on-write is structural sharing implemented by the MMU.

Why it's built this way

  1. Some objects are expensive to set up or carry many decisions (validation, lookups, defaults), and you need many variations of them.
  2. Rebuilding each one repeats that cost and spreads knowledge of every field into the code that creates them, so copy a finished example instead.
  3. Copying by reference is cheap but makes the copies share mutable state, so owned nested data must be copied deeply.
  4. Deep copies that duplicate shared nodes or loop on cycles corrupt the graph, so the copier keeps a memo from original to copy.
  5. Deep copies cost allocations proportional to the graph, so where the template can be immutable, share it and copy only what changes.

Diagram

flowchart TD
  R["registry.create(key)"] --> L{"key registered?"}
  L -- "no" --> E["error: unknown prototype"]
  L -- "yes" --> C["clone(node, memo)"]
  C --> M{"id(node) in memo?"}
  M -- "yes" --> S["return memo copy (shared or cycle)"]
  M -- "no" --> N["new node, copy scalar fields"]
  N --> W["memo[id] = new node"]
  W --> K["clone each child with same memo"]
  K --> O["apply overrides to root"]
  O --> U["caller gets private copy"]

Worked example

A game spawns enemies from data files holding stats, an equipment list, a 120-node behaviour tree and a reference to a shared texture atlas. Building one from its file takes 1.8 ms, so a wave of 300 costs 540 ms, far beyond a 16.7 ms frame budget at 60 fps.

The team loads each type once and spawns with clone(prototype). The first version used a generic deep copy, which also copied the 4 MB atlas metadata per enemy: a 1.2 GB spike. The fix was an explicit clone contract: stats and equipment are copied, the behaviour tree is shared because it is immutable after load (per-enemy state lives in a small "blackboard"), and asset references are shared handles. A spawn now copies about 2 KB in 6 microseconds; a wave takes under 2 ms.

The bug that slipped through came from the other direction: the hand-written clone copied the equipment list but not the dicts inside it, so one goblin's power-up upgraded the sword of every goblin spawned after it. A test that clones twice and mutates nested data on one now guards every prototype type.

Trade-offs

OptionStrengthsWeaknessesChoose when
Constructor or factory per objectExplicit; no copy semantics to get wrongRepeats expensive setup; caller knows fieldsSetup is cheap and variants are few
Shallow copyFast; one allocationNested mutable data is shared with the templateAll fields are immutable or meant to be shared
Generic deep copy (deepcopy, structuredClone)Correct for plain data, cycles and sharingSlow on big graphs; copies handles you meant to share; drops class identity in JSPlain data templates, tests, tooling
Hand-written clone()Precise contract per field; fastestMust be updated when fields are addedHot paths and objects mixing values with shared handles
Immutable template plus structural sharingNo copy cost; safe to share across threadsRequires an immutable data modelConfiguration, documents, state in UI frameworks

Pitfalls in production

Leadership lens

Check yourself

  1. A template holds a list of steps. Why does {...template} cause a bug only when two objects are created from it?

    Show answer

    The spread copies the reference to the list, so both copies and the template share one list. With one copy the shared mutation is invisible; the second copy reveals the first copy's changes.

  2. Why does a correct deep copy need a memo of already-copied objects?

    Show answer

    Without it, a node reached twice is copied twice, so shared references become separate objects, and a cycle recurses forever. The memo maps each original to its single copy.

  3. When is cloning slower than constructing from scratch?

    Show answer

    When the object graph is large and the constructor is cheap: a deep copy allocates and dispatches per node, while a constructor might share immutable parts or build compact data directly.

  4. How is JavaScript's prototype chain different from the prototype pattern?

    Show answer

    The chain is delegation: a new object points at its prototype and reads that miss fall through to it, so changes to the prototype are visible later. The pattern copies, so later changes to the template do not affect existing copies.

Watch

Free resources

Open in the interactive app: practice, quiz, mock interview →