Skip to content

Prototype

Intent

Create an object by copying an existing, already-configured one instead of running a constructor that would rebuild the same state. The client asks an instance for a copy, so it needs to know neither the concrete class nor how the original was assembled.

When to use and when not to

Use it when

  • Reaching the configured state is the expensive part — a file, a query, a twenty-call builder. Copying is a memory operation; rebuilding is not: ~500 µs for a datacenter round trip against ~3 µs to read 1 MB from memory, ~170x.
  • You explore alternatives from one starting point and discard most of them: game-tree search, what-if pricing, a speculative transaction.
  • The variants are data, so a registry of configured instances replaces a subclass or a factory branch per variant.

Leave it out when

  • The constructor is cheap and complete; Board(squares, side) beats prototype.clone() when nothing was configured.
  • The state has identity that must not be duplicated — an open connection, a lock, a primary key — so two clones each believe they own one resource.
  • You want the same object moved back in time rather than a second object: that is Memento.
  • The change is one field of a value object, which dataclasses.replace spells better, or the fork is cheaper to undo than to copy — a search that can unmake a move should not clone a position.

Structure

Four roles: the Prototype interface, a Concrete Prototype that copies itself, a registry of configured instances, and a client that clones instead of constructing.

classDiagram
    class Prototype {
        <<interface>>
        +clone() Self
    }
    class Board {
        +squares: Dict~Square,Piece~
        +side_to_move: Side
        +move_log: List~str~
        +clone() Board
        +apply(move: Move) Board
        +push(move: Move) Piece
        +pop(move: Move, captured: Piece) None
    }
    class Piece {
        +kind: PieceKind
        +side: Side
        +symbol: str
    }
    class PrototypeRegistry {
        +register(name: str, prototype: Prototype) None
        +create(name: str) Prototype
        +names: Tuple~str~
    }
    class MoveSearch {
        +clones: int
        +count_by_cloning(board: Board, depth: int) int
        +count_in_place(board: Board, depth: int) int
    }
    Prototype <|.. Board
    Board o-- Piece : shares frozen values
    PrototypeRegistry o-- Prototype : stores and clones
    MoveSearch ..> Board : one clone per branch

MoveSearch never names a class or a constructor; it calls clone. Board decides per field what is copied and what is shared; PrototypeRegistry holds instances, so a new position is data.

Canonical example in Python

The interface and the prototype come first (code/patterns/prototype.py, tested by code/patterns/tests/test_prototype.py):

code/patterns/prototype.py — the Prototype interface and a board that copies itself
@runtime_checkable
class Prototype(Protocol):
    """The interface: an object that can hand back an independent copy of itself.

    ``Self`` ties the return type to the receiver, so a clone of a ``Board`` is a
    ``Board``. A ``Protocol`` rather than an ``ABC``: nothing inherits from this,
    a class qualifies by having a matching ``clone`` method.
    """

    def clone(self) -> Self: ...


class Side(StrEnum):
    WHITE = "white"
    BLACK = "black"

    def other(self) -> Side:
        return Side.BLACK if self is Side.WHITE else Side.WHITE


class PieceKind(StrEnum):
    KING = "k"
    ROOK = "r"


type Square = str
type Move = tuple[Square, Square]


@dataclass(frozen=True, slots=True)
class Piece:
    """Immutable, therefore shared by every clone instead of copied into each one.

    This is the decision that makes cloning cheap: a board copies its *mapping*
    of squares to pieces, never the pieces, because no holder can change one.
    """

    kind: PieceKind
    side: Side

    @property
    def symbol(self) -> str:
        return self.kind.upper() if self.side is Side.WHITE else str(self.kind)


