Las dos lecciones de fábricas resolvieron qué clase instanciar. Builder ataca un problema distinto: la clase está clara —es un Pedido, no hay duda—, pero montarlo es un suplicio: muchos datos, la mayoría opcionales, combinaciones inválidas, y un constructor que ha ido engordando hasta volverse ilegible. En esta lección resolveremos el constructor telescópico de Pedido que arrastramos desde la introducción del módulo, veremos la estructura GoF original (con su Director) y la variante fluida moderna que domina el Java actual, y aprenderemos a usar build() como frontera de validación e inmutabilidad.

Contenido

  1. El problema en PideYa: el constructor telescópico
  2. Intención del patrón
  3. Estructura GoF: Builder y Director
  4. La variante fluida moderna (la que usarás el 95% de las veces)
  5. Implementación Java completa del Pedido.Builder
  6. Inmutabilidad y validación en build()
  7. El Director en la variante moderna
  8. Cuándo usarlo y cuándo no
  9. Errores comunes, ejercicios y conclusión

El problema en PideYa: el constructor telescópico

Un Pedido de PideYa necesita, para nacer: cliente y restaurante (siempre), sus líneas (al menos una), dirección de entrega (salvo recogida en local), y opcionalmente franja horaria, instrucciones para el repartidor ("portal 3, no llamar al timbre"), código promocional, cubiertos sí/no y teléfono de contacto alternativo. La evolución típica del constructor a lo largo de los sprints:

// Sprint 3
public Pedido(Cliente cliente, Restaurante restaurante, List<LineaPedido> lineas) { ... }

// Sprint 7: entrega a domicilio
public Pedido(Cliente cliente, Restaurante restaurante, List<LineaPedido> lineas,
              Direccion direccionEntrega) { ... }

// Sprint 12: la criatura ya asusta
public Pedido(Cliente cliente, Restaurante restaurante, List<LineaPedido> lineas,
              Direccion direccionEntrega, FranjaHoraria franja, String instrucciones,
              String codigoPromocional, boolean incluirCubiertos, String telefonoContacto) { ... }

A esto se le llama constructor telescópico: para cubrir las combinaciones de opcionales se acaba con una escalera de constructores solapados (o uno gigante al que se llama con nulls). Las llamadas resultantes son criptogramas:

Pedido p = new Pedido(cliente, rest, lineas, direccion, null, null, "PIZZA10", true, null);
// ¿Qué es "PIZZA10"? ¿Qué significa true? ¿Cuál de los null era la franja?

Los dolores, enumerados:

  • Ilegibilidad: los argumentos posicionales no dicen qué son; los booleanos y nulls son minas.
  • Fragilidad: dos parámetros contiguos del mismo tipo (instrucciones y codigoPromocional, ambos String) se intercambian sin que el compilador diga nada.
  • Explosión de combinaciones: con 6 opcionales harían falta hasta 64 constructores para cubrir todas las variantes con firmas limpias.
  • Sin sitio para validar: reglas como "domicilio requiere dirección" o "máximo un código promocional" acaban repartidas por los llamantes.
  • La alternativa JavaBeans (constructor vacío + setters) es aún peor: el objeto atraviesa estados intermedios inválidos, cualquiera puede mutarlo después, e imposibilita la inmutabilidad.

Intención del patrón

Intención (GoF): separar la construcción de un objeto complejo de su representación, de modo que el mismo proceso de construcción pueda crear representaciones diferentes.

La idea: en lugar de entregar todos los datos de golpe a un constructor, se acumulan paso a paso en un objeto intermedio —el builder— que sabe recibirlos con nombre, en cualquier orden, y que al final fabrica el producto de una pieza, validado y completo. La segunda mitad de la intención GoF ("el mismo proceso, representaciones diferentes") pertenece a la variante clásica con Director; la variante moderna se queda sobre todo con la primera mitad. Veamos ambas.

Estructura GoF: Builder y Director

classDiagram
    class Director {
        -builder : Builder
        +construir()
    }
    class Builder {
        <<interface>>
        +ponerBase()
        +ponerLineas()
        +ponerEntrega()
        +obtenerResultado()
    }
    class BuilderConcreto1 {
        +ponerBase()
        +ponerLineas()
        +ponerEntrega()
        +obtenerResultado() Producto1
    }
    class BuilderConcreto2 {
        +ponerBase()
        +ponerLineas()
        +ponerEntrega()
        +obtenerResultado() Producto2
    }
    Director o--> Builder : dirige
    Builder <|.. BuilderConcreto1
    Builder <|.. BuilderConcreto2
    BuilderConcreto1 ..> Producto1 : crea
    BuilderConcreto2 ..> Producto2 : crea
