StackPractices
intermediate By Mathias Paulenko

Builder Pattern

Construct complex objects step by step. A creational design pattern for readable, configurable object construction.

Topics: design

Overview

The Builder Pattern is a creational design pattern that lets you construct complex objects step by step. It separates the construction of an object from its representation, allowing the same construction process to create different representations.

It shines when an object has many optional parameters, nested components, or when you want a fluent, readable API for object creation.

When to Use

Use the Builder Pattern when:

  • An object has many optional or nested configuration parameters
  • You want to enforce a specific construction sequence
  • The constructor would have too many parameters (telescoping constructor problem)
  • You need different configurations of the same object type
  • You want an immutable object built from a mutable builder

Solution

Python

class Pizza:
    def __init__(self, size, cheese=False, pepperoni=False, mushrooms=False):
        self.size = size
        self.cheese = cheese
        self.pepperoni = pepperoni
        self.mushrooms = mushrooms

    def __str__(self):
        toppings = []
        if self.cheese: toppings.append("cheese")
        if self.pepperoni: toppings.append("pepperoni")
        if self.mushrooms: toppings.append("mushrooms")
        return f"Pizza({self.size}, {', '.join(toppings) or 'plain'})"

class PizzaBuilder:
    def __init__(self, size):
        self.size = size
        self.cheese = False
        self.pepperoni = False
        self.mushrooms = False

    def add_cheese(self):
        self.cheese = True
        return self

    def add_pepperoni(self):
        self.pepperoni = True
        return self

    def build(self):
        return Pizza(self.size, self.cheese, self.pepperoni, self.mushrooms)

# Usage
pizza = PizzaBuilder("large").add_cheese().add_pepperoni().build()
print(pizza)  # Pizza(large, cheese, pepperoni)

JavaScript

class Pizza {
  constructor(size, cheese, pepperoni, mushrooms) {
    this.size = size;
    this.cheese = cheese;
    this.pepperoni = pepperoni;
    this.mushrooms = mushrooms;
  }

  toString() {
    const toppings = [
      this.cheese && "cheese",
      this.pepperoni && "pepperoni",
      this.mushrooms && "mushrooms",
    ].filter(Boolean);
    return `Pizza(${this.size}, ${toppings.join(", ") || "plain"})`;
  }
}

class PizzaBuilder {
  constructor(size) {
    this.size = size;
    this.cheese = false;
    this.pepperoni = false;
    this.mushrooms = false;
  }

  addCheese() { this.cheese = true; return this; }
  addPepperoni() { this.pepperoni = true; return this; }
  addMushrooms() { this.mushrooms = true; return this; }
  build() { return new Pizza(this.size, this.cheese, this.pepperoni, this.mushrooms); }
}

// Usage
const pizza = new PizzaBuilder("large").addCheese().addPepperoni().build();
console.log(pizza.toString()); // Pizza(large, cheese, pepperoni)

Java

public class Pizza {
    private final String size;
    private final boolean cheese;
    private final boolean pepperoni;
    private final boolean mushrooms;

    private Pizza(Builder builder) {
        this.size = builder.size;
        this.cheese = builder.cheese;
        this.pepperoni = builder.pepperoni;
        this.mushrooms = builder.mushrooms;
    }

    public static class Builder {
        private final String size;
        private boolean cheese = false;
        private boolean pepperoni = false;
        private boolean mushrooms = false;

        public Builder(String size) { this.size = size; }
        public Builder cheese() { this.cheese = true; return this; }
        public Builder pepperoni() { this.pepperoni = true; return this; }
        public Builder mushrooms() { this.mushrooms = true; return this; }
        public Pizza build() { return new Pizza(this); }
    }

    @Override
    public String toString() {
        return "Pizza(" + size + ", cheese=" + cheese + ", pepperoni=" + pepperoni + ")";
    }
}

// Usage
Pizza pizza = new Pizza.Builder("large").cheese().pepperoni().build();
System.out.println(pizza);

Explanation

