StackPractices
intermediate Por Mathias Paulenko

Patrón Builder

Construye objetos complejos paso a paso. Patrón de diseño creacional para construcción de objetos legible y configurable.

Temas: design

Visión general

El Patrón Builder es un patrón de diseño creacional que te permite construir objetos complejos paso a paso. Separa la construcción de un objeto de su representación, permitiendo que el mismo proceso de construcción cree diferentes representaciones.

Brilla cuando un objeto tiene muchos parámetros opcionales, componentes anidados, o cuando quieres una API fluida y legible para la creación de objetos.

Cuándo usarlo

Usa el Patrón Builder cuando:

  • Un objeto tiene muchos parámetros de configuración opcionales o anidados
  • Quieres forzar una secuencia específica de construcción
  • El constructor tendría demasiados parámetros (problema del constructor telescópico)
  • Necesitas diferentes configuraciones del mismo tipo de objeto
  • Quieres un objeto inmutable construido desde un builder mutable

Solución

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)

# Uso
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); }
}

// Uso
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 + ")";
    }
}

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

Explicación

El Patrón Builder separa el ensamblaje del objeto en dos partes:

  • Builder: Acumula el estado de configuración y sabe cómo construir el objeto final
  • Producto (Pizza): El objeto inmutable o completamente configurado que devuelve build()

Al devolver self (o this) desde cada método de configuración, creas una interfaz fluida que se lee como una oración. Esto elimina constructores con docenas de parámetros.

flowchart diagram: Cliente

Variantes

VarianteCaso de usoCompromiso
Fluent BuilderConstrucción legible paso a pasoRequiere estado mutable del builder
Director + BuilderMúltiples secuencias de construcciónMás clases, pero recetas reutilizables
Static Factory BuilderPatrón Class.Builder() de JavaAPI limpia, pero acoplado al producto

Builder vs. alternativas más simples

No todo objeto necesita un builder. Usa esta comparación para decidir:

EnfoqueCuándo ganaPor qué no un Builder
Constructor simplePocos parámetros, todos obligatoriosEl encadenamiento añade código repetitivo innecesario
Constructor telescópicoUn conjunto pequeño de combinaciones fijas de opcionesExplota combinatoriamente; el orden de argumentos es propenso a errores
Objeto literal / spreadBolsa de opciones plana sin invariantesSin validación, valores por defecto ni invariantes en la construcción
Método factoryLógica de creación puntualNo compone opciones paso a paso

Lo que funciona

  • Devuelve self de cada método de paso para habilitar el encadenamiento de métodos
  • Haz el producto inmutable después de que se llama build()
  • Valida en build(), no en pasos individuales, para tener contexto completo de errores
  • Usa un Director cuando tienes configuraciones preestablecidas comunes (ej. pizzaDirector.makeMargherita())
  • Documenta pasos obligatorios vs opcionales para que quien llame sepa la configuración mínima válida

Errores comunes

  • Productos mutables: Permitir modificaciones después de build() anula el propósito
  • Validación faltante: Construir un objeto inválido porque se omitió la validación
  • Builders excesivamente complejos: Un builder para un objeto simple con 2 campos es excesivo
  • Fuga de estado: Reutilizar una instancia de builder después de build() sin reiniciar el estado
  • Olvidar devolver self: Romper la cadena fluida devolviendo None/void

Técnicas avanzadas

Builder con validación y valores por defecto

Añade validación en el método build() y proporciona valores por defecto razonables:

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"  # Valor por defecto

    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"Salsa inválida: {sauce}. Debe ser una de {valid_sauces}")
        self.sauce = sauce
        return self

    def build(self):
        if not self.size:
            raise ValueError("El tamaño es obligatorio")
        if self.size not in ["small", "medium", "large"]:
            raise ValueError(f"Tamaño inválido: {self.size}")
        return Pizza(self.size, self.cheese, self.pepperoni, self.mushrooms, self.sauce)

Builder para objetos anidados

Maneja grafos de objetos complejos con builders anidados:

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);
        }
    }
}

// Uso
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 con métodos de copia

Permite crear un builder desde un objeto existente para construir copias modificadas:

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;
  }
}

// Uso: modificar una pizza existente sin reescribir su configuración
const originalPizza = new PizzaBuilder("large").addCheese().build();
const modifiedPizza = originalPizza.toBuilder().addPepperoni().build();

