Some objects have 3-15 optional fields, some fields
depend on others, and building them is a real step.
Builder gives you a readable, chainable, checked way
to build these objects — and hands you an object you
can't accidentally change at the end.
The problem
You're building an email system. An Email has:
to (required, list of addresses)
subject (required)
body (required)
cc (optional, list)
bcc (optional, list)
reply_to (optional)
attachments (optional, list of file paths)
headers (optional, dict)
priority (optional: "low" | "normal" | "high")
tracking_enabled (optional, bool)
Rules that must always hold:
- At least one recipient somewhere in
to / cc /
bcc.
priority: "high" requires tracking_enabled: True.
- Once built, the Email can't be changed (nobody should
be able to modify it after handing it to the sender).
Naive construction:
Email(
to=["a@x.com"],
subject="Hi",
body="hello",
cc=None,
bcc=None,
reply_to=None,
attachments=[],
headers={},
priority="normal",
tracking_enabled=False,
)
Every caller passes eight None / default values. When we
add a new optional field, every existing call site
changes. And the rules aren't checked anywhere — you find
out about a broken rule when the email fails to send.
Questions to ask about the requirements
Questions that shape the choice:
- How many fields total? 3-4 → a normal constructor
is fine. 10+ → constructor calls become hard to read.
- How many are optional? Any rules across fields
(like "high priority requires tracking")? If yes, we
need one place to check them all together.
- Should the built object be changeable or fixed?
Fixed (can't change after building) is safer when the
object gets passed around. Builder makes "fixed" easy.
- Do callers build in stages (start now, add cc
later, finish much later)? If yes, we need a
work-in-progress object — the builder itself.
- How often is the same "template" reused with small
tweaks? Builders can be copied part-way through to
make variants.
For this problem: ~10 fields, mostly optional, rules
across fields, must be fixed once built, often built in
stages (add attachments after computing them, add headers
based on user preferences later). All signs point at
Builder.
Different designs, compared
Design A — one giant constructor with defaults
class Email:
def __init__(self, to, subject, body,
cc=None, bcc=None, reply_to=None,
attachments=None, headers=None,
priority="normal", tracking_enabled=False):
# check the rules here
...
- Good: Standard Python. Callers use keyword
arguments → readable enough for 5-6 fields.
- Bad: Calls with 10 kwargs are dense. Each stage of
building needs the WHOLE object; no in-between state.
Rules across fields must be checked at build time —
you can't build in stages.
- When it wins: Up to ~5 fields, no staged building,
simple checking. This is the "just use kwargs" answer
— often correct, don't over-engineer.
Design B — dataclass with post-init checking
@dataclass
class Email:
to: list
subject: str
body: str
cc: list = field(default_factory=list)
# ... etc
def __post_init__(self):
# check the rules
- Good: Less boilerplate. Fixed after building if
you set
frozen=True.
- Bad: Same "no staged building" problem as Design
A.
frozen=True also stops you from cleaning up data
in post-init.
- When it wins: Everything Design A wins, plus you
want dataclass niceness (auto
__repr__, __eq__).
Still caps around 5-7 fields before calls get
unreadable.
Design C — build a dict, then construct
cfg = {"to": [...], "subject": "..."}
if condition:
cfg["cc"] = [...]
email = Email(**cfg)
- Good: Staged building works — change the dict,
then build once at the end. No new classes.
- Bad: No type checking on the dict. Typos are
silent (
cfg["ccc"] doesn't raise). No autocomplete.
Checking happens once at build — no early warning on
broken in-between states.
- When it wins: Small internal scripts, prototypes.
Not for a customer-facing API.
Design D — Builder pattern
class EmailBuilder:
def __init__(self):
self._to = []
self._subject = None
# ... etc
def to(self, address):
self._to.append(address); return self
def subject(self, s):
self._subject = s; return self
def cc(self, address):
self._cc.append(address); return self
def high_priority(self):
self._priority = "high"
self._tracking_enabled = True # enforces invariant
return self
def build(self):
# cross-field validation ONCE, here
if not (self._to or self._cc or self._bcc):
raise ValueError("at least one recipient required")
if self._priority == "high" and not self._tracking_enabled:
raise ValueError("high priority requires tracking")
return Email(
to=tuple(self._to),
subject=self._subject,
# ... construct immutable Email
)
email = (EmailBuilder()
.to("a@x.com")
.subject("Q1 report")
.body("Attached.")
.attach("q1.pdf")
.high_priority()
.build())
- Good: Every chain method has a name
(
.high_priority() hides two field sets). Rules
across fields checked in one place — .build(). Built
object can't be changed. Building in stages is
natural. Named methods (.high_priority()) can
encode business rules.
- Bad: Twice the code (a Builder class + an Email
class). Overkill for a 3-field object.
- When it wins: Many fields, rules across fields,
need "can't change after building", want named
building steps.
The locked-in choice: Design D
Our answers:
- 10 fields — call sites are unreadable with plain
constructors.
→ Rules out A and B.
- Rules across fields (
priority ↔ tracking_enabled)
need one place to check.
→ Rules out A / B / C which all check at
build time, not while building.
- Must be fixed after building.
→ Rules out mutable dataclass (B with
frozen=False).
- Building in stages is common.
→ Rules out A cleanly.
- We want named steps (
.high_priority() doing two
things at once).
→ Rules out C which has no place for business
rules.
Design D wins.
Mechanics
class Email:
# frozen: exposes attributes, forbids mutation
def __init__(self, to, subject, body, cc, bcc, reply_to,
attachments, headers, priority, tracking_enabled):
self._to = to
# ... store all fields as private
# (or use dataclass(frozen=True) — either works)
@property
def to(self): return self._to
# ... etc
class EmailBuilder:
def __init__(self):
self._to = []
self._cc = []
self._bcc = []
self._subject = None
self._body = None
self._reply_to = None
self._attachments = []
self._headers = {}
self._priority = "normal"
self._tracking_enabled = False
# Fluent setters — each returns self for chaining.
def to(self, address): self._to.append(address); return self
def cc(self, address): self._cc.append(address); return self
def bcc(self, address): self._bcc.append(address); return self
def subject(self, s): self._subject = s; return self
def body(self, b): self._body = b; return self
def reply_to(self, addr): self._reply_to = addr; return self
def attach(self, path): self._attachments.append(path); return self
def header(self, name, value):
self._headers[name] = value; return self
def high_priority(self):
self._priority = "high"
self._tracking_enabled = True
return self
def build(self):
if not (self._to or self._cc or self._bcc):
raise ValueError("at least one recipient required")
if self._subject is None:
raise ValueError("subject required")
if self._body is None:
raise ValueError("body required")
if self._priority == "high" and not self._tracking_enabled:
raise ValueError("high priority requires tracking")
return Email(
to=tuple(self._to),
cc=tuple(self._cc),
bcc=tuple(self._bcc),
subject=self._subject,
body=self._body,
reply_to=self._reply_to,
attachments=tuple(self._attachments),
headers=dict(self._headers),
priority=self._priority,
tracking_enabled=self._tracking_enabled,
)
Your task
In main.py build:
Email class with the fields above, all exposed as
read-only properties. Constructing an Email directly is
fine (the Builder calls it) — but callers should use the
Builder.
EmailBuilder with:
- Fluent setters:
to(addr), cc(addr), bcc(addr),
subject(s), body(b), reply_to(addr), attach(path),
header(name, value), high_priority() — each returns
self.
build() returns an Email after validating:
- At least one recipient (across to/cc/bcc)
- subject and body must be set
- high priority requires tracking
- Invalid →
ValueError.
Structural tests enforce:
Email fields are all read-only (assigning to .subject
raises).
- Every fluent method returns
self (enables chaining).
- The lists on the built Email are IMMUTABLE snapshots
(mutating them doesn't change the Email; the Builder used
tuple(...)).
Click Submit to run the tests.