Notes and exercises for learning design patterns
Sometimes creating an object from scratch is annoying, repetitive, or expensive.
Imagine you have a report template:
Monthly Sales Report
- company logo
- default layout
- theme
- standard charts
- default filters
- export settings
Now you need several similar reports:
Monthly Sales Report - UK
Monthly Sales Report - Germany
Monthly Sales Report - Enterprise Customers
Monthly Sales Report - Trial Customers
Most of the configuration is the same. Only a few details change.
Without Prototype, you may keep repeating the same construction code:
uk_report = Report(
title="Monthly Sales - UK",
theme="executive",
charts=[...],
filters={"region": "UK"},
export_format="pdf",
)
germany_report = Report(
title="Monthly Sales - Germany",
theme="executive",
charts=[...],
filters={"region": "Germany"},
export_format="pdf",
)
The problem is:
I already have an object that is almost correct. Why rebuild it from zero?
That is the problem the Prototype pattern solves.
The Prototype pattern creates new objects by copying existing objects.
Instead of saying:
Create a fresh object from this class.
we say:
Take this existing object as a prototype, clone it, then customize the clone.
Prototype is a creational design pattern.
Its main idea is:
Create new objects by cloning existing objects.
The shape is:
prototype object
|
| clone
v
new object
|
| customize
v
final object
In code, the pattern usually appears as a method like this:
copy = original.clone()
or this:
copy = original.clone(title="New title")
The important idea is not the method name. The important idea is:
The existing object becomes the recipe for the new object.
from __future__ import annotations
from copy import deepcopy
from dataclasses import dataclass, field
@dataclass
class Chart:
title: str
metric: str
@dataclass
class Report:
title: str
theme: str
charts: list[Chart] = field(default_factory=list)
filters: dict[str, str] = field(default_factory=dict)
export_format: str = "pdf"
def clone(self, **changes) -> "Report":
new_report = deepcopy(self)
for name, value in changes.items():
setattr(new_report, name, value)
return new_report
Usage:
base_report = Report(
title="Monthly Sales",
theme="executive",
charts=[
Chart("Revenue", "revenue"),
Chart("New Customers", "new_customers"),
],
filters={"region": "all"},
)
uk_report = base_report.clone(
title="Monthly Sales - UK",
)
uk_report.filters["region"] = "UK"
Now uk_report starts as a copy of base_report, but it can be changed independently.
That is Prototype.
Prototype is simple in idea, but copying is subtle.
A shallow copy copies only the outer object. Nested objects may still be shared.
A deep copy copies the outer object and nested objects.
Example problem:
from copy import copy
report_a = copy(base_report)
report_b = copy(base_report)
report_a.filters["region"] = "UK"
print(report_b.filters["region"])
With a shallow copy, report_a.filters and report_b.filters may point to the same dictionary.
Changing one can accidentally affect the other.
That is why the earlier clone() method used:
deepcopy(self)
For Prototype, always ask:
Should the clone share nested objects with the original,
or should it get its own independent copies?
This is the most common practical issue in Prototype.
A natural example is an email campaign system.
Suppose your company sends similar marketing emails:
Base campaign:
- sender name
- brand theme
- tracking settings
- unsubscribe footer
- default subject style
- default delivery settings
Now the marketing team wants variants:
Trial users campaign
Enterprise users campaign
Inactive users campaign
Black Friday campaign
You do not want to rebuild every campaign from scratch. You want to start from a known-good campaign template and slightly modify it.
from copy import deepcopy
from dataclasses import dataclass, field
@dataclass
class EmailCampaign:
name: str
subject: str
audience: str
template: str
tracking_enabled: bool = True
tags: list[str] = field(default_factory=list)
def clone(self, **changes):
campaign = deepcopy(self)
for field_name, value in changes.items():
setattr(campaign, field_name, value)
return campaign
Usage:
base_campaign = EmailCampaign(
name="Base Welcome Campaign",
subject="Welcome to our product",
audience="all_users",
template="welcome.html",
tags=["welcome", "onboarding"],
)
trial_campaign = base_campaign.clone(
name="Trial Welcome Campaign",
audience="trial_users",
)
enterprise_campaign = base_campaign.clone(
name="Enterprise Welcome Campaign",
audience="enterprise_users",
subject="Welcome to your enterprise workspace",
)
This feels natural because campaign variants are not completely different objects. They are modified copies of a standard template.
A Factory answers:
Which class should I create?
Example:
importer = ImporterFactory.create_for_file("customers.csv")
Prototype answers:
Which existing object should I copy?
Example:
enterprise_campaign = base_campaign.clone(audience="enterprise_users")
So:
Factory = choose the right class.
Prototype = copy the right existing object.
Builder is useful when creation is a process:
request = (
HttpRequestBuilder()
.post(url)
.with_auth_token(token)
.with_json_body(body)
.build()
)
Prototype is useful when creation is mostly repetition:
new_report = base_report.clone(title="Monthly Sales - UK")
So:
Builder = build step by step.
Prototype = copy a configured example.
Prototype can help when new variants should be added without changing a big conditional.
Instead of this:
if campaign_type == "trial":
...
elif campaign_type == "enterprise":
...
elif campaign_type == "inactive":
...
you can keep a registry of prototypes:
class CampaignPrototypeRegistry:
def __init__(self):
self._prototypes = {}
def register(self, name, campaign):
self._prototypes[name] = campaign
def create(self, name, **changes):
prototype = self._prototypes[name]
return prototype.clone(**changes)
Usage:
registry = CampaignPrototypeRegistry()
registry.register("welcome", base_campaign)
trial_campaign = registry.create(
"welcome",
name="Trial Welcome Campaign",
audience="trial_users",
)
Now the creation rule is:
Find the right prototype.
Clone it.
Customize the clone.
A good data-science example is sklearn.base.clone from scikit-learn.
In scikit-learn, clone(estimator) creates a new unfitted estimator with the same parameters as the original estimator.
It copies the model configuration, but not the learned fitted state.
Example:
from sklearn.base import clone
from sklearn.linear_model import LogisticRegression
prototype_model = LogisticRegression(
C=0.5,
max_iter=1000,
)
model_for_fold_1 = clone(prototype_model)
model_for_fold_2 = clone(prototype_model)
This is Prototype-like:
prototype_model:
LogisticRegression(C=0.5, max_iter=1000)
clone(prototype_model):
new LogisticRegression with the same parameters,
but not fitted yet
This matters in machine learning workflows. For example, cross-validation needs several fresh models with the same configuration. Each fold should get a new estimator, not reuse a model already fitted on another fold.
That is a practical Prototype idea:
Use a configured estimator as a prototype, then create fresh independent estimators from it.
Use Prototype when:
| Situation | Why Prototype helps |
|---|---|
| You have many similar objects | Clone a base object instead of repeating setup. |
| Object setup is expensive or verbose | Configure once, then copy. |
| You need template-like objects | Store standard prototypes and create variants. |
| The exact class may not matter to the caller | The caller can clone an object without knowing how to build it. |
| Runtime configuration matters | Users or config files can define prototypes dynamically. |
| You want fresh objects with the same configuration | Common in ML estimators, simulations, workflows, reports, and templates. |
Good examples:
report templates
email campaign templates
game enemy templates
configured ML estimators
workflow/task templates
document/page templates
chart templates
Do not use Prototype when normal construction is already clear.
point = Point(x=10, y=20)
This does not need:
point = point_prototype.clone(x=10, y=20)
Avoid Prototype when copying is dangerous or unclear.
Examples:
database connections
open files
network sockets
thread locks
objects with unique IDs
objects with security tokens
objects with hidden mutable state
Also avoid it when deep copying would be too expensive.
If the object contains a huge dataset, cloning the whole thing may be wasteful.
In that case, you may need a custom clone() method that copies configuration but shares or resets heavy data intentionally.
Ask:
Do I already have an object that is almost the object I need?
If yes, Prototype may help.
Ask:
Would rebuilding this object from scratch repeat a lot of setup?
If yes, Prototype may help.
Ask:
Is it obvious which parts should be shared and which parts should be copied?
If no, be careful.
Define a custom clone() method instead of blindly using copy.copy() or copy.deepcopy().
The best practical rule:
Use Prototype when “copy this configured example and adjust a few fields” is clearer than “construct a new object from scratch.”
Prototype is a creational design pattern for making new objects by cloning existing ones.
It is useful when objects have template-like configuration:
Start with this known-good object.
Make a copy.
Change only what is different.
Mental model:
Factory: choose the right class.
Builder: assemble the object step by step.
Prototype: copy an existing object and customize the copy.
One sentence:
Prototype is useful when an existing configured object is the clearest recipe for creating the next object.