The Builder Pattern separates object assembly into two parts:

  • Builder: Accumulates configuration state and knows how to construct the final object
  • Product (Pizza): The immutable or fully-configured object returned by build()

By returning self (or this) from each configuration method, you create a fluent interface that reads like a sentence. This eliminates constructors with dozens of parameters.

flowchart diagram: Client

Variants

VariantUse CaseTrade-off
Fluent BuilderReadable step-by-step constructionRequires mutable builder state
Director + BuilderPredefined construction sequencesMore classes, but reusable recipes
Static Factory BuilderJava’s Class.Builder() patternClean API, but tightly coupled to the product

Builder vs. Simpler Alternatives

Not every object needs a builder. Use this comparison to decide:

ApproachWhen it winsWhy not a Builder
Plain constructorFew parameters, all requiredChaining adds boilerplate you don’t need
Telescoping constructorA small set of fixed option combinationsExplodes combinatorially; argument order is error-prone
Object literal / spreadFlat options bag with no invariantsNo validation, defaults, or invariants enforced at construction
Factory methodOne-off creation logicDoesn’t compose options step by step

What Works

  • Return self from each step method to enable method chaining
  • Make the product immutable after build() is called
  • Validate in build(), not in individual steps, for complete error context
  • Use a Director when you need common preset configurations (e.g., pizzaDirector.makeMargherita())
  • Document required vs. optional steps so callers know the minimum valid configuration

Common Mistakes

  • Mutable products: Allowing modifications after build() defeats the purpose
  • Missing validation: Building an invalid object because validation was skipped
  • Overly complex builders: A builder for a simple object with 2 fields is overkill
  • State leakage: Reusing a builder instance after build() without resetting state
  • Forgetting return self: Breaking the fluent chain by returning None/void

Advanced Techniques

Builder with validation and defaults

Add validation in the build() method and provide sensible defaults:

class Pizza:
    def __init__(self, size, cheese=False, pepperoni=False, mushrooms=False, sauce="tomato"):
        self.size = size
        self.cheese = cheese
        self.pepperoni = pepperoni
        self.mushrooms = mushrooms
        self.sauce = sauce

class PizzaBuilder:
    def __init__(self, size="medium"):
        self.size = size
        self.cheese = False
        self.pepperoni = False
        self.mushrooms = False
        self.sauce = "tomato"  # Default value

    def add_cheese(self):
        self.cheese = True
        return self

    def add_pepperoni(self):
        self.pepperoni = True
        return self

    def set_sauce(self, sauce):
        valid_sauces = ["tomato", "bbq", "pesto"]
        if sauce not in valid_sauces:
            raise ValueError(f"Invalid sauce: {sauce}. Must be one of {valid_sauces}")
        self.sauce = sauce
        return self

    def build(self):
        if not self.size:
            raise ValueError("Size is required")
        if self.size not in ["small", "medium", "large"]:
            raise ValueError(f"Invalid size: {self.size}")
        return Pizza(self.size, self.cheese, self.pepperoni, self.mushrooms, self.sauce)

Builder for nested objects

Handle complex object graphs with nested builders:

public class House {
    private final String address;
    private final Kitchen kitchen;
    private final List<Room> rooms;

    private House(Builder builder) {
        this.address = builder.address;
        this.kitchen = builder.kitchen;
        this.rooms = builder.rooms;
    }

    public static class Builder {
        private String address;
        private Kitchen kitchen;
        private List<Room> rooms = new ArrayList<>();

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

        public Builder kitchen(Kitchen.Builder kitchenBuilder) {
            this.kitchen = kitchenBuilder.build();
            return this;
        }

        public Builder addRoom(Room.Builder roomBuilder) {
            this.rooms.add(roomBuilder.build());
            return this;
        }

        public House build() {
            return new House(this);
        }
    }
}

// Usage
House house = new House.Builder()
    .address("123 Main St")
    .kitchen(new Kitchen.Builder().size("large").build())
    .addRoom(new Room.Builder().type("bedroom").size("medium").build())
    .addRoom(new Room.Builder().type("bathroom").size("small").build())
    .build();