Construcción condicional

Los builders combinan bien con lógica condicional que aplica opciones solo cuando hacen falta. Mantén el estado acumulado en campos privados para que los nombres de atributos nunca colisionen con los métodos:

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

# Uso con lógica condicional
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()

Construcción de múltiples tipos de producto

Un builder puede compartir configuración entre varios productos relacionados:

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);
    }
}

// Uso: construir múltiples formatos desde la misma configuración
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();

Construcción de objetos de configuración

Los objetos de configuración son uno de los usos reales más comunes del patrón Builder. Una configuración de base de datos o de cliente HTTP suele tener muchos campos opcionales con valores por defecto: exactamente lo que describe el problema del constructor telescópico:

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
    );
  }
}

// Los presets de producción y desarrollo comparten el mismo 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 de 10, SSL desactivado

Dos prácticas importan en los builders de configuración. Define presets por entorno (desarrollo, staging, producción) como funciones que devuelven un builder preconfigurado, de modo que cada entorno solo sobrescriba lo que difiere. Y nunca escribas secretos directamente en las llamadas del builder: cárgalos desde variables de entorno o un gestor de secretos antes de pasarlos.

Mejores prácticas

  1. Usa nombres de métodos descriptivos. Los nombres deberían indicar claramente qué configuran (ej. withTimeout() vs setTimeout()).

  2. Proporciona valores por defecto razonables. Reducen el número de llamadas obligatorias para los casos de uso comunes.

  3. Considera un constructor de copia. Permite crear un builder desde un objeto existente para soportar patrones de modificación.

  4. Maneja null de forma consistente. Decide si permitir valores nulos o lanzar excepciones, y sé consistente.

  5. Seguridad en hilos para builders compartidos. Si los builders se reutilizan entre hilos, asegúrate de que sean seguros o no se compartan.

  6. Mantén los builders enfocados. Un builder debería construir un solo tipo de objeto. No añadas lógica de construcción no relacionada.

Véase también

Preguntas frecuentes

¿Cuál es la diferencia entre Builder y Factory?

Factory decide qué clase instanciar; Builder ensambla un único objeto complejo paso a paso. Responden a preguntas distintas —"qué objeto crear" frente a "cómo ensamblarlo"— y a menudo aparecen juntos.

¿Debería usar un Builder para cada clase?

No. A partir de 3-4 parámetros opcionales, o cuando el orden de construcción empieza a importar, un builder se paga solo; por debajo de eso es mayormente código repetitivo.

¿Cómo se compara Builder con el patrón Prototype?

Prototype clona una instancia existente; Builder ensambla una nueva desde parámetros. Elige Prototype cuando ya tienes un objeto plantilla, Builder cuando construyes desde cero.

¿Debería usar un Builder para objetos inmutables?

Es uno de sus mejores usos: el builder carga el estado mutable durante la construcción y luego te entrega un producto inmutable.

¿Pueden los Builders usarse para objetos de configuración?

Es el caso clásico; mira el ejemplo de configuración en Técnicas avanzadas. Los ajustes opcionales, los valores por defecto y los presets por entorno salen ganando.

¿Cómo manejo parámetros opcionales en un Builder?

Dales valores por defecto en los campos o el constructor del builder, y comprueba dentro de build() que los obligatorios estén realmente establecidos.

¿Cómo manejo errores de validación en un Builder?

Recopila todos los problemas que encuentres en build() y lanza una sola excepción con la lista completa: quien llama lo arregla todo de una vez en lugar de error a error.

¿Cómo manejo el reinicio de estado del builder después de build()?

La respuesta barata es no reutilizar builders: una instancia nueva por objeto casi no cuesta nada. Si tienes que reutilizarlo, añade un reset() y documéntalo claramente.

¿Debería el Builder ser una clase separada o una clase estática anidada?

Las clases estáticas anidadas (la convención de Java) mantienen juntos builder y producto; elige una clase separada cuando un builder sirve a varios tipos de producto.

¿Puedo usar Builder con inyección de dependencias?

Claro: pasa los colaboradores por el constructor o los setters del builder como con cualquier otro objeto. Es útil cuando el producto necesita servicios durante la construcción.