Ir al contenido
wannacode

Builder

En resumen: Builder arma un objeto complejo paso a paso, con un nombre para cada campo, lo valida en build() y devuelve un objeto terminado e inmutable.

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
{
public string To { get; }
public string ReplyTo { get; }
public string Subject { get; }
public string Body { get; }
public string Cc { get; }
public string Bcc { get; }
public string Attachment { get; }
public bool HighPriority { get; }
public bool TrackOpens { get; }
public Email(string to, string replyTo, string subject, string body, string cc,
string bcc, string attachment, bool highPriority, bool trackOpens)
{
To = to;
ReplyTo = replyTo;
Subject = subject;
Body = body;
Cc = cc;
Bcc = bcc;
Attachment = attachment;
HighPriority = highPriority;
TrackOpens = trackOpens;
}
public void Send()
{
Console.WriteLine($"Para: {To}");
if (ReplyTo != null)
{
Console.WriteLine($"Responder a: {ReplyTo}");
}
Console.WriteLine($"Asunto: {Subject}");
if (Attachment != null)
{
Console.WriteLine($"Adjunto: {Attachment}");
}
if (HighPriority)
{
Console.WriteLine("Prioridad alta");
}
}
}

Así se envía la confirmación del pedido 1042:

var 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();
Salida
Para: support@shop.example
Responder a: maria@example.com
Asunto: El pedido 1042 ha sido enviado
Adjunto: invoice-1042.pdf
Prioridad alta

El 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 compilador 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, con un constructor privado, así que no hay otra forma de crear uno;
  • el builder: Email.Builder, 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
{
public string To { get; private init; }
public string ReplyTo { get; private init; }
public string Subject { get; private init; }
public string Body { get; private init; }
public string Cc { get; private init; }
public string Bcc { get; private init; }
public string Attachment { get; private init; }
public bool HighPriority { get; private init; }
public bool TrackOpens { get; private init; }
private Email()
{
}
public void Send()
{
Console.WriteLine($"Para: {To}");
if (ReplyTo != null)
{
Console.WriteLine($"Responder a: {ReplyTo}");
}
Console.WriteLine($"Asunto: {Subject}");
if (Attachment != null)
{
Console.WriteLine($"Adjunto: {Attachment}");
}
if (HighPriority)
{
Console.WriteLine("Prioridad alta");
}
}
public class Builder
{
private string to;
private string replyTo;
private string subject;
private string body;
private string cc;
private string bcc;
private string attachment;
private bool highPriority;
private bool trackOpens;
public Builder To(string value) { to = value; return this; }
public Builder ReplyTo(string value) { replyTo = value; return this; }
public Builder Subject(string value) { subject = value; return this; }
public Builder Body(string value) { body = value; return this; }
public Builder Cc(string value) { cc = value; return this; }
public Builder Bcc(string value) { bcc = value; return this; }
public Builder Attachment(string value) { attachment = value; return this; }
public Builder HighPriority() { highPriority = true; return this; }
public Builder TrackOpens() { trackOpens = true; return this; }
public Email Build()
{
if (to == null)
{
throw new InvalidOperationException("Falta el destinatario");
}
if (subject == null)
{
throw new InvalidOperationException("Falta el asunto");
}
if (body == null)
{
throw new InvalidOperationException("Falta el texto");
}
return new Email
{
To = to,
ReplyTo = replyTo,
Subject = subject,
Body = body,
Cc = cc,
Bcc = bcc,
Attachment = attachment,
HighPriority = highPriority,
TrackOpens = trackOpens,
};
}
}
}

Un builder cuesta código: cada campo se escribe tres veces: en el correo, en el builder y en su método. Por eso no se hace para cada clase.

Email.Builder es una clase anidada: puede usar el init privado de las propiedades y el constructor privado, y nadie más.
var email = new Email.Builder()
.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();
var draft = new Email.Builder()
.To("peter@example.com")
.Body("Tu paquete está en camino.");
try
{
draft.Build();
}
catch (InvalidOperationException e)
{
Console.WriteLine(e.Message);
}
Salida
Para: maria@example.com
Asunto: El pedido 1042 ha sido enviado
Adjunto: invoice-1042.pdf
Prioridad alta
Falta el asunto

