Apuntes DAM

Patrón Builder

Construye objetos con muchos parámetros, varios opcionales, paso a paso y con nombres (builder.disco(2000).conWifi().build()), y los valida una sola vez al final: sin constructores de diez parámetros.

nivel básicoTambién: constructor, constructor paso a paso, fluent builder, interfaz fluida

Visualízalo paso a paso

Cambia los datos, dale a reproducir y sigue cada paso en el dibujo, en la línea de Java que se ejecuta y en sus variables.

Builder

Escribe el procesador, la RAM y las opciones que se piden al builder, separadas por comas: disco 2000, grafica RTX 4070, wifi, sistema Windows 11.

Paso 1

Se crea el builder con los dos datos obligatorios. Los opcionales empiezan con su valor por defecto. Todavía no existe ningún Ordenador.

1Ordenador pc = Ordenador.builder("Core i7", 32)  // llamadas = 4, objeto creado = no
2        .disco(2000)
3        .conWifi()
4        .build();
5
6Builder disco(int gb) {
7    discoGb = gb;
8    return this;
9}
10
11Ordenador build() {
12    if (grafica != null && ram < 16)
13        throw new IllegalStateException("con gráfica hacen falta 16 GB");
14    return new Ordenador(this);
15}

Variables

llamadas
4
objeto creado
no

Atajos con el foco dentro del visualizador: ← → paso a paso, Espacio reproducir o pausar, Inicio/Fin ir al principio o al final.

La idea

Un ordenador a medida tiene dos piezas obligatorias y media docena opcionales. Con constructores, o se escribe uno gigante (new Ordenador("Core i7", 32, 2000, "RTX 4070", true, "Windows 11"), donde nadie sabe qué es cada true) o uno por combinación, que es imposible de mantener. Con setters, el objeto puede quedar a medias y deja de poder ser inmutable.

El patrón Builder pone una clase intermedia, el builder, que recibe los obligatorios en su constructor y cada opcional en un método con nombre que devuelve el propio builder, para encadenar las llamadas. Al final, build() comprueba que la combinación es válida y crea el objeto de una vez, ya completo e inmutable.

En Java lo habitual es que el builder sea una clase anidada estática del objeto que construye y que el constructor del objeto sea privado: la única forma de crearlo es pasar por build(). StringBuilder, HttpRequest.newBuilder(), AlertDialog.Builder en Android o @Builder de Lombok siguen esta misma idea.

Cuándo usarlo

  • Un objeto tiene muchos parámetros, sobre todo si varios son opcionales o del mismo tipo (dos int seguidos se confunden fácilmente).
  • El objeto debe ser inmutable pero no se puede crear de un solo golpe con un constructor claro.
  • Hay reglas que dependen de varios parámetros a la vez (una gráfica exige 16 GB de RAM): se comprueban juntas en build().
  • Se construye algo por partes en un orden libre: una consulta SQL, un correo, una petición HTTP, un informe.