Rol GoF Papel
Builder Interfaz con las operaciones de construcción parciales
ConcreteBuilder Implementa los pasos y sabe montar UNA representación; expone obtenerResultado()
Director Conoce la receta (qué pasos y en qué orden), sin saber qué representación sale
Product El objeto complejo resultante

La clave del reparto: el Director posee el algoritmo de construcción y el Builder posee la materialización de cada paso. Con la misma receta y builders distintos se obtienen productos distintos. En PideYa esto encaja, por ejemplo, en generar el resumen de un pedido en varios formatos: un DirectorResumenPedido recorre siempre los mismos pasos (cabecera, líneas, totales, datos de entrega) y, según le enchufes un BuilderResumenHtml, un BuilderResumenTexto (para SMS) o un BuilderTicketCocina, sale una representación u otra.

Dicho esto, seamos francos: en el Java cotidiano esta forma completa, con interfaz e intercambio de builders, es minoritaria. Lo que encontrarás por todas partes es su descendiente fluido.

La variante fluida moderna (la que usarás el 95% de las veces)

La variante popularizada por Effective Java (Bloch) simplifica: un único builder, clase estática interna del producto, con métodos que devuelven this para encadenarse (interfaz fluida), sin interfaz ni Director. El objetivo ya no es "misma receta, varias representaciones" sino domar los constructores telescópicos con legibilidad, inmutabilidad y validación. Así se usa el que vamos a escribir:

Pedido pedido = Pedido.builder(cliente, restaurante)
        .linea(margarita, 2)
        .linea(agua, 1)
        .entregaEnDomicilio(direccionCasa)
        .franja(FranjaHoraria.de(21, 0, 21, 30))
        .instrucciones("Portal 3, no llamar al timbre")
        .codigoPromocional("PIZZA10")
        .conCubiertos()
        .build();

Compara con el criptograma del principio: cada dato lleva su nombre, los opcionales solo aparecen si existen, el orden es libre, y el compilador impide confundir la dirección con el teléfono. Este estilo te lo cruzarás a diario: StringBuilder, Stream.builder(), HttpRequest.newBuilder() en el JDK; UriComponentsBuilder en Spring; y las anotaciones @Builder de Lombok o los records con builders generados en muchas bases de código.

Implementación Java completa del Pedido.Builder

public final class Pedido {

    // ---- Estado: todo final. Un Pedido, una vez creado, es inmutable ----
    private final Cliente cliente;
    private final Restaurante restaurante;
    private final List<LineaPedido> lineas;
    private final TipoEntrega tipoEntrega;          // DOMICILIO o RECOGIDA
    private final Direccion direccionEntrega;       // solo si DOMICILIO
    private final FranjaHoraria franja;             // opcional
    private final String instrucciones;             // opcional
    private final String codigoPromocional;         // opcional
    private final boolean incluirCubiertos;
    private final String telefonoContacto;          // opcional

    /** Constructor PRIVADO: la única puerta de entrada es el builder. */
    private Pedido(Builder b) {
        this.cliente = b.cliente;
        this.restaurante = b.restaurante;
        this.lineas = List.copyOf(b.lineas);        // copia defensiva e inmutable
        this.tipoEntrega = b.tipoEntrega;
        this.direccionEntrega = b.direccionEntrega;
        this.franja = b.franja;
        this.instrucciones = b.instrucciones;
        this.codigoPromocional = b.codigoPromocional;
        this.incluirCubiertos = b.incluirCubiertos;
        this.telefonoContacto = b.telefonoContacto;
    }

    /** Punto de entrada: los OBLIGATORIOS se exigen ya aquí. */
    public static Builder builder(Cliente cliente, Restaurante restaurante) {
        return new Builder(cliente, restaurante);
    }

    // ---- getters (sin setters: inmutable) ----
    public BigDecimal getTotalSinImpuestos() {
        return lineas.stream()
                .map(LineaPedido::getSubtotal)
                .reduce(BigDecimal.ZERO, BigDecimal::add);
    }
    // ... resto de getters ...

    // =====================================================================
    public static final class Builder {

        // Obligatorios: final, llegan por el constructor del builder
        private final Cliente cliente;
        private final Restaurante restaurante;

        // Acumulables y opcionales: mutables DURANTE la construcción
        private final List<LineaPedido> lineas = new ArrayList<>();
        private TipoEntrega tipoEntrega = TipoEntrega.RECOGIDA;   // valor por defecto
        private Direccion direccionEntrega;
        private FranjaHoraria franja;
        private String instrucciones;
        private String codigoPromocional;
        private boolean incluirCubiertos = false;
        private String telefonoContacto;

