Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Builder Pattern

The Builder pattern is a creational design pattern that provides a way to construct complex objects step by step. It's particularly useful when you need to create objects with many optional parameters or when the construction process itself is complex and needs to be separated from the object's representation.

Core Concept

The Builder pattern solves the "telescoping constructor" problem where you'd otherwise need multiple constructors with different parameter combinations. Instead, it provides a fluent interface for building objects incrementally.

Java Implementation (Classical OOP)

Let's start with a traditional Java example:

// Product class
class Computer {
    private final String cpu;
    private final String ram;
    private final String storage;
    private final boolean hasGraphicsCard;
    private final boolean hasWifi;
    private final String operatingSystem;

    // Private constructor - can only be called by Builder
    private Computer(Builder builder) {
        this.cpu = builder.cpu;
        this.ram = builder.ram;
        this.storage = builder.storage;
        this.hasGraphicsCard = builder.hasGraphicsCard;
        this.hasWifi = builder.hasWifi;
        this.operatingSystem = builder.operatingSystem;
    }

    // Getters
    public String getCpu() { return cpu; }
    public String getRam() { return ram; }
    public String getStorage() { return storage; }
    public boolean hasGraphicsCard() { return hasGraphicsCard; }
    public boolean hasWifi() { return hasWifi; }
    public String getOperatingSystem() { return operatingSystem; }

    @Override
    public String toString() {
        return String.format(
            "Computer{cpu='%s', ram='%s', storage='%s', graphics=%b, wifi=%b, os='%s'}",
            cpu, ram, storage, hasGraphicsCard, hasWifi, operatingSystem
        );
    }

    // Static nested Builder class
    public static class Builder {
        // Required parameters
        private final String cpu;
        private final String ram;

        // Optional parameters with defaults
        private String storage = "256GB SSD";
        private boolean hasGraphicsCard = false;
        private boolean hasWifi = true;
        private String operatingSystem = "Windows 11";

        // Builder constructor with required parameters
        public Builder(String cpu, String ram) {
            this.cpu = cpu;
            this.ram = ram;
        }

        // Fluent interface methods
        public Builder storage(String storage) {
            this.storage = storage;
            return this;
        }

        public Builder withGraphicsCard() {
            this.hasGraphicsCard = true;
            return this;
        }

        public Builder withoutWifi() {
            this.hasWifi = false;
            return this;
        }

        public Builder operatingSystem(String os) {
            this.operatingSystem = os;
            return this;
        }

        // Build method creates the final object
        public Computer build() {
            return new Computer(this);
        }
    }
}

// Usage example
public class BuilderPatternDemo {
    public static void main(String[] args) {
        // Basic computer
        Computer basicComputer = new Computer.Builder("Intel i5", "8GB")
            .build();

        // Gaming computer
        Computer gamingComputer = new Computer.Builder("Intel i9", "32GB")
            .storage("1TB NVMe SSD")
            .withGraphicsCard()
            .operatingSystem("Windows 11 Pro")
            .build();

        // Server computer
        Computer serverComputer = new Computer.Builder("AMD Threadripper", "128GB")
            .storage("2TB SSD RAID")
            .withoutWifi()
            .operatingSystem("Ubuntu Server")
            .build();

        System.out.println(basicComputer);
        System.out.println(gamingComputer);
        System.out.println(serverComputer);
    }
}

Rust Implementation

Now let's see how we implement the Builder pattern in Rust. Rust's approach leverages its ownership system and type safety features:

// Product struct
#[derive(Debug, Clone)]
pub struct Computer {
    cpu: String,
    ram: String,
    storage: String,
    has_graphics_card: bool,
    has_wifi: bool,
    operating_system: String,
}

impl Computer {
    // Associated function to create a new builder
    pub fn builder(cpu: impl Into<String>, ram: impl Into<String>) -> ComputerBuilder {
        ComputerBuilder::new(cpu, ram)
    }

    // Getters
    pub fn cpu(&self) -> &str { &self.cpu }
    pub fn ram(&self) -> &str { &self.ram }
    pub fn storage(&self) -> &str { &self.storage }
    pub fn has_graphics_card(&self) -> bool { self.has_graphics_card }
    pub fn has_wifi(&self) -> bool { self.has_wifi }
    pub fn operating_system(&self) -> &str { &self.operating_system }
}

// Builder struct
pub struct ComputerBuilder {
    cpu: String,
    ram: String,
    storage: String,
    has_graphics_card: bool,
    has_wifi: bool,
    operating_system: String,
}

impl ComputerBuilder {
    // Constructor with required parameters
    pub fn new(cpu: impl Into<String>, ram: impl Into<String>) -> Self {
        Self {
            cpu: cpu.into(),
            ram: ram.into(),
            storage: "256GB SSD".to_string(),
            has_graphics_card: false,
            has_wifi: true,
            operating_system: "Windows 11".to_string(),
        }
    }

