Builder
En resumen: Builder arma un objeto complejo paso a paso, con un nombre para cada campo, y lo valida en build(); en JavaScript un objeto de opciones suele hacer lo mismo.
Problema
Nueve parámetros y dos palabras intercambiadas
La tienda envía un correo cuando un pedido sale. El correo tiene nueve campos: a quién va, la dirección para las respuestas, el asunto, el texto, una copia, una copia oculta, un adjunto, si es importante y si se rastrea su apertura.
class Email { constructor(to, replyTo, subject, body, cc, bcc, attachment, highPriority, trackOpens) { this.to = to; this.replyTo = replyTo; this.subject = subject; this.body = body; this.cc = cc; this.bcc = bcc; this.attachment = attachment; this.highPriority = highPriority; this.trackOpens = trackOpens; }
send() { console.log(`Para: ${this.to}`); if (this.replyTo) { console.log(`Responder a: ${this.replyTo}`); } console.log(`Asunto: ${this.subject}`); if (this.attachment) { console.log(`Adjunto: ${this.attachment}`); } if (this.highPriority) { console.log("Prioridad alta"); } }}Así se envía la confirmación del pedido 1042:
const email = new Email("support@shop.example", "maria@example.com", "El pedido 1042 ha sido enviado", "Tu paquete está en camino.", null, null, "invoice-1042.pdf", true, false);email.send();Para: support@shop.exampleResponder a: maria@example.comAsunto: El pedido 1042 ha sido enviadoAdjunto: invoice-1042.pdfPrioridad altaEl correo fue al soporte de la tienda, y María no recibió nada. Los dos primeros argumentos están intercambiados. Los dos son texto, así que el lenguaje no tiene forma de saberlo. ¿Y cuál es true y cuál false al final? Para averiguarlo, cuentas posiciones en el constructor. Dos null guardan el lugar de campos que este correo no tiene.
Cada campo nuevo, digamos el idioma del correo, cambia el constructor y cada lugar que lo llama.
La idea
Builder: por nombre, por pasos, con comprobación
El objeto no se crea con una llamada de nueve posiciones, sino por pasos, cada uno con un nombre: .to(…), .subject(…). Los pasos van a un objeto aparte, el builder. Él reúne los valores, y al final build():
- comprueba los campos obligatorios (aquí: destinatario, asunto y texto) y lanza una excepción si falta alguno;
- crea un objeto inmutable: después el correo ya no puede cambiar.
Este es el patrón Builder. Los papeles:
- el producto:
Email, inmutable; - el builder:
EmailBuilder, con un método para cada campo. El método devuelve el propio builder, así que las llamadas se alinean una tras otra en una cadena; build(): el único lugar donde se comprueba y se crea el producto.
Míralo
La cadena y build()
class Email { constructor({ to, replyTo = null, subject, body, cc = null, bcc = null, attachment = null, highPriority = false, trackOpens = false, }) { Object.assign(this, { to, replyTo, subject, body, cc, bcc, attachment, highPriority, trackOpens }); Object.freeze(this); }
send() { console.log(`Para: ${this.to}`); if (this.replyTo) { console.log(`Responder a: ${this.replyTo}`); } console.log(`Asunto: ${this.subject}`); if (this.attachment) { console.log(`Adjunto: ${this.attachment}`); } if (this.highPriority) { console.log("Prioridad alta"); } }}
class EmailBuilder { fields = {};
to(value) { this.fields.to = value; return this; }
replyTo(value) { this.fields.replyTo = value; return this; }
subject(value) { this.fields.subject = value; return this; }
body(value) { this.fields.body = value; return this; }
cc(value) { this.fields.cc = value; return this; }
bcc(value) { this.fields.bcc = value; return this; }
attachment(value) { this.fields.attachment = value; return this; }
highPriority() { this.fields.highPriority = true; return this; }
trackOpens() { this.fields.trackOpens = true; return this; }
build() { if (this.fields.to === undefined) { throw new Error("Falta el destinatario"); } if (this.fields.subject === undefined) { throw new Error("Falta el asunto"); } if (this.fields.body === undefined) { throw new Error("Falta el texto"); } return new Email(this.fields); }}Un builder cuesta código: cada campo se escribe tanto en el correo como en el método del builder. Por eso no se hace para cada clase.
Email recibe un objeto de opciones y lo desestructura por nombre, con valores por defecto. Object.freeze(this) prohíbe los cambios después de la creación.const email = new EmailBuilder() .to("maria@example.com") .subject("El pedido 1042 ha sido enviado") .body("Tu paquete está en camino.") .attachment("invoice-1042.pdf") .highPriority() .build();email.send();
const draft = new EmailBuilder() .to("peter@example.com") .body("Tu paquete está en camino.");try { draft.build();} catch (e) { console.log(e.message);}Para: maria@example.comAsunto: El pedido 1042 ha sido enviadoAdjunto: invoice-1042.pdfPrioridad altaFalta el asuntoconst email = new EmailBuilder() .to("maria@example.com") .subject("El pedido 1042 ha sido enviado") .body("Tu paquete está en camino.") .attachment("invoice-1042.pdf") .highPriority() .build();email.send();
const draft = new EmailBuilder() .to("peter@example.com") .body("Tu paquete está en camino.");try { draft.build();} catch (e) { console.log(e.message);}Paso a paso, en palabras
- new EmailBuilder(): un plano vacío con nueve casillas. Las tres obligatorias (to, subject, body) están marcadas.
- .to(…) rellena una casilla y devuelve el mismo builder, así que la cadena continúa.
- Cada llamada tiene un nombre: un valor no puede acabar en la casilla equivocada, como pasaba con las posiciones del constructor.
- .body(…): las tres casillas obligatorias están rellenas.
- Los campos opcionales se escriben solo cuando hacen falta. highPriority() no lleva argumento: ya no hay un true en el octavo lugar.
- build() comprueba las casillas obligatorias y estampa un Email. Sus campos son Object.freeze: el objeto ya no cambia.
- El correo sale hacia María, con la factura, con prioridad alta.
- Un builder nuevo, un plano vacío nuevo. Aquí alguien olvidó el asunto.
- draft.build() llega a la casilla vacía subject y se detiene con una excepción. Un correo sin asunto ni siquiera llega a existir.
- El error dice qué falta. No se ha enviado nada.
El correo sin asunto nunca llega al correo postal: build() lo detiene ya en el armado.
Míralo
Sin builder: lo que ofrece el lenguaje
class Email { constructor({ to, replyTo = null, subject, body, cc = null, bcc = null, attachment = null, highPriority = false, trackOpens = false, }) { if (!to || !subject || !body) { throw new Error("Falta el destinatario, el asunto o el texto"); } Object.assign(this, { to, replyTo, subject, body, cc, bcc, attachment, highPriority, trackOpens }); Object.freeze(this); }}const email = new Email({ to: "maria@example.com", subject: "El pedido 1042 ha sido enviado", body: "Tu paquete está en camino.", attachment: "invoice-1042.pdf", highPriority: true,});console.log(email.to, "/", email.subject, "/", email.highPriority);maria@example.com / El pedido 1042 ha sido enviado / trueObject.freeze detiene los cambios. En un módulo (un archivo con import) o con "use strict" la asignación lanza un error, y en un script simple no hace nada en silencio:email.to = "peter@example.com";TypeError: Cannot assign to read only property 'to' of object '#<Email>'Cuándo vale la pena un builder de todos modos:
- el correo se arma por pasos, en distintas partes del código: el adjunto se añade solo si hay factura, la copia solo para empresas;
- hay una comprobación entre campos: por ejemplo, la copia oculta no puede ser la dirección del destinatario;
- hay muchos campos, la mayoría opcionales, y el armado tiene que pasar de un lugar a otro antes de estar listo.
Pruébalo
Predice la salida
Un builder, dos llamadas a build(). ¿Qué imprime el programa?
const builder = new EmailBuilder() .to("maria@example.com") .subject("El pedido 1042 ha sido enviado") .body("Tu paquete está en camino.");const first = builder.build();builder.highPriority();const second = builder.build();console.log(first.highPriority, second.highPriority);Pruébalo
Encuentra el error
La tienda envía los correos de los pedidos enviados en un bucle. Solo el primer pedido tiene factura:
const orders = [ { email: "maria@example.com", id: 1042, invoice: "invoice-1042.pdf" }, { email: "peter@example.com", id: 1043, invoice: null },];const builder = new EmailBuilder().replyTo("support@shop.example");for (const order of orders) { builder .to(order.email) .subject(`El pedido ${order.id} ha sido enviado`) .body("Tu paquete está en camino."); if (order.invoice) { builder.attachment(order.invoice); } builder.build().send();}Para: maria@example.comResponder a: support@shop.exampleAsunto: El pedido 1042 ha sido enviadoAdjunto: invoice-1042.pdfPara: peter@example.comResponder a: support@shop.exampleAsunto: El pedido 1043 ha sido enviadoAdjunto: invoice-1042.pdfPeter recibió la factura de María. ¿En qué línea está el error?
Elige la línea con el error.
El builder se crea una sola vez, antes del bucle. Recuerda todo lo que se le dijo, y el adjunto del primer correo se queda para el segundo, porque nadie lo borró. Un builder es para un solo armado: crea uno nuevo dentro del bucle, para cada correo. Aquí el error filtra datos personales: la factura de otra persona, con nombre y dirección.
La idea
Cuándo no lo necesitas
Builder duplica el código de la clase. No compensa cuando:
- hay dos o tres campos y todos son obligatorios: un constructor simple es más claro;
- un objeto de opciones hace lo mismo sin una segunda clase;
- el objeto no tiene nada que comprobar y no se arma por pasos.
Compensa cuando hay muchos campos y la mayoría son opcionales, cuando hay una comprobación entre ellos, o cuando el objeto se arma por pasos, en distintas partes del código.
Ejercicio
Un paquete para el mensajero
Resuelve la tarea en tu editor.
Un builder para un paquete. El mensajero quiere un paquete con destinatario, teléfono, dirección y peso (obligatorios), y opcionalmente: frágil y contra reembolso. Escribe Shipment y un builder para él. build() comprueba que los campos obligatorios estén puestos y que el peso sea mayor que 0 y de hasta 30 kg. Crea un paquete para María (2,5 kg, frágil, contra reembolso 42,90 €), imprímelo y prueba un paquete de 45 kg:
Paquete para María, 0888 123 456Dirección: Calle Mayor 5, MadridPeso: 2.5 kg, frágilContra reembolso: 42.90 €El peso debe ser mayor que 0 y de hasta 30 kgMás tareas
- Arregla el bucle. Arregla el programa de «Encuentra el error» para que cada correo lleve solo su propia factura. La dirección de respuesta debe quedar para todos los correos.
- Una copia con un cambio. Añade un método que convierta un correo terminado en un builder con los mismos valores (en Java suele llamarse
toBuilder()). Envía el mismo correo a otro destinatario. - El camino del lenguaje. Escribe
Shipmentcomo en la tarjeta «Sin builder» y compara las dos versiones. ¿Cuál es más corta, y qué pierdes?
Test
Ponte a prueba
Lecciones relacionadas
Sección «Lecciones relacionadas»- Se basa en: Clases anidadas y datos · Datos, switch y desestructuración · Patrones de diseño · Factory Method y Simple Factory
- Continúa en: La tienda: qué patrón dónde
- Ejercicios sobre el tema: Patrones de diseño