        private Builder(Cliente cliente, Restaurante restaurante) {
            this.cliente = Objects.requireNonNull(cliente, "cliente");
            this.restaurante = Objects.requireNonNull(restaurante, "restaurante");
        }

        public Builder linea(Producto producto, int cantidad) {
            lineas.add(new LineaPedido(producto, cantidad));
            return this;                              // <- lo que permite encadenar
        }

        public Builder entregaEnDomicilio(Direccion direccion) {
            this.tipoEntrega = TipoEntrega.DOMICILIO;
            this.direccionEntrega = Objects.requireNonNull(direccion, "direccion");
            return this;
        }

        public Builder recogidaEnLocal() {
            this.tipoEntrega = TipoEntrega.RECOGIDA;
            this.direccionEntrega = null;
            return this;
        }

        public Builder franja(FranjaHoraria franja) { this.franja = franja; return this; }
        public Builder instrucciones(String texto) { this.instrucciones = texto; return this; }
        public Builder codigoPromocional(String codigo) { this.codigoPromocional = codigo; return this; }
        public Builder conCubiertos() { this.incluirCubiertos = true; return this; }
        public Builder telefonoContacto(String telefono) { this.telefonoContacto = telefono; return this; }

        /** La frontera: valida TODO y entrega el producto terminado. */
        public Pedido build() {
            if (lineas.isEmpty()) {
                throw new IllegalStateException("Un pedido necesita al menos una línea");
            }
            if (tipoEntrega == TipoEntrega.DOMICILIO && direccionEntrega == null) {
                throw new IllegalStateException("La entrega a domicilio requiere dirección");
            }
            if (franja != null && !restaurante.reparteEn(franja)) {
                throw new IllegalStateException("El restaurante no reparte en esa franja");
            }
            return new Pedido(this);
        }
    }
}

Puntos finos del código, uno a uno:

  • Constructor privado de Pedido: nadie puede fabricar un pedido saltándose el builder (misma jugada de "cerrar la puerta" que en Singleton, con otro fin).
  • Obligatorios en el constructor del builder, opcionales como métodos: imposible olvidar cliente o restaurante; imposible ensuciar la llamada con nulls por lo que no se usa.
  • return this: cada método devuelve el propio builder; es lo único que hace falta para la fluidez del encadenado.
  • Métodos con semántica, no solo setters: entregaEnDomicilio(direccion) y recogidaEnLocal() agrupan cambios coherentes (tipo + dirección) y hacen inexpresables varios estados absurdos; mejor que un setTipoEntrega() y un setDireccion() sueltos.
  • List.copyOf en el constructor: copia defensiva; aunque alguien reutilice el builder después, el pedido ya creado no cambia.

Inmutabilidad y validación en build()

Estas dos palabras son la mitad del valor del patrón, así que merecen su sección:

Inmutabilidad: el builder es mutable mientras dura la construcción (para eso está), pero el producto nace completo y congelado: todos los campos final, sin setters, colecciones copiadas. La consecuencia práctica en PideYa es enorme: un Pedido puede pasar por el cálculo de promociones, el cobro y las notificaciones —incluso en hilos distintos— con la garantía de que nadie lo altera por el camino. El patrón resuelve así la tensión clásica "quiero objetos inmutables pero con muchos opcionales": mutabilidad confinada al andamio, inmutabilidad en el edificio.

Validación en build(): build() es la frontera única entre "datos sueltos" y "pedido válido". Todas las reglas de integridad —al menos una línea, domicilio ⇒ dirección, franja compatible con el restaurante— se comprueban ahí, una sola vez, en un solo lugar. La garantía resultante vale oro: si existe un Pedido, es válido; ningún código posterior necesita re-comprobar. Y las reglas de validación cruzada (las que implican varios campos a la vez) por fin tienen un hogar natural, cosa que ni el constructor telescópico ni los setters ofrecían.

Nota de alcance: hablamos de validación estructural del objeto. Las reglas de negocio dinámicas (¿el código promocional está vigente? ¿el restaurante está abierto ahora?) pertenecen a los servicios de dominio, no al builder, que no debe cargar con dependencias de repositorios o relojes.

El Director en la variante moderna

¿Y el Director, se perdió? No: se transformó. Su esencia —una receta reutilizable con nombre— sigue siendo útil cuando ciertas combinaciones de pasos se repiten. En PideYa, los "pedidos típicos" son recetas:

/** Director moderno: encapsula recetas de construcción frecuentes. */
public class RecetasPedido {

