Builder
In short: Builder assembles a complex object step by step, with a name for every field, validates it in build() and returns a finished immutable object.
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{ public string To { get; } public string ReplyTo { get; } public string Subject { get; } public string Body { get; } public string Cc { get; } public string Bcc { get; } public string Attachment { get; } public bool HighPriority { get; } public bool TrackOpens { get; }
public Email(string to, string replyTo, string subject, string body, string cc, string bcc, string attachment, bool highPriority, bool trackOpens) { To = to; ReplyTo = replyTo; Subject = subject; Body = body; Cc = cc; Bcc = bcc; Attachment = attachment; HighPriority = highPriority; TrackOpens = trackOpens; }
public void Send() { Console.WriteLine($"To: {To}"); if (ReplyTo != null) { Console.WriteLine($"Reply to: {ReplyTo}"); } Console.WriteLine($"Subject: {Subject}"); if (Attachment != null) { Console.WriteLine($"Attachment: {Attachment}"); } if (HighPriority) { Console.WriteLine("High priority"); } }}This is how the confirmation for order 1042 is sent:
var email = new Email("support@shop.example", "maria@example.com", "Order 1042 has shipped", "Your parcel is on its way.", null, null, "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 compiler 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 null hold the places of fields this email doesn’t have.
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, with a private constructor, so there is no other way to make one; - the builder:
Email.Builder, 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()
class Email{ public string To { get; private init; } public string ReplyTo { get; private init; } public string Subject { get; private init; } public string Body { get; private init; } public string Cc { get; private init; } public string Bcc { get; private init; } public string Attachment { get; private init; } public bool HighPriority { get; private init; } public bool TrackOpens { get; private init; }
private Email() { }
public void Send() { Console.WriteLine($"To: {To}"); if (ReplyTo != null) { Console.WriteLine($"Reply to: {ReplyTo}"); } Console.WriteLine($"Subject: {Subject}"); if (Attachment != null) { Console.WriteLine($"Attachment: {Attachment}"); } if (HighPriority) { Console.WriteLine("High priority"); } }
public class Builder { private string to; private string replyTo; private string subject; private string body; private string cc; private string bcc; private string attachment; private bool highPriority; private bool trackOpens;
public Builder To(string value) { to = value; return this; } public Builder ReplyTo(string value) { replyTo = value; return this; } public Builder Subject(string value) { subject = value; return this; } public Builder Body(string value) { body = value; return this; } public Builder Cc(string value) { cc = value; return this; } public Builder Bcc(string value) { bcc = value; return this; } public Builder Attachment(string value) { attachment = value; return this; } public Builder HighPriority() { highPriority = true; return this; } public Builder TrackOpens() { trackOpens = true; return this; }
public Email Build() { if (to == null) { throw new InvalidOperationException("Missing recipient"); } if (subject == null) { throw new InvalidOperationException("Missing subject"); } if (body == null) { throw new InvalidOperationException("Missing body"); } return new Email { To = to, ReplyTo = replyTo, Subject = subject, Body = body, Cc = cc, Bcc = bcc, Attachment = attachment, HighPriority = highPriority, TrackOpens = trackOpens, }; } }}A builder costs code: every field is written three times: in the email, in the builder and in its method. That’s why it isn’t made for every class.
Email.Builder is a nested class: it can use the private init of the properties and the private constructor, and nobody else can.var email = new Email.Builder() .To("maria@example.com") .Subject("Order 1042 has shipped") .Body("Your parcel is on its way.") .Attachment("invoice-1042.pdf") .HighPriority() .Build();email.Send();
var draft = new Email.Builder() .To("peter@example.com") .Body("Your parcel is on its way.");try{ draft.Build();}catch (InvalidOperationException e){ Console.WriteLine(e.Message);}To: maria@example.comSubject: Order 1042 has shippedAttachment: invoice-1042.pdfHigh priorityMissing subjectvar email = new Email.Builder() .To("maria@example.com") .Subject("Order 1042 has shipped") .Body("Your parcel is on its way.") .Attachment("invoice-1042.pdf") .HighPriority() .Build();email.Send();
var draft = new Email.Builder() .To("peter@example.com") .Body("Your parcel is on its way.");try{ draft.Build();}catch (InvalidOperationException e){ Console.WriteLine(e.Message);}Step by step, in words
- new Email.Builder(): 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. HighPriority() takes no argument: no more true in eighth place.
- Build() checks the required slots and stamps out an Email. Its fields are private init: 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
init lets a property be set only at creation, required makes the compiler check the required ones, and the object initializer writes each one by name:class Email{ public required string To { get; init; } public string ReplyTo { get; init; } public required string Subject { get; init; } public required string Body { get; init; } public string Cc { get; init; } public string Bcc { get; init; } public string Attachment { get; init; } public bool HighPriority { get; init; } public bool TrackOpens { get; init; }}var email = new Email{ To = "maria@example.com", Subject = "Order 1042 has shipped", Body = "Your parcel is on its way.", Attachment = "invoice-1042.pdf", HighPriority = true,};Console.WriteLine($"{email.To} / {email.Subject} / {email.HighPriority}");maria@example.com / Order 1042 has shipped / Truevar email = new Email { To = "maria@example.com", Body = "Your parcel is on its way." };email.To = "peter@example.com";error CS9035: Required member 'Email.Subject' must be set in the object initializer or attribute constructor.error CS8852: Init-only property or indexer 'Email.To' can only be assigned in an object initializer, or on 'this' or 'base' in an instance constructor or an 'init' accessor.new Email(to: "…", replyTo: "…"). Then the swap from the first card doesn’t go unnoticed either.When 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?
var builder = new Email.Builder() .To("maria@example.com") .Subject("Order 1042 has shipped") .Body("Your parcel is on its way.");Email first = builder.Build();builder.HighPriority();Email second = builder.Build();Console.WriteLine($"{first.HighPriority} {second.HighPriority}");Try it
Find the bug
The shop sends the emails for shipped orders in a loop. Only the first order has an invoice:
var orders = new List<Order>{ new("maria@example.com", 1042, "invoice-1042.pdf"), new("peter@example.com", 1043, null),};var builder = new Email.Builder().ReplyTo("support@shop.example");foreach (var order in orders){ builder.To(order.Email) .Subject($"Order {order.Id} has shipped") .Body("Your parcel is on its way."); if (order.Invoice != null) { builder.Attachment(order.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;
-
requiredandinitwith an object initializer 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 · record, switch and patterns · Design patterns · Factory Method and Simple Factory
- Continues in: The shop: which pattern where
- Tasks on this topic: Design patterns