Cuándo no

  • Si el objeto tiene dos o tres parámetros obligatorios: un constructor normal es más claro.
  • En lenguajes con parámetros con nombre y valores por defecto (Python, C#, Kotlin, PHP 8) muchas veces basta con eso.

Participantes

  1. Producto. La clase que se construye (Ordenador), con sus atributos final y el constructor privado que recibe el builder.
  2. Builder. La clase anidada con los mismos atributos: los obligatorios en su constructor y los opcionales con su valor por defecto.
  3. Métodos de construcción. Uno por opción (disco, conWifi…), que guarda el valor y devuelve this para encadenar.
  4. build(). Valida la combinación y crea el producto; si algo no vale, lanza una excepción y el objeto no llega a existir.

Diagrama de clases

creausaOrdenador-procesador : String-ram : int-discoGb : int-grafica : String-wifi : boolean-sistema : String-Ordenador(b : Builder)+builder(procesador : String, ram : int) : BuilderBuilder-procesador : String-ram : int-discoGb : int-grafica : String-wifi : boolean-sistema : String+disco(gb : int) : Builder+grafica(modelo : String) : Builder+conWifi() : Builder+sistema(nombre : String) : Builder+build() : OrdenadorCliente
Arrastra las clases para colocarlas a tu gusto.
Ver el diagrama en PlantUML
text
1@startuml
2class Ordenador {
3  -procesador : String
4  -ram : int
5  -discoGb : int
6  -grafica : String
7  -wifi : boolean
8  -sistema : String
9  -Ordenador(b : Builder)
10  +{static} builder(procesador : String, ram : int) : Builder
11}
12class Builder {
13  -procesador : String
14  -ram : int
15  -discoGb : int
16  -grafica : String
17  -wifi : boolean
18  -sistema : String
19  +disco(gb : int) : Builder
20  +grafica(modelo : String) : Builder
21  +conWifi() : Builder
22  +sistema(nombre : String) : Builder
23  +build() : Ordenador
24}
25class Cliente
26Builder ..> Ordenador : crea
27Cliente ..> Builder : usa
28@enduml

Puedes copiarlo en el editor de diagramas UML y modificarlo.

El código

Un ordenador a medida

Cada llamada se lee sola (.conWifi()), los opcionales que no se piden toman su valor por defecto y build() rechaza las combinaciones imposibles.

Java
1import java.util.*;
2
3/** Un ordenador a medida: dos piezas obligatorias y muchas opcionales. Inmutable una vez creado. */
4class Ordenador {
5    private final String procesador;
6    private final int ram;
7    private final int discoGb;
8    private final String grafica;
9    private final boolean wifi;
10    private final String sistema;
11
12    private Ordenador(Builder b) {                      // solo el builder puede crearlo
13        procesador = b.procesador;
14        ram = b.ram;
15        discoGb = b.discoGb;
16        grafica = b.grafica;
17        wifi = b.wifi;
18        sistema = b.sistema;
19    }
20
21    static Builder builder(String procesador, int ram) { return new Builder(procesador, ram); }
22
23    @Override
24    public String toString() {
25        return procesador + ", " + ram + " GB de RAM, " + discoGb + " GB de disco"
26                + (grafica != null ? ", gráfica " + grafica : "")
27                + (wifi ? ", wifi" : "") + ", " + sistema;
28    }
29
30    static class Builder {
31        private final String procesador;               // obligatorios: en el constructor del builder
32        private final int ram;
33        private int discoGb = 512;                      // opcionales, con su valor por defecto
34        private String grafica;
35        private boolean wifi;
36        private String sistema = "sin sistema operativo";
37
38        private Builder(String procesador, int ram) {
39            this.procesador = procesador;
40            this.ram = ram;
41        }
42
43        // Cada método devuelve el propio builder: así las llamadas se encadenan
44        Builder disco(int gb) { discoGb = gb; return this; }
45        Builder grafica(String modelo) { grafica = modelo; return this; }
46        Builder conWifi() { wifi = true; return this; }
47        Builder sistema(String nombre) { sistema = nombre; return this; }
48
49        /** Valida una sola vez, con todo ya elegido, y crea el objeto. */
50        Ordenador build() {
51            if (!List.of(8, 16, 32, 64).contains(ram)) throw new IllegalStateException("RAM no válida: " + ram + " GB");
52            if (grafica != null && ram < 16) throw new IllegalStateException("con gráfica dedicada hacen falta 16 GB de RAM");
53            return new Ordenador(this);
54        }
55    }
56}
57
58public class Main {
59    public static void main(String[] args) {
60        Ordenador oficina = Ordenador.builder("Ryzen 5", 16).build();
61        Ordenador juegos = Ordenador.builder("Core i7", 32)
62                .disco(2000)
63                .grafica("RTX 4070")
64                .conWifi()
65                .sistema("Windows 11")
66                .build();
67        System.out.println("Oficina: " + oficina);
68        System.out.println("Juegos: " + juegos);
69        try {
70            Ordenador.builder("Celeron", 8).grafica("RTX 4090").build();
71        } catch (IllegalStateException e) {
72            System.out.println("No se crea: " + e.getMessage());
73        }
74    }
75}

Salida al ejecutarlo (la misma en los 5 lenguajes)

Oficina: Ryzen 5, 16 GB de RAM, 512 GB de disco, sin sistema operativo
Juegos: Core i7, 32 GB de RAM, 2000 GB de disco, gráfica RTX 4070, wifi, Windows 11
No se crea: con gráfica dedicada hacen falta 16 GB de RAM

Los builders que ya usas

La biblioteca de Java, Android y Lombok están llenos de builders.

Java
1// StringBuilder construye un String paso a paso (un String no se puede modificar)
2String linea = new StringBuilder().append("Ana").append(';').append(8.5).toString();
3
4// java.net.http: una petición con muchos parámetros opcionales
5HttpRequest peticion = HttpRequest.newBuilder(URI.create("https://api.ejemplo.com/alumnos"))
6        .header("Accept", "application/json")
7        .timeout(Duration.ofSeconds(10))
8        .GET()
9        .build();
10
11// Android: los diálogos se construyen igual
12new AlertDialog.Builder(this)
13        .setTitle("¿Borrar la nota?")
14        .setPositiveButton("Borrar", (d, which) -> borrar())
15        .setNegativeButton("Cancelar", null)
16        .show();
17
18// Con Lombok, @Builder escribe la clase Builder por ti
19@Builder
20class Alumno { String nombre; String email; int curso; }
21Alumno a = Alumno.builder().nombre("Ana").curso(1).build();

En la práctica

  • StringBuilder y StringJoiner para montar textos; Stream.builder() para streams.
  • HttpRequest.newBuilder() y HttpClient.newBuilder() en Java; Request.Builder de OkHttp y Retrofit.Builder en Android.
  • AlertDialog.Builder y NotificationCompat.Builder en Android; WebApplication.CreateBuilder() en ASP.NET Core.
  • Los constructores de consultas: el query builder de Laravel, el CriteriaBuilder de JPA o SQLAlchemy encadenan where, orderBy y limit.

Errores típicos

  • Validar en cada método del builder en vez de en build(): las reglas que dependen de varios valores solo se pueden comprobar al final.
  • Dejar públicos los setters o el constructor del producto: el objeto se puede crear o cambiar sin pasar por las comprobaciones.
  • Reutilizar el mismo builder para dos objetos sin darse cuenta: lo que quedó del primero se cuela en el segundo.
  • Usar un builder para clases de dos atributos: añade una clase entera sin aportar nada.

Ejercicios

Cada ejercicio se corrige solo con sus pruebas (algunas ocultas). Escribe tu solución en el editor y pulsa Ejecutar o Comprobar; la solución explicada está debajo, por si te atascas.

1. Un constructor de consultas SQL

El programa lee una consulta línea a línea y, al llegar a fin, la construye con el builder de Consulta y escribe el SQL. Los métodos del builder no guardan nada y build() solo sabe escribir SELECT * FROM tabla. Complétalos.

  • consulta tabla, select a, b, where condición (varias se unen con AND), orden campo [asc|desc] (varios se separan con coma; desc se escribe DESC y asc no se escribe), limite n y fin.
  • Salida: SELECT columnas FROM tabla WHERE c1 AND c2 ORDER BY o1, o2 LIMIT n, poniendo solo las partes que se han pedido; sin select, las columnas son *. Después de fin empieza una consulta nueva.
  • build() lanza una IllegalStateException con Falta la tabla o LIMIT no válido: n (menor que 1), y el main escribe Error: mensaje.
JavaUn constructor de consultas SQLFácil

Ejemplo

Entrada (lo que se escribe por teclado)
consulta alumnos
select nombre, nota
where nota >= 5
where ciclo = 'DAM'
orden nota desc
orden nombre
limite 10
fin
consulta ciclos
fin
Salida esperada
SELECT nombre, nota FROM alumnos WHERE nota >= 5 AND ciclo = 'DAM' ORDER BY nota DESC, nombre LIMIT 10
SELECT * FROM ciclos
Test oculto #3
Test oculto #4
0/4 tests pasados · pulsa un test para ver su entrada y su salida esperada
Ver la solución explicada
java
1import java.util.*;
2
3/** Una consulta SELECT ya construida (y validada). */
4class Consulta {
5    private final String sql;
6    private Consulta(String sql) { this.sql = sql; }
7    String sql() { return sql; }
8
9    static Builder builder() { return new Builder(); }
10
11    static class Builder {
12        private String tabla;
13        private final List<String> columnas = new ArrayList<>();
14        private final List<String> condiciones = new ArrayList<>();
15        private final List<String> orden = new ArrayList<>();
16        private Integer limite;
17
18        Builder from(String tabla) { this.tabla = tabla; return this; }
19        Builder select(List<String> cols) { columnas.addAll(cols); return this; }
20        Builder where(String condicion) { condiciones.add(condicion); return this; }
21        Builder orderBy(String campo, boolean descendente) { orden.add(descendente ? campo + " DESC" : campo); return this; }
22        Builder limit(int n) { limite = n; return this; }
23
24        Consulta build() {
25            if (tabla == null) throw new IllegalStateException("Falta la tabla");
26            if (limite != null && limite < 1) throw new IllegalStateException("LIMIT no válido: " + limite);
27            StringBuilder sb = new StringBuilder("SELECT ");
28            sb.append(columnas.isEmpty() ? "*" : String.join(", ", columnas));
29            sb.append(" FROM ").append(tabla);
30            if (!condiciones.isEmpty()) sb.append(" WHERE ").append(String.join(" AND ", condiciones));
31            if (!orden.isEmpty()) sb.append(" ORDER BY ").append(String.join(", ", orden));
32            if (limite != null) sb.append(" LIMIT ").append(limite);
33            return new Consulta(sb.toString());
34        }
35    }
36}
37
38public class Main {
39    public static void main(String[] args) {
40        Scanner sc = new Scanner(System.in);
41        Consulta.Builder b = Consulta.builder();
42        while (sc.hasNextLine()) {
43            String linea = sc.nextLine().trim();
44            if (linea.isEmpty()) continue;
45            int esp = linea.indexOf(' ');
46            String orden = esp < 0 ? linea : linea.substring(0, esp);
47            String resto = esp < 0 ? "" : linea.substring(esp + 1).trim();
48            switch (orden) {
49                case "consulta" -> b.from(resto);
50                case "select" -> {
51                    List<String> cols = new ArrayList<>();
52                    for (String c : resto.split(",")) if (!c.isBlank()) cols.add(c.trim());
53                    if (cols.isEmpty()) System.out.println("Orden no válida: " + linea);
54                    else b.select(cols);
55                }
56                case "where" -> b.where(resto);
57                case "orden" -> {
58                    String[] p = resto.split("\\s+");
59                    if (p.length == 1 && !p[0].isEmpty()) b.orderBy(p[0], false);
60                    else if (p.length == 2 && (p[1].equals("asc") || p[1].equals("desc"))) b.orderBy(p[0], p[1].equals("desc"));
61                    else System.out.println("Orden no válida: " + linea);
62                }
63                case "limite" -> {
64                    try {
65                        b.limit(Integer.parseInt(resto));
66                    } catch (NumberFormatException e) {
67                        System.out.println("Orden no válida: " + linea);
68                    }
69                }
70                case "fin" -> {
71                    try {
72                        System.out.println(b.build().sql());
73                    } catch (IllegalStateException e) {
74                        System.out.println("Error: " + e.getMessage());
75                    }
76                    b = Consulta.builder();
77                }
78                default -> System.out.println("Orden no válida: " + linea);
79            }
80        }
81    }
82}

El orden de las líneas no importa: el builder guarda las piezas y build() las coloca en el orden que exige SQL.

Las comprobaciones están en un solo sitio, build(), y una consulta que no vale no llega a crearse.

2. Billetes de tren con reglas

Los billetes se construyen con un builder: el origen y el destino al empezar y el resto con un método por opción. Completa en el builder precio() y build(), que debe rechazar los billetes que no cumplen las reglas. La lectura de órdenes y la salida ya están.

  • Un billete empieza con billete origen destino y termina con fin; entre medias, clase turista|preferente, asiento 5A, maleta (una por línea), mascota, bicicleta y joven.
  • Precio: 20 € en turista y 35 € en preferente; la primera maleta va incluida y cada una más cuesta 5 €; la mascota, 10 €; la bicicleta, 8 €; y joven descuenta un 20 % del total.
  • Reglas (en este orden), con IllegalStateException: el origen y el destino son el mismo (sin distinguir mayúsculas), la clase X no existe, el asiento X no existe (fila del 1 al 20 y letra de la A a la D), se admiten 3 maletas como mucho y las bicicletas solo van en turista.
JavaBilletes de tren con reglasMedio

Ejemplo

Entrada (lo que se escribe por teclado)
billete Madrid Sevilla
fin
billete Barcelona Valencia
clase preferente
asiento 5A
maleta
maleta
mascota
joven
fin
billete Bilbao Burgos
maleta
bicicleta
fin
Salida esperada
Madrid → Sevilla · turista: 20,00 €
Barcelona → Valencia · preferente · asiento 5A · 2 maletas · mascota · joven: 40,00 €
Bilbao → Burgos · turista · 1 maleta · bicicleta: 28,00 €
Test oculto #3
Test oculto #4
0/4 tests pasados · pulsa un test para ver su entrada y su salida esperada
Ver la solución explicada
java
1import java.util.*;
2
3/** Un billete de tren, inmutable: solo se crea con su builder. */
4class Billete {
5    private final String origen, destino, clase, asiento;
6    private final int maletas;
7    private final boolean mascota, bicicleta, joven;
8    private final double precio;
9
10    private Billete(Builder b) {
11        origen = b.origen;
12        destino = b.destino;
13        clase = b.clase;
14        asiento = b.asiento;
15        maletas = b.maletas;
16        mascota = b.mascota;
17        bicicleta = b.bicicleta;
18        joven = b.joven;
19        precio = b.precio();
20    }
21
22    static Builder de(String origen, String destino) { return new Builder(origen, destino); }
23
24    @Override
25    public String toString() {
26        StringBuilder sb = new StringBuilder(origen + " → " + destino + " · " + clase);
27        if (asiento != null) sb.append(" · asiento ").append(asiento);
28        if (maletas > 0) sb.append(" · ").append(maletas).append(maletas == 1 ? " maleta" : " maletas");
29        if (mascota) sb.append(" · mascota");
30        if (bicicleta) sb.append(" · bicicleta");
31        if (joven) sb.append(" · joven");
32        return sb + ": " + Main.euros(precio);
33    }
34
35    static class Builder {
36        private final String origen, destino;
37        private String clase = "turista";
38        private String asiento;
39        private int maletas;
40        private boolean mascota, bicicleta, joven;
41
42        private Builder(String origen, String destino) {
43            this.origen = origen;
44            this.destino = destino;
45        }
46
47        String trayecto() { return origen + " → " + destino; }
48
49        Builder clase(String clase) { this.clase = clase; return this; }
50        Builder asiento(String asiento) { this.asiento = asiento; return this; }
51        Builder maleta() { maletas++; return this; }
52        Builder mascota() { mascota = true; return this; }
53        Builder bicicleta() { bicicleta = true; return this; }
54        Builder joven() { joven = true; return this; }
55
56        double precio() {
57            double p = clase.equals("preferente") ? 35 : 20;
58            if (maletas > 1) p += 5 * (maletas - 1);       // la primera maleta va incluida
59            if (mascota) p += 10;
60            if (bicicleta) p += 8;
61            if (joven) p *= 0.8;                            // el descuento, al final
62            return p;
63        }
64
65        Billete build() {
66            if (origen.equalsIgnoreCase(destino)) throw new IllegalStateException("el origen y el destino son el mismo");
67            if (!clase.equals("turista") && !clase.equals("preferente")) throw new IllegalStateException("la clase " + clase + " no existe");
68            if (asiento != null && !asiento.matches("([1-9]|1[0-9]|20)[A-D]")) throw new IllegalStateException("el asiento " + asiento + " no existe");
69            if (maletas > 3) throw new IllegalStateException("se admiten 3 maletas como mucho");
70            if (bicicleta && clase.equals("preferente")) throw new IllegalStateException("las bicicletas solo van en turista");
71            return new Billete(this);
72        }
73    }
74}
75
76public class Main {
77    static String euros(double x) {
78        return String.format(Locale.ROOT, "%.2f", x).replace('.', ',') + " €";
79    }
80
81    public static void main(String[] args) {
82        Scanner sc = new Scanner(System.in);
83        Billete.Builder b = null;
84        while (sc.hasNextLine()) {
85            String[] p = sc.nextLine().trim().split("\\s+");
86            if (p[0].isEmpty()) continue;
87            if (p[0].equals("billete")) {
88                if (b != null) System.out.println("Billete sin terminar: " + b.trayecto());
89                b = p.length == 3 ? Billete.de(p[1], p[2]) : null;
90                if (b == null) System.out.println("Orden no válida: " + String.join(" ", p));
91                continue;
92            }
93            if (b == null) {
94                System.out.println("Empieza con billete origen destino");
95                continue;
96            }
97            switch (p.length == 1 ? p[0] : p[0] + ":") {
98                case "clase:" -> b.clase(p[1]);
99                case "asiento:" -> b.asiento(p[1]);
100                case "maleta" -> b.maleta();
101                case "mascota" -> b.mascota();
102                case "bicicleta" -> b.bicicleta();
103                case "joven" -> b.joven();
104                case "fin" -> {
105                    try {
106                        System.out.println(b.build());
107                    } catch (IllegalStateException e) {
108                        System.out.println("Billete no válido: " + e.getMessage());
109                    }
110                    b = null;
111                }
112                default -> System.out.println("Orden no válida: " + String.join(" ", p));
113            }
114        }
115        if (b != null) System.out.println("Billete sin terminar: " + b.trayecto());
116    }
117}

Las reglas que mezclan opciones (bicicleta y clase) solo se pueden comprobar cuando ya se ha elegido todo: por eso van en build() y no en cada método.

Como el constructor de Billete es privado y sus campos final, no puede existir un billete sin validar ni cambiar después.

Test

Test: Builder

0/5 respondidas · 0 aciertos

Elige una respuesta en cada pregunta: verás al momento si es correcta y por qué. Con un 80 % de aciertos se da por superada.

  1. 1.¿Qué devuelve cada método de opción de un builder (disco, conWifi…)?

  2. 2.¿Dónde se comprueban las reglas que dependen de varios parámetros?

  3. 3.¿Qué problema resuelve frente a un constructor con muchos parámetros?

  4. 4.¿Cuál de estas clases de Java es un builder?

  5. 5.¿Por qué el constructor del producto suele ser privado?

Relacionado