Skip to main content

Builder Pattern

Definition​

The Builder pattern separates the construction of a complex object from its representation, allowing the same construction process to create different representations. Instead of a telescoping constructor with many parameters, you build the object step-by-step.

Coffee Shop Example​

A custom coffee order can have many optional parts: size, extra shots, milk type, syrup, toppings, and special instructions. A constructor with all these parameters would be unreadable β€” Builder solves this.

The Problem: Telescoping Constructor​

// Don't do this
var coffee = new CustomCoffee(
"Latte", // name
CupSize.Large, // size
2, // extra shots
"Oat", // milk type
"Vanilla", // syrup
true, // whipped cream
false, // iced
"Extra hot" // special instructions
);
// Which parameter was "Oat" again? Hard to read, hard to maintain.

Implementation​

public class CustomCoffee
{
public string Name { get; private set; } = "";
public CupSize Size { get; private set; } = CupSize.Medium;
public int ExtraShots { get; private set; }
public string MilkType { get; private set; } = "Whole";
public string? Syrup { get; private set; }
public bool WhippedCream { get; private set; }
public bool Iced { get; private set; }
public string? SpecialInstructions { get; private set; }
public List<string> Toppings { get; private set; } = new();

public override string ToString()
{
var parts = new List<string> { $"{Name} ({Size})" };
if (ExtraShots > 0) parts.Add($"+{ExtraShots} shots");
if (MilkType != "Whole") parts.Add($"{MilkType} milk");
if (Syrup is not null) parts.Add($"{Syrup} syrup");
if (WhippedCream) parts.Add("whipped cream");
if (Iced) parts.Add("iced");
if (Toppings.Count > 0) parts.Add($"topped with {string.Join(", ", Toppings)}");
if (SpecialInstructions is not null) parts.Add($"[{SpecialInstructions}]");
return string.Join(" | ", parts);
}

// Builder β€” nested class has access to private setters
public class Builder
{
private readonly CustomCoffee _coffee = new();

public Builder(string name)
{
_coffee.Name = name;
}

public Builder WithSize(CupSize size)
{
_coffee.Size = size;
return this;
}

public Builder WithExtraShots(int shots)
{
_coffee.ExtraShots = shots;
return this;
}

public Builder WithMilk(string milkType)
{
_coffee.MilkType = milkType;
return this;
}

public Builder WithSyrup(string syrup)
{
_coffee.Syrup = syrup;
return this;
}

public Builder WithWhippedCream()
{
_coffee.WhippedCream = true;
return this;
}

public Builder MakeIced()
{
_coffee.Iced = true;
return this;
}

public Builder WithTopping(string topping)
{
_coffee.Toppings.Add(topping);
return this;
}

public Builder WithSpecialInstructions(string instructions)
{
_coffee.SpecialInstructions = instructions;
return this;
}

public CustomCoffee Build()
{
// Validation
if (string.IsNullOrWhiteSpace(_coffee.Name))
throw new InvalidOperationException("Coffee name is required.");

if (_coffee.Iced && _coffee.SpecialInstructions == "Extra hot")
throw new InvalidOperationException("Iced coffee cannot be extra hot.");

return _coffee;
}
}
}

Usage β€” Fluent Builder​

var coffee = new CustomCoffee.Builder("Latte")
.WithSize(CupSize.Large)
.WithExtraShots(2)
.WithMilk("Oat")
.WithSyrup("Vanilla")
.WithWhippedCream()
.WithTopping("Caramel Drizzle")
.WithTopping("Chocolate Shavings")
.WithSpecialInstructions("Extra hot")
.Build();

Console.WriteLine(coffee);
// Output: Latte (Large) | +2 shots | Oat milk | Vanilla syrup | whipped cream | topped with Caramel Drizzle, Chocolate Shavings | [Extra hot]

// Minimal order β€” only the required parameter
var simple = new CustomCoffee.Builder("Espresso")
.WithSize(CupSize.Small)
.Build();
// Output: Espresso (Small)

Director β€” Reusable Recipes​

A Director defines standard construction sequences β€” like a barista following a recipe:

public class BaristaDirector
{
private readonly CustomCoffee.Builder _builder;

public BaristaDirector(CustomCoffee.Builder builder)
{
_builder = builder;
}

public CustomCoffee MakeSignatureMocha()
{
return _builder
.WithSize(CupSize.Large)
.WithExtraShots(1)
.WithMilk("Whole")
.WithSyrup("Chocolate")
.WithWhippedCream()
.WithTopping("Cocoa Powder")
.Build();
}

public CustomCoffee MakeIcedVanillaLatte()
{
return _builder
.WithSize(CupSize.Large)
.WithMilk("Almond")
.WithSyrup("Vanilla")
.MakeIced()
.Build();
}
}

// Usage
var director = new BaristaDirector(new CustomCoffee.Builder("Mocha"));
var signature = director.MakeSignatureMocha();

Record-Based Builder (Modern C#)​

Using init properties and with expressions, you can achieve a builder-like pattern without a separate Builder class:

public record CustomCoffeeOrder(
string Name,
CupSize Size = CupSize.Medium,
int ExtraShots = 0,
string MilkType = "Whole",
string? Syrup = null,
bool WhippedCream = false,
bool Iced = false
);

// Usage β€” named arguments act like a builder
var order = new CustomCoffeeOrder(
Name: "Latte",
Size: CupSize.Large,
MilkType: "Oat",
Syrup: "Vanilla",
WhippedCream: true
);

// Clone with modifications using 'with' expression
var icedVersion = order with { Iced = true, MilkType = "Almond" };

StringBuilder β€” .NET's Built-in Builder​

System.Text.StringBuilder is a Builder pattern itself β€” you construct a string step-by-step instead of creating many intermediate string objects:

var receipt = new StringBuilder();
receipt.AppendLine("=== Coffee Receipt ===");
receipt.AppendLine($"Latte (Large) $4.00");
receipt.AppendLine($" + Oat milk $0.60");
receipt.AppendLine($" + Vanilla $0.50");
receipt.AppendLine($"-----------------------");
receipt.AppendLine($"Total: $5.10");
Console.WriteLine(receipt.ToString());

When to Use​

  • Object has many optional parameters or configuration steps
  • You need immutable objects with many properties (combine with init or record)
  • The construction process should support different representations (same steps, different results)
  • You need validation before the object is fully created

When NOT to Use​

  • The object is simple (2-3 fields, all required) β€” use a constructor
  • There's only one way to build the object β€” no need for step-by-step control
  • The builder adds no validation or logic over a constructor β€” it's unnecessary overhead

Key Takeaways​

  • Builder separates construction from representation β€” the same steps can produce different results
  • Fluent API (return this) makes the builder readable and chainable
  • A Director encapsulates common recipes β€” reuse standard configurations
  • In modern C#, record types with named arguments and with expressions can replace builders for simpler cases
  • StringBuilder is a real-world Builder you already use

Interview Questions​

Q: What's the difference between Builder and Factory Method? Factory Method focuses on which class to instantiate. Builder focuses on how to construct a complex object step-by-step. Factory returns a product in one call; Builder returns it after multiple steps.

Q: Do you always need a Director? No. Director is optional β€” use it only when you have standard recipes that should be reused. If every construction is unique, skip the Director and use the Builder directly.

Q: How does Builder relate to Immutable objects? Builder is the standard way to construct immutable objects with many fields. The object has no setters; the Builder collects state and produces the final object in Build().