@dataclass(slots=True)
class Board:
    """The Concrete Prototype: mutable state that a search wants to fork cheaply.

    ``clone`` copies field by field and that is the whole pattern: a new ``dict``
    and a new ``list`` because both are mutable, the same ``Piece`` objects
    because they are frozen, and ``side_to_move`` by value. The cost is one
    sentence you can defend: O(occupied squares), no constructor logic re-run.
    """

    squares: dict[Square, Piece] = field(default_factory=dict)
    side_to_move: Side = Side.WHITE
    move_log: list[str] = field(default_factory=list)

    def clone(self) -> Board:
        """A new board that shares nothing mutable with this one."""
        return Board(dict(self.squares), self.side_to_move, list(self.move_log))

    def moves(self) -> list[Move]:
        """Every one-square orthogonal step for the side to move, in a fixed order."""
        found: list[Move] = []
        for source in sorted(self.squares):
            piece = self.squares[source]
            if piece.side is not self.side_to_move:
                continue
            for target in neighbours(source):
                occupant = self.squares.get(target)
                if occupant is None or occupant.side is not piece.side:
                    found.append((source, target))
        return found

    def apply(self, move: Move) -> Board:
        """Return a *new* board with the move made; the receiver is untouched."""
        child = self.clone()
        child.push(move)
        return child

    def push(self, move: Move) -> Piece | None:
        """Make the move in place and return the captured piece, if any.

        The return value is the undo record. Handing it to the caller keeps it on
        the recursion stack, which is why make/unmake needs no memory of its own.
        """
        source, target = move
        piece = self.squares.get(source)
        if piece is None:
            raise ValidationError(f"no piece on {source}")
        if piece.side is not self.side_to_move:
            raise InvalidStateError(f"{self.side_to_move} to move, not {piece.side}")
        captured = self.squares.pop(target, None)
        del self.squares[source]
        self.squares[target] = piece
        self.move_log.append(f"{piece.symbol}{source}{target}")
        self.side_to_move = self.side_to_move.other()
        return captured

    def pop(self, move: Move, captured: Piece | None) -> None:
        """Unmake the move: O(1), the alternative to a clone per branch."""
        source, target = move
        if target not in self.squares:
            raise InvalidStateError(f"nothing on {target} to unmake")
        self.squares[source] = self.squares.pop(target)
        if captured is not None:
            self.squares[target] = captured
        self.move_log.pop()
        self.side_to_move = self.side_to_move.other()

    def render(self) -> str:
        """One group of characters per rank, highest rank first."""
        ranks: list[str] = []
        for rank in reversed(RANKS):
            cells = []
            for file in FILES:
                piece = self.squares.get(f"{file}{rank}")
                cells.append(piece.symbol if piece is not None else ".")
            ranks.append("".join(cells))
        return " ".join(ranks)


def neighbours(square: Square) -> list[Square]:
    """The squares one orthogonal step away that are still on the board."""
    file_index, rank_index = FILES.index(square[0]), RANKS.index(square[1])
    targets = []
    for file_step, rank_step in ORTHOGONAL_STEPS:
        file, rank = file_index + file_step, rank_index + rank_step
        if 0 <= file < len(FILES) and 0 <= rank < len(RANKS):
            targets.append(f"{FILES[file]}{RANKS[rank]}")
    return sorted(targets)

Three decisions to say out loud:

  • Self, not Board, in the Protocol ties the return type to the receiver, so a clone of a Board type-checks as one.
  • The clone is a per-field decision, and that is the whole pattern. dict(self.squares) and list(self.move_log) are new containers because both are mutable; side_to_move copies by value; the Piece objects are shared, because a frozen dataclass cannot change under either holder. Deep or shallow is a property of each field, not of the copy, and the cost follows: O(occupied squares).
  • push returns the undo record instead of storing it, so make/unmake keeps its bookkeeping on the recursion stack and the board carries no history a clone would copy.

The registry and the client cover the two reasons the pattern exists:

code/patterns/prototype.py — a registry of instances and a client that forks positions
class PrototypeRegistry[P: Prototype]:
    """The Prototype Manager: named, already-configured instances that ``create`` clones.

    It never calls a constructor and never learns a class name, so adding a
    position is registering an object rather than writing a subclass or a
    builder. Handing out ``clone()`` and not the stored object is the invariant:
    a caller that mutated the prototype would poison every later ``create``.
    """

    def __init__(self) -> None:
        self._prototypes: dict[str, P] = {}

    def register(self, name: str, prototype: P) -> None:
        if name in self._prototypes:
            raise ConflictError(f"prototype {name!r} is already registered")
        self._prototypes[name] = prototype

    def create(self, name: str) -> P:
        prototype = self._prototypes.get(name)
        if prototype is None:
            raise NotFoundError(f"no prototype named {name!r}")
        return prototype.clone()

    @property
    def names(self) -> tuple[str, ...]:
        return tuple(sorted(self._prototypes))