Builder with copy methods

Support creating a builder from an existing object to build modified copies:

class Pizza {
  constructor(size, cheese, pepperoni, mushrooms) {
    this.size = size;
    this.cheese = cheese;
    this.pepperoni = pepperoni;
    this.mushrooms = mushrooms;
  }

  toBuilder() {
    const builder = new PizzaBuilder(this.size);
    if (this.cheese) builder.addCheese();
    if (this.pepperoni) builder.addPepperoni();
    if (this.mushrooms) builder.addMushrooms();
    return builder;
  }
}

// Usage: modify an existing pizza without rewriting its config
const originalPizza = new PizzaBuilder("large").addCheese().build();
const modifiedPizza = originalPizza.toBuilder().addPepperoni().build();

Conditional construction

Builders pair well with conditional logic that applies options only when needed. Keep accumulated state in private fields so attribute names never collide with method names:

class QueryBuilder:
    def __init__(self):
        self.conditions = []
        self.joins = []
        self._order_by = None
        self._limit = None

    def where(self, condition):
        self.conditions.append(condition)
        return self

    def join(self, table, on_clause):
        self.joins.append((table, on_clause))
        return self

    def order_by(self, field, direction="ASC"):
        self._order_by = (field, direction)
        return self

    def limit(self, count):
        self._limit = count
        return self

    def build(self):
        query = "SELECT * FROM items"
        if self.joins:
            for table, on_clause in self.joins:
                query += f" JOIN {table} ON {on_clause}"
        if self.conditions:
            query += " WHERE " + " AND ".join(self.conditions)
        if self._order_by:
            query += f" ORDER BY {self._order_by[0]} {self._order_by[1]}"
        if self._limit:
            query += f" LIMIT {self._limit}"
        return query

# Usage with conditional logic
builder = QueryBuilder()
builder.join("users", "items.user_id = users.id")

if include_active_only:
    builder.where("users.active = true")

if sort_by_date:
    builder.order_by("created_at", "DESC")

if max_results:
    builder.limit(max_results)

query = builder.build()

Building multiple product types

One builder can share configuration across several related products:

public class ReportBuilder {
    private String title;
    private String author;
    private String date;
    private List<Section> sections = new ArrayList<>();

    public ReportBuilder title(String title) {
        this.title = title;
        return this;
    }

    public ReportBuilder author(String author) {
        this.author = author;
        return this;
    }

    public ReportBuilder date(String date) {
        this.date = date;
        return this;
    }

    public ReportBuilder addSection(Section section) {
        this.sections.add(section);
        return this;
    }

    public PDFReport buildPDF() {
        return new PDFReport(title, author, date, sections);
    }

    public HTMLReport buildHTML() {
        return new HTMLReport(title, author, date, sections);
    }

    public MarkdownReport buildMarkdown() {
        return new MarkdownReport(title, author, date, sections);
    }
}

// Usage: build multiple formats from the same configuration
ReportBuilder builder = new ReportBuilder()
    .title("Q4 Sales Report")
    .author("John Doe")
    .date("2026-01-15")
    .addSection(new Section("Executive Summary", "..."))
    .addSection(new Section("Data Analysis", "..."));

PDFReport pdf = builder.buildPDF();
HTMLReport html = builder.buildHTML();
MarkdownReport markdown = builder.buildMarkdown();

Building configuration objects

Configuration objects are one of the most common real-world uses of the Builder pattern. A database or HTTP client config typically has many optional fields with defaults — exactly what the telescoping-constructor problem describes:

class DatabaseConfig {
  constructor(
    public readonly host: string,
    public readonly port: number,
    public readonly database: string,
    public readonly username: string,
    public readonly password: string,
    public readonly poolSize: number,
    public readonly timeout: number,
    public readonly ssl: boolean,
    public readonly retryAttempts: number
  ) {}
}

