Course contents

Builder — complex objects, readable construction

Sign in to run this lab and save your progress

Learn

Before you start

Builder is the pattern students reach for too often. "The constructor is a bit long, let me add a Builder." Slow down. Builder is worth it when the object has ALL of:

  1. Many fields (about 6+)
  2. Rules across fields (some combinations aren't allowed)
  3. Should be fixed after building
  4. Often built in stages, with decisions coming in over time

If your object has 3 fields, frozen=True dataclass wins. If it has 5 fields with no cross-field rules, kwargs win. Builder is real extra work — only use it when the requirements actually need it.

The staged-building signal

The clearest sign you need Builder: you're calling the constructor with data that isn't decided yet.

Watch for code like:

kwargs = {"to": [user.email], "subject": title}
if user.wants_attachments:
    kwargs["attachments"] = compute_attachments()
if user.is_priority:
    kwargs["priority"] = "high"
    kwargs["tracking_enabled"] = True
email = Email(**kwargs)

Every if is a stage. Builder moves those stages onto methods of the builder object instead of dict tricks. Then the rules across fields (priority=high needs tracking) live in one place — build().

The named-methods payoff

The best Builder API isn't .set_priority("high") and .set_tracking_enabled(True). It's .high_priority() — one method that encodes the business rule "high priority ALWAYS turns tracking on". Callers can't accidentally set one without the other; the method does both.

Look for chances like this in every Builder you design. .for_customer(user) might set the recipient AND their timezone AND their language. Business rules turned into verbs.

When NOT to use Builder

  • Small objects (≤ 5 fields). Kwargs win.
  • Objects with no cross-field rules. Dataclass wins.
  • Objects that need to change after building. You lose the main "can't change it" payoff. The extra work isn't worth it.
  • "Just to be consistent." Don't add Builder because another class has one. Every pattern is a case-by-case choice.

Common design problems where Builder wins

  • HTTP requests — method + url + headers + body + timeout + retry policy + auth + query params. 8+ fields, rules across fields (auth type shapes headers).
  • SQL query builders — SELECT + FROM + JOIN + WHERE + GROUP BY + ORDER BY + LIMIT. Order matters, rules matter (GROUP BY needs aggregates in SELECT).
  • UI widget configuration — dashboards with 10-20 options.
  • Report generation — title + sections + charts + attachments + delivery + audience.
  • Game character creation — race + class + stats + inventory + starting spells + backstory. Cross-field rules everywhere.

If a problem has "many optional fields", pause before reaching for kwargs.

The Builder decision cheat-sheet

Fields Optionals Rules across fields Can change after build? Pick
1-3 any none any Constructor
4-6 few simple yes Dataclass
4-6 few simple no dataclass(frozen=True)
6+ many simple either Kwargs or dataclass
6+ many cross-field no Builder
4+ few cross-field + staged either Builder

The last two rows are where Builder is the right answer. Everything else has a lighter option.

About the strict test

test_email_list_fields_are_immutable_snapshots catches a common half-refactor: the Builder returns the Email with a reference to its OWN internal list. A caller can then .append() to the built email's .to and corrupt it. The tuple(...) snapshot in build() prevents this. Small detail, big effect.

Where Builder shows up in real Python code

  • requests.Session() methods (mostly)
  • SQLAlchemy's select().where().order_by()... — a Builder written as method chains
  • Django's QuerySet — Model.objects.filter().order_by()...
  • Kubernetes Python client — every object has a V1PodBuilder equivalent

Not always called "Builder" — the shape (chain of methods that produces an object you can't change) is the same.

Your task

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:

  1. How many fields total? 3-4 → a normal constructor is fine. 10+ → constructor calls become hard to read.
  2. How many are optional? Any rules across fields (like "high priority requires tracking")? If yes, we need one place to check them all together.
  3. 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.
  4. 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.
  5. 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 (prioritytracking_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:

  1. 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.
  2. 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.

Starter files

Preview only — open the lab to edit and run.

files_dir
03-builder-starter