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
- El problema en PideYa: el constructor telescópico
- Intención del patrón
- Estructura GoF: Builder y Director
- La variante fluida moderna (la que usarás el 95% de las veces)
- Implementación Java completa del
Pedido.Builder - Inmutabilidad y validación en
build() - El Director en la variante moderna
- Cuándo usarlo y cuándo no
- 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 (
instruccionesycodigoPromocional, ambosString) 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)yrecogidaEnLocal()agrupan cambios coherentes (tipo + dirección) y hacen inexpresables varios estados absurdos; mejor que unsetTipoEntrega()y unsetDireccion()sueltos. List.copyOfen 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
recordde 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
recordcon métodoswith...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 hacenewdesperdicia 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 elbuild(). - 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 segundobuild()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. MejorconCubiertos()/ 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
- ¿Qué son los Patrones de Diseño?
- Historia y Origen de los Patrones de Diseño
- Principios de Diseño: SOLID y Otros Fundamentos
- UML Esencial para Entender Patrones
- Clasificación de los Patrones de Diseño
- Ventajas y Desventajas de Usar Patrones de Diseño
Módulo 2: Patrones Creacionales
- Introducción a los Patrones Creacionales
- Singleton
- Factory Method
- Abstract Factory
- Builder
- Prototype
- Comparativa y Elección de Patrones Creacionales
Módulo 3: Patrones Estructurales
- Introducción a los Patrones Estructurales
- Adapter
- Bridge
- Composite
- Decorator
- Facade
- Flyweight
- Proxy
- Comparativa y Elección de Patrones Estructurales
Módulo 4: Patrones de Comportamiento
- Introducción a los Patrones de Comportamiento
- Chain of Responsibility
- Command
- Interpreter
- Iterator
- Mediator
- Memento
- Observer
- State
- Strategy
- Template Method
- Visitor
- Comparativa y Elección de Patrones de Comportamiento
Módulo 5: Aplicación de Patrones de Diseño
- Cómo Seleccionar el Patrón Adecuado
- Ejemplos Prácticos de Uso de Patrones
- Patrones de Diseño en Proyectos Reales
- Refactorización Usando Patrones de Diseño
- Antipatrones: Cuándo los Patrones se Vuelven un Problema
Módulo 6: Patrones de Diseño Avanzados
- Patrones de Diseño en Arquitecturas Modernas
- Patrones de Diseño en Microservicios
- Patrones de Diseño en Sistemas Distribuidos
- Patrones de Concurrencia
- Patrones de Diseño en Desarrollo Ágil