    /** El "menú del día" de un restaurante, listo en una llamada. */
    public static Pedido menuDelDia(Cliente cliente, Restaurante rest, Direccion dir) {
        Pedido.Builder builder = Pedido.builder(cliente, rest)
                .entregaEnDomicilio(dir)
                .conCubiertos();
        rest.getMenuDelDia().forEach(prod -> builder.linea(prod, 1));
        return builder.build();
    }

    /** Pedido de empresa: recogida, sin cubiertos, factura a la empresa. */
    public static Pedido pedidoEmpresa(Cliente cliente, Restaurante rest, List<Producto> prods) {
        Pedido.Builder builder = Pedido.builder(cliente, rest)
                .recogidaEnLocal()
                .instrucciones("Pedido de empresa - preguntar por recepción");
        prods.forEach(p -> builder.linea(p, 1));
        return builder.build();
    }
}

Es el mismo reparto de papeles GoF (la receta separada de los pasos), sin la ceremonia de interfaces que aquí no compra nada. Cuando en la función "repetir pedido" necesitemos construir un pedido a partir de otro, veremos que hay una alternativa aún más directa: la siguiente lección.

Cuándo usarlo y cuándo no

Úsalo cuando:

  • Un constructor supera los ~4 parámetros o mezcla varios del mismo tipo (umbral orientativo, no dogma).
  • Hay varios opcionales con combinaciones libres: es el caso exacto de Pedido.
  • Quieres inmutabilidad + validación centralizada en objetos con muchos datos.
  • Construyes algo incremental por naturaleza (una consulta, un informe, una petición HTTP).
  • (Forma GoF con Director) La misma secuencia de construcción debe producir representaciones distintas.

No lo uses cuando:

  • La clase tiene 2-3 campos obligatorios y ninguno opcional: un constructor normal —o un record de Java— es más corto, más claro y suficiente. Un builder ahí es ceremonia pura (sobreingeniería).
  • El problema es qué clase instanciar, no cómo montarla: eso son las fábricas.
  • Solo quieres parámetros con nombre: valora antes si un record con métodos with... o parámetros agrupados en un objeto pequeño resuelven el caso con menos código.

Relación con otros patrones (solo mención): una Abstract Factory puede devolver productos que internamente monta un builder; el Director clásico es pariente de Template Method (receta fija, pasos variables); Composite se construye a menudo con builders; y Prototype es la alternativa cuando el punto de partida es un objeto existente y no datos sueltos.

Errores Comunes y Consejos

  • Builder sin validación: un build() que solo hace new desperdicia la mitad del patrón. Si no hay nada que validar ni inmutabilidad que proteger, quizá no necesitabas builder.
  • Builder mutable como sustituto de setters: pasar el builder de un lado a otro del sistema y llamarle build() tres veces en sitios distintos reintroduce el objeto a medio hacer que queríamos evitar. El builder debe vivir poco: se crea, se rellena y muere en el build().
  • Olvidar la copia defensiva de colecciones: sin List.copyOf, quien conserve la lista original puede mutar el pedido "inmutable" desde fuera. Error silencioso y clásico.
  • Reutilizar un builder para varios productos sin pensarlo: tras build(), el builder retiene su estado; un segundo build() crea otro pedido igual (a veces se quiere, a menudo no). Decide y documenta la política; en caso de duda, un builder = un producto.
  • Booleanos posicionales disfrazados: builder.cubiertos(true) repite el problema del telescópico en miniatura. Mejor conCubiertos() / nada.
  • Consejo: cuando un grupo de parámetros viaja siempre junto (calle, número, piso, ciudad...), no les des un builder a todos: extrae primero el objeto Direccion. Muchos "constructores telescópicos" son en realidad objetos de valor sin descubrir.

Ejercicios

Ejercicio 1: builder para Factura

La Factura de PideYa a los restaurantes tiene obligatorios restaurante, periodo y importeComisiones, y opcionales descuentoPorVolumen (BigDecimal), notas (String) e iban alternativo de cobro. Regla cruzada: si hay descuentoPorVolumen, no puede superar el 20% de importeComisiones. Escribe la clase inmutable con su builder fluido.

Ejercicio 2: encontrar los fallos del builder

Este builder pasó una revisión de código con tres fallos serios. Localízalos:

public class ReservaMesa {
    public String restaurante;
    public LocalDateTime fecha;
    public int comensales;

    public static class Builder {
        private final ReservaMesa r = new ReservaMesa();

        public Builder restaurante(String nombre) { r.restaurante = nombre; return this; }
        public Builder fecha(LocalDateTime f) { r.fecha = f; return this; }
        public Builder comensales(int n) { r.comensales = n; return this; }

        public ReservaMesa build() { return r; }
    }
}