    // Fluent interface methods - each takes ownership and returns Self
    pub fn storage(mut self, storage: impl Into<String>) -> Self {
        self.storage = storage.into();
        self
    }

    pub fn with_graphics_card(mut self) -> Self {
        self.has_graphics_card = true;
        self
    }

    pub fn without_wifi(mut self) -> Self {
        self.has_wifi = false;
        self
    }

    pub fn operating_system(mut self, os: impl Into<String>) -> Self {
        self.operating_system = os.into();
        self
    }

    // Build method consumes the builder and returns the final product
    pub fn build(self) -> Computer {
        Computer {
            cpu: self.cpu,
            ram: self.ram,
            storage: self.storage,
            has_graphics_card: self.has_graphics_card,
            has_wifi: self.has_wifi,
            operating_system: self.operating_system,
        }
    }
}

// Advanced: Type-state pattern for compile-time validation
// This ensures required fields are set at compile time

pub struct CpuSet;
pub struct RamSet;
pub struct NotSet;

pub struct TypedComputerBuilder<CpuState = NotSet, RamState = NotSet> {
    cpu: Option<String>,
    ram: Option<String>,
    storage: String,
    has_graphics_card: bool,
    has_wifi: bool,
    operating_system: String,
    _cpu_state: std::marker::PhantomData<CpuState>,
    _ram_state: std::marker::PhantomData<RamState>,
}

impl TypedComputerBuilder<NotSet, NotSet> {
    pub fn new() -> Self {
        Self {
            cpu: None,
            ram: None,
            storage: "256GB SSD".to_string(),
            has_graphics_card: false,
            has_wifi: true,
            operating_system: "Windows 11".to_string(),
            _cpu_state: std::marker::PhantomData,
            _ram_state: std::marker::PhantomData,
        }
    }

    pub fn cpu(self, cpu: impl Into<String>) -> TypedComputerBuilder<CpuSet, NotSet> {
        TypedComputerBuilder {
            cpu: Some(cpu.into()),
            ram: self.ram,
            storage: self.storage,
            has_graphics_card: self.has_graphics_card,
            has_wifi: self.has_wifi,
            operating_system: self.operating_system,
            _cpu_state: std::marker::PhantomData,
            _ram_state: std::marker::PhantomData,
        }
    }
}

impl TypedComputerBuilder<CpuSet, NotSet> {
    pub fn ram(self, ram: impl Into<String>) -> TypedComputerBuilder<CpuSet, RamSet> {
        TypedComputerBuilder {
            cpu: self.cpu,
            ram: Some(ram.into()),
            storage: self.storage,
            has_graphics_card: self.has_graphics_card,
            has_wifi: self.has_wifi,
            operating_system: self.operating_system,
            _cpu_state: std::marker::PhantomData,
            _ram_state: std::marker::PhantomData,
        }
    }
}

impl<CpuState, RamState> TypedComputerBuilder<CpuState, RamState> {
    pub fn storage(mut self, storage: impl Into<String>) -> Self {
        self.storage = storage.into();
        self
    }

    pub fn with_graphics_card(mut self) -> Self {
        self.has_graphics_card = true;
        self
    }

    pub fn without_wifi(mut self) -> Self {
        self.has_wifi = false;
        self
    }

    pub fn operating_system(mut self, os: impl Into<String>) -> Self {
        self.operating_system = os.into();
        self
    }
}

// Only allow building when all required fields are set
impl TypedComputerBuilder<CpuSet, RamSet> {
    pub fn build(self) -> Computer {
        Computer {
            cpu: self.cpu.unwrap(),
            ram: self.ram.unwrap(),
            storage: self.storage,
            has_graphics_card: self.has_graphics_card,
            has_wifi: self.has_wifi,
            operating_system: self.operating_system,
        }
    }
}

fn main() {
    // Simple builder usage
    let basic_computer = Computer::builder("Intel i5", "8GB")
        .build();

    let gaming_computer = Computer::builder("Intel i9", "32GB")
        .storage("1TB NVMe SSD")
        .with_graphics_card()
        .operating_system("Windows 11 Pro")
        .build();

    let server_computer = Computer::builder("AMD Threadripper", "128GB")
        .storage("2TB SSD RAID")
        .without_wifi()
        .operating_system("Ubuntu Server")
        .build();

    println!("{:?}", basic_computer);
    println!("{:?}", gaming_computer);
    println!("{:?}", server_computer);

    // Type-state builder usage (compile-time safety)
    let typed_computer = TypedComputerBuilder::new()
        .cpu("Intel i7")
        .ram("16GB")
        .storage("512GB SSD")
        .with_graphics_card()
        .build();

    println!("{:?}", typed_computer);

    // This would cause a compile error:
    // let incomplete = TypedComputerBuilder::new()
    //     .cpu("Intel i7")
    //     .build(); // Error: ram not set!
}

Key Differences Between Java and Rust Implementations

