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 thisto enable method chaining, keeping the same object reference - Rust: Methods take
selfby value and returnSelf, 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
Stringdirectly 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
selfby 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 toString- 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:
= NotSetprovides 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
selfby 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.