Ejercicio 3: receta de Director

Escribe, usando Pedido.builder(...) de la lección, la receta reposicionOficina(Cliente, Restaurante, Direccion): pedido a domicilio para la oficina, con 10 unidades de agua y 5 de café (asume Producto AGUA y CAFE disponibles), franja de 9:00 a 9:30, instrucciones "Recepción, planta 2" y sin cubiertos.

Soluciones

Solución 1:

public final class Factura {
    private final Restaurante restaurante;
    private final Periodo periodo;
    private final BigDecimal importeComisiones;
    private final BigDecimal descuentoPorVolumen;   // puede ser null
    private final String notas;                     // puede ser null
    private final String iban;                      // puede ser null

    private Factura(Builder b) {
        this.restaurante = b.restaurante;
        this.periodo = b.periodo;
        this.importeComisiones = b.importeComisiones;
        this.descuentoPorVolumen = b.descuentoPorVolumen;
        this.notas = b.notas;
        this.iban = b.iban;
    }

    public static Builder builder(Restaurante r, Periodo p, BigDecimal comisiones) {
        return new Builder(r, p, comisiones);
    }

    public static final class Builder {
        private final Restaurante restaurante;
        private final Periodo periodo;
        private final BigDecimal importeComisiones;
        private BigDecimal descuentoPorVolumen;
        private String notas;
        private String iban;

        private Builder(Restaurante r, Periodo p, BigDecimal comisiones) {
            this.restaurante = Objects.requireNonNull(r);
            this.periodo = Objects.requireNonNull(p);
            this.importeComisiones = Objects.requireNonNull(comisiones);
        }

        public Builder descuentoPorVolumen(BigDecimal d) { this.descuentoPorVolumen = d; return this; }
        public Builder notas(String n) { this.notas = n; return this; }
        public Builder iban(String iban) { this.iban = iban; return this; }

        public Factura build() {
            if (descuentoPorVolumen != null) {
                BigDecimal tope = importeComisiones.multiply(new BigDecimal("0.20"));
                if (descuentoPorVolumen.compareTo(tope) > 0) {
                    throw new IllegalStateException("El descuento supera el 20% de las comisiones");
                }
            }
            return new Factura(this);
        }
    }
}

Solución 2: (a) ReservaMesa tiene los campos públicos y mutables y ningún constructor privado: cualquiera puede crearla vacía con new ReservaMesa() o mutarla tras el build(): no hay inmutabilidad ni puerta única; (b) el builder muta directamente la instancia final desde el primer momento: si el builder se abandona a medias, ha existido un objeto inválido, y dos build() devuelven el mismo objeto compartido (alias accidental); (c) build() no valida nada: se puede construir una reserva sin restaurante, con fecha pasada o con 0 comensales. Corrección: campos privados final, constructor privado que copia del builder, y validación de obligatorios y rangos en build().

Solución 3:

public static Pedido reposicionOficina(Cliente cliente, Restaurante rest, Direccion oficina) {
    return Pedido.builder(cliente, rest)
            .linea(AGUA, 10)
            .linea(CAFE, 5)
            .entregaEnDomicilio(oficina)
            .franja(FranjaHoraria.de(9, 0, 9, 30))
            .instrucciones("Recepción, planta 2")
            .build();                 // sin conCubiertos(): el defecto ya es false
}

Conclusión

Builder cierra el flanco que las fábricas no cubrían: cuando la dificultad no está en elegir la clase sino en montarla, la construcción paso a paso con métodos nombrados elimina el constructor telescópico, y el build() final convierte el montaje en una frontera de validación e inmutabilidad —si existe un Pedido, es válido y nadie podrá corromperlo—. Ya distingues además la forma GoF (Director con receta + builders intercambiables, útil para múltiples representaciones) de la variante fluida interna que domina el Java moderno, y su regla de proporcionalidad: por debajo de cuatro campos, probablemente sobra.

Queda una última manera de traer objetos al mundo, y es la más distinta de todas: no elegir clase ni montar pieza a pieza, sino copiar un objeto que ya existe. Es justo lo que pide la función "repetir mi último pedido" de PideYa, y tiene más aristas en Java de las que aparenta. Nos vemos en Prototype.

Curso de Patrones de Diseño de Software

Módulo 1: Introducción a los Patrones de Diseño

Módulo 2: Patrones Creacionales

Módulo 3: Patrones Estructurales

Módulo 4: Patrones de Comportamiento

Módulo 5: Aplicación de Patrones de Diseño

Módulo 6: Patrones de Diseño Avanzados

Módulo 7: Recursos Adicionales y Conclusión

© Copyright 2026. Todos los derechos reservados