class DatabaseConfigBuilder {
  private host: string = 'localhost';
  private port: number = 5432;
  private database!: string;
  private username!: string;
  private password!: string;
  private poolSize: number = 10;
  private timeout: number = 5000;
  private ssl: boolean = false;
  private retryAttempts: number = 3;

  setHost(host: string): this { this.host = host; return this; }
  setPort(port: number): this { this.port = port; return this; }
  setDatabase(database: string): this { this.database = database; return this; }
  setCredentials(username: string, password: string): this {
    this.username = username;
    this.password = password;
    return this;
  }
  setPoolSize(size: number): this { this.poolSize = size; return this; }
  setTimeout(timeout: number): this { this.timeout = timeout; return this; }
  enableSsl(): this { this.ssl = true; return this; }
  setRetryAttempts(attempts: number): this { this.retryAttempts = attempts; return this; }

  build(): DatabaseConfig {
    if (!this.database) throw new Error('Database name is required');
    if (!this.username || !this.password) {
      throw new Error('Credentials are required');
    }
    return new DatabaseConfig(
      this.host, this.port, this.database, this.username,
      this.password, this.poolSize, this.timeout, this.ssl, this.retryAttempts
    );
  }
}

// Production and development presets share the same builder
const prodConfig = new DatabaseConfigBuilder()
  .setHost('db.production.com')
  .setDatabase('myapp')
  .setCredentials('user', 'secret')
  .setPoolSize(50)
  .enableSsl()
  .setTimeout(10000)
  .build();

const devConfig = new DatabaseConfigBuilder()
  .setDatabase('myapp_dev')
  .setCredentials('dev', 'dev')
  .build(); // localhost:5432, pool of 10, SSL off

Two practices matter for configuration builders. Define environment presets (development, staging, production) as functions that return a pre-configured builder, so each environment only overrides what differs. And never hardcode secrets in builder calls — load them from environment variables or a secrets manager before passing them in.

Best Practices

  1. Use descriptive method names. Method names should clearly indicate what they configure (e.g., withTimeout() vs setTimeout()).

  2. Provide sensible defaults. Default values reduce the number of required method calls for common use cases.

  3. Consider a copy constructor. Allow creating a builder from an existing object to support modification patterns.

  4. Handle null gracefully. Decide whether to allow null values or throw exceptions, and be consistent.

  5. Thread-safety for shared builders. If builders are reused across threads, ensure they’re either thread-safe or not shared.

  6. Keep builders focused. A builder should construct one type of object. Don’t add unrelated construction logic.

See Also

Frequently Asked Questions

What is the difference between Builder and Factory?

Factory decides which class to instantiate; Builder assembles one complex object step by step. They answer different questions — "which object to create" vs "how to assemble it" — and often appear together.

Should I use a Builder for every class?

No. Past 3-4 optional parameters, or when construction order starts to matter, a builder pays for itself; below that it's mostly boilerplate.

How does Builder compare to the Prototype pattern?

Prototype clones an existing instance; Builder assembles a new one from parameters. Pick Prototype when a template object already exists, Builder when you're constructing from scratch.

Should I use a Builder for immutable objects?

It's one of the best fits — the builder carries the mutable state during construction, then hands you an immutable product.

Can Builders be used for configuration objects?

That's the classic use; see the configuration example in Advanced Techniques above. Optional settings, defaults, and per-environment presets all benefit from it.

How do I handle optional parameters in a Builder?

Give them defaults in the builder's fields or constructor, and check inside build() that the required ones were actually set.

How do I handle validation errors in a Builder?

Collect every problem you find in build() and throw one exception listing them all — callers fix everything in a single pass instead of one error at a time.

How do I handle builder state reset after build()?

The cheap answer is not reusing builders at all — a fresh instance per object costs almost nothing. If you must reuse one, add a reset() method and document it clearly.

Should the Builder be a separate class or a nested static class?

Nested static classes (the Java convention) keep builder and product together; go with a separate class when one builder serves several product types.

Can I use Builder with dependency injection?

Sure — pass collaborators through the builder's constructor or setters like you would for any other object. It's handy when the product needs services during construction.