patron-builder

Builder Pattern: Complex Objects Step by Step

  • 5 min

The Builder is a pattern that separates the step-by-step construction of a complex object. It is useful when a class supports many configurations or needs to validate the result before delivering it.

We have all encountered a “monster” class at some point. A class that requires so much configuration that instantiating it becomes a headache.

Imagine that we are modeling a Gaming PC. A computer has a processor, RAM, hard drive (or several), graphics card (or not), liquid cooling, RGB lights, WiFi card…

The Problem: The Telescoping Constructor

The first solution that comes to mind is to create a constructor that accepts everything.

// ❌ The Telescoping Constructor anti-pattern
var myPC = new Computer(
    "Intel i9",  // CPU
    32,          // RAM
    "SSD 1TB",   // Disk
    true,        // Has RGB?
    null,        // No liquid cooling
    "Nvidia 4090", // GPU
    false        // No WiFi
);
Copied!

Do you see the problem?

  1. It’s unreadable: What does that true in the fourth parameter mean? And the false at the end? You have to go to the class definition to find out.
  2. It’s rigid: If you want to create a PC without a graphics card, you have to pass null.
  3. It’s error-prone: If several parameters have the same type, you can swap them without the compiler detecting it.

That’s what the Builder Pattern is for. Its goal is to separate the construction of a complex object from its representation, allowing different representations to be created with the same construction process.

The Solution: Step-by-Step Construction

Instead of creating the object all at once, the Builder proposes creating it step by step. We extract the construction logic from the Computer class itself and move it to a separate class called ComputerBuilder.

Implementation in C# (Fluent Style)

In C# and modern development, we often simplify the pattern using a Fluent Interface (Fluent API). This allows us to chain methods, making the code read almost like natural language.

The Product

Our complex object. Notice that we no longer need a giant constructor.

public class Computer
{
    public string CPU { get; set; }
    public int RAM { get; set; }
    public string Storage { get; set; }
    public string GPU { get; set; }
    public bool HasRGBLights { get; set; }

    public override string ToString()
    {
        return $"PC: {CPU}, {RAM}GB RAM, {Storage}, GPU: {GPU ?? "Integrated"}, RGB: {HasRGBLights}";
    }
}
Copied!

The Builder (With Fluent Interface)

We create a class responsible for configuring the object step by step. Each method returns this (the builder itself), allowing chaining.

public class ComputerBuilder
{
    // We keep a reference to the object we are building
    private Computer _computer = new Computer();

    public ComputerBuilder WithCPU(string cpu)
    {
        _computer.CPU = cpu;
        return this; // Return the builder for chaining
    }

    public ComputerBuilder WithRAM(int gb)
    {
        _computer.RAM = gb;
        return this;
    }

    public ComputerBuilder WithStorage(string storage)
    {
        _computer.Storage = storage;
        return this;
    }

    public ComputerBuilder WithGPU(string gpu)
    {
        _computer.GPU = gpu;
        return this;
    }

    public ComputerBuilder WithRGB()
    {
        _computer.HasRGBLights = true;
        return this;
    }

    // The final step: Deliver the product
    public Computer Build()
    {
        if (_computer.RAM < 4)
            throw new InvalidOperationException("At least 4 GB of RAM are required");

        Computer result = _computer;
        _computer = new Computer(); // The builder is ready to be reused
        return result;
    }
}
Copied!

Using the Pattern (The Client)

Look at the difference from the constructor at the beginning. Now the code tells a story.

public class Program
{
    public static void Main()
    {
        // ✅ Clean and readable construction
        var mySuperPC = new ComputerBuilder()
            .WithCPU("Intel i9")
            .WithRAM(32)
            .WithStorage("NVMe 2TB")
            .WithGPU("RTX 4090")
            .WithRGB() // Optional method
            .Build();

        Console.WriteLine(mySuperPC);
    }
}
Copied!

This technique is what you constantly use in .NET, for example, when configuring the WebHostBuilder in ASP.NET Core or when adding services with services.AddMvc().AddJsonOptions(...).

The Director (Optional)

Sometimes we want to encapsulate known “recipes”. If our store sells a “Basic PC” and a “Gamer PC”, we don’t want to repeat the Builder steps every time.

We can create a Director class (or simply static factory methods) that uses the Builder for us.

public class ComputerDirector
{
    public Computer BuildBasicPC()
    {
        return new ComputerBuilder()
            .WithCPU("Intel i3")
            .WithRAM(8)
            .WithStorage("HDD 500GB")
            .Build();
    }

    public Computer BuildGamerPC()
    {
        return new ComputerBuilder()
            .WithCPU("Ryzen 9")
            .WithRAM(32)
            .WithStorage("SSD 1TB")
            .WithGPU("RTX 3080")
            .WithRGB()
            .Build();
    }
}
Copied!

Advantages and Disadvantages

AdvantagesDisadvantages
Readability: The client code is self-explanatory.Verbosity: Requires creating extra classes (the Builder).
Immutability: You can make Build() return an immutable object, ensuring no one modifies it after creation.Complexity: For classes with 3 parameters, it is over-engineering.
Flexibility: Allows building different representations using the same process.

Builder vs Factory

It’s easy to confuse them because both create objects, but the difference is key:

  • Factory Method / Abstract Factory: Create the object all at once (“here, your car”). They are good when creation doesn’t require many steps or external configuration.
  • Builder: Creates the object step by step (“Put the engine, now the wheels, now paint it…”). It is ideal when the object has many optional parts or the creation process is complex.