1. Ownership and Move Semantics

  • Java: Uses return this to enable method chaining, keeping the same object reference
  • Rust: Methods take self by value and return Self, using move semantics for the fluent interface

2. Memory Management

  • Java: Relies on garbage collection; the builder object persists until GC
  • Rust: The builder is consumed when build() is called, preventing use-after-build errors at compile time

3. Type Safety

  • Java: Runtime validation if required fields aren't set
  • Rust: Can use the type-state pattern to enforce required fields at compile time

4. String Handling

  • Java: Uses String directly with implicit conversions
  • Rust: Uses impl Into<String> for flexible string input (&str, String, etc.)

5. Null Safety

  • Java: Potential for null pointer exceptions
  • Rust: No null values; uses Option<T> when needed

When to Use the Builder Pattern

The Builder pattern is ideal when:

  • Objects have many optional parameters
  • Object construction is complex or requires validation
  • You want to enforce immutability in the final object
  • The construction process should be independent of the object's representation

The Rust implementation particularly shines with its compile-time safety guarantees and zero-cost abstractions, making it both safer and potentially more performant than traditional OOP implementations.


Core Rust concepts introduced

Building on the concepts from the NewType pattern, the Builder pattern introduces several new Rust concepts:

1. Move Semantics in Method Chaining

#![allow(unused)]
fn main() {
pub fn storage(mut self, storage: impl Into<String>) -> Self {
    self.storage = storage.into();
    self
}
}
  • Taking self by value: Each builder method consumes the builder and returns a new one
  • Move semantics: The builder is moved through the chain, preventing accidental reuse
  • Different from references: Unlike Java's return this, Rust actually moves the data
  • mut self: Explicitly declares that the method can modify the moved value

2. Trait Bounds and Generic Programming

#![allow(unused)]
fn main() {
pub fn new(cpu: impl Into<String>, ram: impl Into<String>) -> Self
}
  • impl Into<String>: A trait bound allowing any type convertible to String
  • Flexible input: Accepts &str, String, Cow<str>, etc. without explicit conversion
  • Compile-time resolution: More efficient than Java's method overloading
  • Generic constraints: Specify what capabilities a type parameter must have

3. Advanced: PhantomData and Type-State Pattern

#![allow(unused)]
fn main() {
pub struct TypedComputerBuilder<CpuState = NotSet, RamState = NotSet> {
    _cpu_state: std::marker::PhantomData<CpuState>,
    _ram_state: std::marker::PhantomData<RamState>,
}
}
  • Generic type parameters: Track builder state at compile time
  • Default type parameters: = NotSet provides defaults for generics
  • PhantomData: Zero-sized type that carries type information without runtime cost
  • Compile-time state tracking: Prevents calling build() before required fields are set

4. Type-Level Programming

#![allow(unused)]
fn main() {
impl TypedComputerBuilder<NotSet, NotSet> {
    pub fn cpu(self) -> TypedComputerBuilder<CpuSet, NotSet> { ... }
}

impl TypedComputerBuilder<CpuSet, NotSet> {
    pub fn ram(self) -> TypedComputerBuilder<CpuSet, RamSet> { ... }
}

// Only this implementation has build()
impl TypedComputerBuilder<CpuSet, RamSet> {
    pub fn build(self) -> Computer { ... }
}
}
  • Different implementations for different type states: Compiler selects the right impl block
  • Encoding logic in types: Use the type system to enforce correct usage patterns
  • Compile-time method availability: Some methods only exist for certain type states
  • State transitions: Methods transform types from one state to another

5. Compile-Time Guarantees vs Runtime Checks

#![allow(unused)]
fn main() {
pub fn build(self) -> Computer {  // Returns Computer, not Option<Computer>
    Computer {
        cpu: self.cpu.unwrap(),  // Safe because type system guarantees this exists
        // ...
    }
}
}
  • Type-safe unwrapping: unwrap() is safe when types guarantee the value exists
  • Eliminating runtime errors: Move error checking from runtime to compile time
  • Performance benefit: No need for runtime validation when types encode correctness

6. Use-After-Move Prevention

#![allow(unused)]
fn main() {
let builder = ComputerBuilder::new("Intel i7", "16GB");
let computer = builder.build();  // builder is consumed here
// builder.storage("1TB SSD");  // ❌ Compile error: builder was moved
}
  • Consuming methods: Methods that take self by value consume the object
  • Preventing reuse bugs: Can't accidentally use a builder after calling build()
  • Memory safety: Eliminates entire classes of bugs at compile time

7. Zero-Cost State Machines

The type-state pattern demonstrates:

  • Compile-time state machines: State transitions verified at compile time
  • Zero runtime overhead: All type-state information is erased during compilation
  • API safety: Impossible to call methods in wrong order or wrong state
  • Documentation through types: The type system serves as living documentation

These advanced concepts showcase Rust's unique ability to encode complex invariants in the type system, providing both safety and performance benefits that are difficult to achieve in traditional object-oriented languages.