class MoveSearch:
    """The Client: it explores a tree of positions and must not disturb the caller's board.

    ``count_by_cloning`` forks a board per branch, which is correct by
    construction and costs one clone per edge. ``count_in_place`` walks the same
    tree with make/unmake and allocates nothing. Both return the same number;
    which one you reach for is a cost decision, not a correctness one.
    """

    def __init__(self) -> None:
        self.clones = 0

    def count_by_cloning(self, board: Board, depth: int) -> int:
        if depth < 0:
            raise ValidationError("depth cannot be negative")
        if depth == 0:
            return 1
        total = 0
        for move in board.moves():
            self.clones += 1
            total += self.count_by_cloning(board.apply(move), depth - 1)
        return total

    def count_in_place(self, board: Board, depth: int) -> int:
        if depth < 0:
            raise ValidationError("depth cannot be negative")
        if depth == 0:
            return 1
        total = 0
        for move in board.moves():
            captured = board.push(move)
            total += self.count_in_place(board, depth - 1)
            board.pop(move, captured)
        return total

create returns prototype.clone(), never the stored object; one caller that mutated the prototype would poison every later create. MoveSearch walks the same tree twice on purpose: cloning per branch cannot corrupt the parent, while make/unmake allocates nothing and relies on pop being an exact inverse. Both find 51 positions at depth 3, and the forking walk pays 63 clones for it — a cost decision, not a correctness one.

Running python -m patterns.prototype prints:

--- a registry of configured objects, not of constructors ---
registered: ('bare_kings', 'duel')
two creates, two objects: True, equal state: True
mutated the first:  ..kr .... R... .K..
the second is cold: ..kr .... .... RK..
the stored prototype is untouched: True
--- clone copies the mutable containers and shares the frozen pieces ---
same piece object: True
different dict:    True
copy.copy shares the dict, so the original moved too: ..kr .... .K.. R...
and the halves disagree: white vs black to move
--- fork per branch, or make and unmake: same tree, different cost ---
depth 3: 51 positions, 63 clones
depth 3: 51 positions, 0 clones
the caller's board survived both: True
--- the Pythonic forms: dataclasses.replace and a deepcopy hook ---
replace: 90+30 -> 3+2, rated still True
deepcopy: shared engine True, notes ['rook lift looks strong']
the branch's evaluation is visible on the session: 1
rejected: no prototype named 'sicilian'

The two copy.copy lines show the failure mode: a shallow copy shares containers but rebinds scalars.

Pythonic variant

Python ships the pattern, so a hand-written clone must justify itself. copy.deepcopy is generic and never rots when a field is added, dataclasses.replace is clone-with-changes on a frozen value, and __deepcopy__ declares what to share:

code/patterns/prototype.py — replace for values, a deepcopy hook for shared resources
@dataclass(frozen=True, slots=True)
class GameSettings:
    """A frozen value object: ``dataclasses.replace`` is its clone-with-changes."""

    variant: str
    minutes: int
    increment_seconds: int
    rated: bool = True


def as_blitz(settings: GameSettings) -> GameSettings:
    """No ``clone`` method and no builder: one call returns a new value, two fields changed.

    ``replace`` re-runs ``__init__``, so ``__post_init__`` validation still fires
    and a misspelled field name is a ``TypeError`` at the call, not a silent
    attribute on the copy.
    """
    return replace(settings, minutes=3, increment_seconds=2)


class Engine:
    """An expensive shared resource. Copying one would be a bug, not a clone."""

    def __init__(self, name: str) -> None:
        self.name = name
        self.evaluations = 0

    def evaluate(self, board: Board) -> int:
        self.evaluations += 1
        return sum(1 if piece.side is Side.WHITE else -1 for piece in board.squares.values())