var email = new Email.Builder()
.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();
var draft = new Email.Builder()
.To("peter@example.com")
.Body("Tu paquete está en camino.");
try
{
draft.Build();
}
catch (InvalidOperationException e)
{
Console.WriteLine(e.Message);
}
Paso a paso, en palabras
  1. new Email.Builder(): un plano vacío con nueve casillas. Las tres obligatorias (To, Subject, Body) están marcadas.
  2. .To(…) rellena una casilla y devuelve el mismo builder, así que la cadena continúa.
  3. Cada llamada tiene un nombre: un valor no puede acabar en la casilla equivocada, como pasaba con las posiciones del constructor.
  4. .Body(…): las tres casillas obligatorias están rellenas.
  5. Los campos opcionales se escriben solo cuando hacen falta. HighPriority() no lleva argumento: ya no hay un true en el octavo lugar.
  6. Build() comprueba las casillas obligatorias y estampa un Email. Sus campos son private init: el objeto ya no cambia.
  7. El correo sale hacia María, con la factura, con prioridad alta.
  8. Un builder nuevo, un plano vacío nuevo. Aquí alguien olvidó el asunto.
  9. 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.
  10. 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

C# tiene lo que hace Builder en el propio lenguaje. init permite asignar una propiedad solo al crear, required hace que el compilador compruebe las obligatorias, y el inicializador de objeto escribe cada una por su nombre:
class Email
{
public required string To { get; init; }
public string ReplyTo { get; init; }
public required string Subject { get; init; }
public required string Body { get; init; }
public string Cc { get; init; }
public string Bcc { get; init; }
public string Attachment { get; init; }
public bool HighPriority { get; init; }
public bool TrackOpens { get; init; }
}
var 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.WriteLine($"{email.To} / {email.Subject} / {email.HighPriority}");
Salida
maria@example.com / El pedido 1042 ha sido enviado / True
Un asunto olvidado y un cambio después de la creación son errores de compilación:
var email = new Email { To = "maria@example.com", Body = "Tu paquete está en camino." };
email.To = "peter@example.com";
Error
error CS9035: Required member 'Email.Subject' must be set in the object initializer or attribute constructor.
error CS8852: Init-only property or indexer 'Email.To' can only be assigned in an object initializer, or on 'this' or 'base' in an instance constructor or an 'init' accessor.
Si la clase tiene un constructor, C# también lo llama con argumentos con nombre: new Email(to: "…", replyTo: "…"). Entonces tampoco pasa inadvertido el intercambio de la primera tarjeta.

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?

var builder = new Email.Builder()
.To("maria@example.com")
.Subject("El pedido 1042 ha sido enviado")
.Body("Tu paquete está en camino.");
Email first = builder.Build();
builder.HighPriority();
Email second = builder.Build();
Console.WriteLine($"{first.HighPriority} {second.HighPriority}");
Respuesta

Pruébalo

Encuentra el error

La tienda envía los correos de los pedidos enviados en un bucle. Solo el primer pedido tiene factura:

var orders = new List<Order>
{
new("maria@example.com", 1042, "invoice-1042.pdf"),
new("peter@example.com", 1043, null),
};
var builder = new Email.Builder().ReplyTo("support@shop.example");
foreach (var order in orders)
{
builder.To(order.Email)
.Subject($"El pedido {order.Id} ha sido enviado")
.Body("Tu paquete está en camino.");
if (order.Invoice != null)
{
builder.Attachment(order.Invoice);
}
builder.Build().Send();
}
Salida
Para: maria@example.com
Responder a: support@shop.example
Asunto: El pedido 1042 ha sido enviado
Adjunto: invoice-1042.pdf
Para: peter@example.com
Responder a: support@shop.example
Asunto: El pedido 1043 ha sido enviado
Adjunto: invoice-1042.pdf

Peter recibió la factura de María. ¿En qué línea está el error?

Elige la línea con el error.

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;
  • required e init con un inicializador de objeto hacen 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:

Salida
Paquete para María, 0888 123 456
Dirección: Calle Mayor 5, Madrid
Peso: 2.5 kg, frágil
Contra reembolso: 42.90 €
El peso debe ser mayor que 0 y de hasta 30 kg

Má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 Shipment como en la tarjeta «Sin builder» y compara las dos versiones. ¿Cuál es más corta, y qué pierdes?

Test

Ponte a prueba

1. new Email("support@…", "maria@…", …) compila, y el correo va a otro sitio. ¿Por qué no ayudó el compilador?
2. ¿Qué hace build()?
3. El builder se crea antes del bucle y se usa para cada correo. El segundo correo recibió el adjunto del primero. ¿Por qué?
4. ¿Cuándo vale la pena Builder de todos modos?
5. ¿Qué hace en C# casi lo mismo que Builder, sin una segunda clase?

Creado por Ivo Gergov como parte de la campaña de Evo Tech por una educación accesible en programación ·