Builder
In short: Builder assembles a complex object step by step, with a name for every field, and validates it in build(); in Python keyword arguments and dataclass often do the same.
Problem
Nine parameters and two swapped words
The shop sends an email when an order ships. The email has nine fields: who it goes to, the address for replies, the subject, the body, a copy, a blind copy, an attachment, whether it is important and whether opening it is tracked.
class Email: def __init__(self, to, reply_to, subject, body, cc, bcc, attachment, high_priority, track_opens): self.to = to self.reply_to = reply_to self.subject = subject self.body = body self.cc = cc self.bcc = bcc self.attachment = attachment self.high_priority = high_priority self.track_opens = track_opens
def send(self): print(f"To: {self.to}") if self.reply_to: print(f"Reply to: {self.reply_to}") print(f"Subject: {self.subject}") if self.attachment: print(f"Attachment: {self.attachment}") if self.high_priority: print("High priority")This is how the confirmation for order 1042 is sent:
email = Email("support@shop.example", "maria@example.com", "Order 1042 has shipped", "Your parcel is on its way.", None, None, "invoice-1042.pdf", True, False)email.send()To: support@shop.exampleReply to: maria@example.comSubject: Order 1042 has shippedAttachment: invoice-1042.pdfHigh priorityThe email went to the shop’s support, and Maria got nothing. The first two arguments are swapped. Both are text, so the language has no way to tell. And which is true and which is false at the end? To find out, you count positions in the constructor. Two None hold the places of fields this email doesn’t have.
reply_to="…"), but nothing makes you use them: the positional call runs without an error.Every new field, say the language of the email, changes the constructor and every place that calls it.
The idea
Builder: by name, in steps, with a check
The object isn’t made with one call of nine positions, but in steps, each with a name: .to(…), .subject(…). The steps go to a separate object, the builder. It collects the values, and at the end build():
- checks the required fields (here: recipient, subject and body) and throws an exception if one is missing;
- makes an immutable object: after that the email can’t change.
This is the Builder pattern. The roles:
- the product:
Email, immutable; - the builder:
EmailBuilder, with a method for every field. The method returns the builder itself, so the calls line up one after another in a chain; build(): the only place where the product is checked and made.
See it
The chain and build()
@dataclass(frozen=True, kw_only=True)class Email: to: str reply_to: str | None = None subject: str body: str cc: str | None = None bcc: str | None = None attachment: str | None = None high_priority: bool = False track_opens: bool = False
def send(self): print(f"To: {self.to}") if self.reply_to: print(f"Reply to: {self.reply_to}") print(f"Subject: {self.subject}") if self.attachment: print(f"Attachment: {self.attachment}") if self.high_priority: print("High priority")
class EmailBuilder: def __init__(self): self.fields = {}
def to(self, value): self.fields["to"] = value return self
def reply_to(self, value): self.fields["reply_to"] = value return self
def subject(self, value): self.fields["subject"] = value return self
def body(self, value): self.fields["body"] = value return self
def cc(self, value): self.fields["cc"] = value return self
def bcc(self, value): self.fields["bcc"] = value return self
def attachment(self, value): self.fields["attachment"] = value return self
def high_priority(self): self.fields["high_priority"] = True return self
def track_opens(self): self.fields["track_opens"] = True return self
def build(self): if "to" not in self.fields: raise ValueError("Missing recipient") if "subject" not in self.fields: raise ValueError("Missing subject") if "body" not in self.fields: raise ValueError("Missing body") return Email(**self.fields)A builder costs code: every field is written both in the email and in the builder’s method. That’s why it isn’t made for every class.
@dataclass(frozen=True, kw_only=True) writes __init__ for you: frozen=True forbids changes after creation, and kw_only=True takes the fields by name only. **self.fields passes the dictionary as keyword arguments.email = ( EmailBuilder() .to("maria@example.com") .subject("Order 1042 has shipped") .body("Your parcel is on its way.") .attachment("invoice-1042.pdf") .high_priority() .build())email.send()
draft = ( EmailBuilder() .to("peter@example.com") .body("Your parcel is on its way."))try: draft.build()except ValueError as e: print(e)To: maria@example.comSubject: Order 1042 has shippedAttachment: invoice-1042.pdfHigh priorityMissing subjectemail = ( EmailBuilder() .to("maria@example.com") .subject("Order 1042 has shipped") .body("Your parcel is on its way.") .attachment("invoice-1042.pdf") .high_priority() .build())email.send()
draft = ( EmailBuilder() .to("peter@example.com") .body("Your parcel is on its way."))try: draft.build()except ValueError as e: print(e)Step by step, in words
- EmailBuilder(): an empty blueprint with nine slots. The three required ones (to, subject, body) are marked.
- .to(…) fills one slot and returns the same builder, so the chain goes on.
- Every call has a name: a value cannot land in the wrong slot, as it did with the positions in the constructor.
- .body(…): all three required slots are filled.
- Optional fields are written only when needed. high_priority() takes no argument: no more true in eighth place.
- build() checks the required slots and stamps out an Email. Its fields are frozen=True: the object doesn't change any more.
- The email goes to Maria, with the invoice, as high priority.
- A new builder, a new empty blueprint. Here someone forgot the subject.
- draft.build() reaches the empty slot subject and stops with an exception. An email without a subject never exists at all.
- The error says what is missing. Nothing was sent.
The email without a subject never reaches the mail: build() stops it already at assembly.
See it
Without a builder: what the language has
kw_only=True allows a call by name only. A check that the dataclass doesn’t do by itself goes into __post_init__:@dataclass(frozen=True, kw_only=True)class Email: to: str reply_to: str | None = None subject: str body: str cc: str | None = None bcc: str | None = None attachment: str | None = None high_priority: bool = False track_opens: bool = False
def __post_init__(self): if "@" not in self.to: raise ValueError(f"Bad address: {self.to}")email = Email( to="maria@example.com", subject="Order 1042 has shipped", body="Your parcel is on its way.", attachment="invoice-1042.pdf", high_priority=True,)print(email.to, "/", email.subject, "/", email.high_priority)maria@example.com / Order 1042 has shipped / Trueemail = Email("support@shop.example", "maria@example.com", "Order 1042 has shipped")TypeError: Email.__init__() takes 1 positional argument but 4 were givenWhen a builder is still worth it:
- the email is put together in steps, in different parts of the code: the attachment is added only if there is an invoice, the copy only for companies;
- there is a check across fields: for example, the blind copy can’t be the recipient’s address;
- there are many fields, most of them optional, and the assembly has to be passed from one place to another before it is ready.
Try it
Predict the output
One builder, two calls to build(). What does the program print?
builder = ( EmailBuilder() .to("maria@example.com") .subject("Order 1042 has shipped") .body("Your parcel is on its way."))first = builder.build()builder.high_priority()second = builder.build()print(first.high_priority, second.high_priority)Try it
Find the bug
The shop sends the emails for shipped orders in a loop. Only the first order has an invoice:
orders = [ ("maria@example.com", 1042, "invoice-1042.pdf"), ("peter@example.com", 1043, None),]builder = EmailBuilder().reply_to("support@shop.example")for email, order_id, invoice in orders: builder.to(email).subject(f"Order {order_id} has shipped") builder.body("Your parcel is on its way.") if invoice: builder.attachment(invoice) builder.build().send()To: maria@example.comReply to: support@shop.exampleSubject: Order 1042 has shippedAttachment: invoice-1042.pdfTo: peter@example.comReply to: support@shop.exampleSubject: Order 1043 has shippedAttachment: invoice-1042.pdfPeter got Maria’s invoice. Which line has the bug?
Pick the line with the bug.
The builder is made once, before the loop. It remembers everything it was told, and the attachment from the first email stays for the second, because nobody cleared it. A builder is for one assembly: make a new one inside the loop, for every email. Here the bug leaks personal data: someone else’s invoice with a name and an address.
The idea
When you don’t need it
Builder doubles the code of the class. It isn’t worth it when:
- there are two or three fields and all are required: a plain constructor is clearer;
- keyword arguments and a dataclass do the same without a second class;
- the object has nothing to check and isn’t put together in steps.
It is worth it when there are many fields and most are optional, when there is a check across them, or when the object is put together in steps, in different parts of the code.
Exercise
A parcel for the courier
Solve the task in your editor.
A builder for a parcel. The courier wants a parcel with a recipient, a phone, an address and a weight (required), and optionally: fragile and cash on delivery. Write Shipment and a builder for it. build() checks that the required fields are set and that the weight is over 0 and up to 30 kg. Make a parcel for Maria (2.5 kg, fragile, cash on delivery €42.90), print it, and try a 45 kg parcel:
Parcel for Maria, 0888 123 456Address: 5 Shipka St, PlovdivWeight: 2.5 kg, fragileCash on delivery: €42.90The weight must be over 0 and up to 30 kgMore tasks
- Fix the loop. Fix the program from "Find the bug" so that every email carries only its own invoice. The reply address must stay for all emails.
- A copy with one change. Add a method that turns a finished email into a builder with the same values (in Java it is often called
toBuilder()). Send the same email to another recipient. - The language’s way. Write
Shipmentas in the card "Without a builder" and compare the two versions. Which one is shorter, and what do you lose?
Test
Check yourself
Related lessons
Section titled "Related lessons"- Builds on: Nested classes and data · dataclass, match and patterns · Design patterns · Factory Method and Simple Factory
- Continues in: The shop: which pattern where
- Tasks on this topic: Design patterns