@dataclass(slots=True)
class AnalysisSession:
    """``copy.deepcopy`` copies the entire reachable graph; ``__deepcopy__`` says what not to.

    Without the hook, deep-copying a session would duplicate the engine (and,
    in real code, the connection pool, the lock and the logger behind it).
    Registering the clone in ``memo`` before copying anything that could point
    back here is what stops ``deepcopy`` looping on a cycle in the graph.
    """

    board: Board
    engine: Engine
    notes: list[str] = field(default_factory=list)

    def __deepcopy__(self, memo: dict[int, Any]) -> AnalysisSession:
        clone = AnalysisSession(self.board.clone(), self.engine, list(self.notes))
        memo[id(self)] = clone
        return clone
  • replace re-runs __init__, so validation fires again and a misspelled field is a TypeError at the call, not a stray attribute on the copy. Python 3.13 generalises it beyond dataclasses as copy.replace().
  • deepcopy copies too much by default. It follows every reference it can reach, duplicating the engine, pool or lock hanging off your object. Write the memo entry before copying anything that can point back, or a cycle never ends.
Reach for When
Nothing The constructor is cheap, or the fork is better as make/unmake
dataclasses.replace A frozen value with one or two fields changed
copy.copy Every field is immutable or shared on purpose
copy.deepcopy You want the whole graph and accept O(graph)
__deepcopy__ or __copy__ Something reachable must be shared, or a cached field dropped
A hand-written clone Selective copying on a hot path

Say the last row out loud: clone is an optimisation over deepcopy, bought with a maintenance hazard — add a field, forget the clone, and the bug is a silently shared list.

Real-world usage

  • The copy module is the pattern. copy.copy and copy.deepcopy dispatch to __copy__ and __deepcopy__, thread a memo dict so cycles terminate and shared subobjects stay shared, and otherwise fall back to __reduce_ex__ — which is why anything picklable is already copyable.
  • Clone-with-changes is everywhere on immutable types — datetime.replace, namedtuple._replace, Path.with_suffix — and every container ships a shallow clone: dict.copy, list.copy, collections.ChainMap.new_child.
  • Frameworks: a Django QuerySet clones itself on every filter(), so chaining never mutates the queryset you started from; duplicating a row is the pk = None; save() idiom. JavaScript's prototypes delegate to an exemplar rather than copying one.

The creational patterns all produce objects; classify by what the creator knows.

Looks like Prototype How to tell them apart
Memento Both may call deepcopy. A prototype is copied to get a new object that lives its own life; a memento, to bring the same object back. A memento is also opaque — only its originator reads it — while a clone is a usable peer.
Factory Method / Abstract Factory A factory calls a constructor and knows the classes; PrototypeRegistry stores instances and knows none, so it is a factory whose products are copies.
Builder A builder assembles step by step, and it is what you run once to produce the object worth registering. The two compose.
Flyweight Opposite intent: a flyweight is the same shared object you must not change, a clone is a distinct copy you are expected to change. Piece is a flyweight inside a prototype. An Object Pool is a third relative — it lends objects and takes them back, while nobody returns a clone.
Copy-on-write An optimisation of cloning, not a rival: share, copy on first write. A clone whose fields are all immutable is already copy-on-write.

Where it appears in LLD problems

  • Design chess — clone the board, play the candidate move, ask whether your own king is attacked, drop the clone. Volunteer the follow-up: a real engine uses make/unmake, this page's push/pop.
  • Design a text editor with undo and redo — cloning the buffer gives a checkpoint you can branch from; the same copy kept for restore is a memento.
  • Design an in-memory key-value store with transactions — BEGIN copies the map so the transaction scribbles on its own version; a write-set of the changed keys only is the same clone-versus-delta trade.

Interview tips

Interview tip

Answer the copy question before naming the pattern: "the clone gets a new dict and a new move log because both are mutable, and the same Piece objects because they are frozen." That proves you know deep versus shallow is decided per field. Then be honest about Python: copy.deepcopy is the default, and clone is an optimisation you justify with a cost.

Common mistake

A shallow clone that shares a mutable container. Board(self.squares, ...) without the dict(...) gives two objects that move together, and the first symptom is a search result that depends on the order the branches ran in. Runner-up: cloning something with identity — a connection, a lock, a primary key — leaving two objects that each believe they own it.