2 de marzo de 2022

Patrón de diseño Builder



Builder es un patrón de diseño creacional con ámbitos de objeto. Nos ayuda a crear objetos complejos de una forma sencilla usando un procedimiento de "paso por paso". Esto facilita el trabajo enormemente cuando tenemos objetos con muchos atributos, pero no necesitamos establecer de inicio todos sus valores, o no siempre establecemos los mismos. De esta forma nos guía en la construcción de estos obejtos. También ayuda en el ensamblado de objetos complejos que están formados por otros objetos.

Debido a esto, este patrón es una de las formas más utilizadas para la creación de objetos; ya que es un elemento externo a los objetos que estamos creando, pero con la suficiente información para ayudarnos en el ensamblaje correcto de estos

En este tutorial te explico los detalles de este patrón, y te muestro tres estrategias diferentes de implementación.

El patrón Builder permite la construcción de objetos de dos formas que, aunque a primera vista podrían parecer diferentes, ambas siguen la esencia del patrón.

Con la primera estrategia buscamos delegar la construcción de objetos complejos a dos elementos externos, el Builder y el Director, para que no nos preocupemos de los detalles de la construcción de este. Esto sirve cuando ese objeto complejo debe estar ensamblado con un cuidado y precisión milimétrica (sí, suena exagerado pero muchas veces esto es un requisito para el correcto funcionamiento del sistema). El objeto Director hace uso del Builder para ensamblar el objeto y entregárnoslo listo para su uso.

Con la segunda estrategia no hay un Director y el Builder actúa también como un objeto externo para la construcción, pero en este caso nos ayuda guiándonos (mostrándonos o limitando las opciones que tenemos) sobre cómo construir de forma correcta el objeto. Esta segunda estrategia sirve cuando el objeto que queremos construir tiene muchas opciones o posibilidades correctas para su construcción; en otras palabras, la inicialización de este es flexible en cuanto a los parámetros que necesitamos para su construcción.

Si no usamos este patrón podemos lograr un efecto algo parecido, proporcionando muchos constructores en nuestra clase, y de esta forma el usuario puede elegir el constructor que más se adecúe a sus necesidades. Sin embargo, el hacerlo así haría que el mantenimiento de la clase sea muy complejo y la construcción de esta termine volviéndose algo obscura. Vemos esto con un ejemplo sencillo.

Imagina que tenemos una clase Usuario, la cual tiene los siguientes atributos:
private String nombre;
private String apellido;
private int edad;
private int numeroDepartamento;
private String apodo;
private String comidaFavorita;

La clase proporciona una serie de constructores para que no sea necesario pasar todos los valores de los atributos, y en un momento dado nos encontramos con esto:
new Usuario ("Alfonso", "Olaguibert", 34, 42, "pollo");

Con solo eso es difícil saber, a primera vista, qué valores estamos proporcionando. ¿"Alfonso" es nombre o apellido? ¿Cuál es la edad y cuál el número de departamento?

A primera vista es difícil responder a alguna (o todas) las preguntas anteriores ya que para hacerlo necesitamos conocer el orden en el que debemos proporcionar los valores a la versión anterior del constructor (la cual puede variar entre las distintas versiones sobrecargadas del constructor). Claro, siempre tenemos la ayuda contextual que nos da el IDE, pero eso hace que tengamos que detenernos (aunque sea unos segundos) a analizar y entender lo que estamos viendo.

Podemos facilitar un poco el proceso anterior si usamos variables en lugar de constantes, de esta forma:
String nombre = "Olaguibert";
String apellido = "Alfonso";
int edad = 34;
int numeroDepartamento = 42;
String apodo = "pollo";

new Usuario (apellido, nombre, edad, numeroDepartamento, apodo);

Pero esto solo refuerza lo que dije al inicio: hace que sea más complejo el mantenimiento del código.

Veremos cómo este patrón nos ayuda a simplificar casos como el anterior (y mucho peores).


Objetivo o intención del patrón

Como podemos ver, el objetivo de este patrón es:
  • Separar la construcción de un objeto complejo de su representación, de forma tal que con el mismo proceso se puedan crear diferentes representaciones.

Esto quiere decir que podemos tener dos instancias, de Usuario siguiendo el ejemplo anterior, y que a pesar de que usemos el mismo proceso de construcción las dos instancias son diferentes; y no hablando solo de que son dos instancias diferentes de la misma clase, sino que sus valores internos (su estado) es diferente también. Esto se verá más claramente en los ejemplos.


Implementación

Antes de ver los detalles en código, un poco de teoría de cómo implementar el patrón. Algunos de estos pasos serán opcionales dependiendo de la estrategia de implementación.

  1. Definir un tipo de objeto complejo de crear. Este será el tipo del objeto que obtendremos como resultado del uso del Builder. Puede ser un tipo abstracto o concreto.
  2. Si el tipo es abstracto, crear una representación concreta.
  3. Definir un tipo Builder. Este tendrá los pasos para la construcción del objeto concreto. Dependiendo de la implementación puede ser abstracto o concreto.
  4. Si el Builder es abstracto, definir un tipo concreto.
  5. Definir una clase que sirva como Director y que controle los pasos para la creación del objeto. Dependiendo de la estrategia, el Builder puede funcionar también como el Director.
Sí, sé que ahora todos los pasos suenan un poco confusos, pero todo quedará explicado y más claro con los diagramas y los ejemplos en las siguientes secciones.

Te recomiendo que una vez que hayas visto los diagramas con sus correspondientes explicaciones y hayas visto los ejemplos en código vuelvas a leer los pasos de la implementación para que todo quede mucho más claro.


Diagrama

Tengo que aclarar algo importante sobre los diagramas y las estrategias antes de ir a la explicación.

El diagrama clásico que encontrarás si buscas este patrón es el siguiente, al cual llamaré Estrategia A.
📌Nota: Estos nombres (estrategia A, B y C) son nombres que yo le estoy dando para diferenciar las formas de implementar el patrón, no son nombres "oficiales" de las diferentes estrategias.


No hay nada de malo con el diagrama anterior; sin embargo, este representa solo una de las estrategias de Builder y no da ni una pista de las otras dos implementaciones (más simples). Así que me he dado a la tarea de crear la representación de las otras dos estrategias de Builder.

Para la Estrategia B el diagrama es el siguiente:

Y para la Estrategia C tenemos:



He comenzado con el diagrama de la estrategia A porque, como lo mencioné, este es el diagrama que encontrarás normalmente en Internet. Pero, comenzaré explicando de atrás hacia adelante, es decir de la estrategia C a la A, ya que me parece que es más fácil entender la utilidad del patrón de esta forma. Haré lo mismo con los ejemplos.

🏆 Es importante que leas con cuidado cada estrategia, ya que explico algunas variaciones de cada una de ellas para poder mostrar la enorme flexibilidad que tiene este patrón.

Nuevamente echa un vistazo al diagrama de la Estrategia C.

En la Estrategia C, el Cliente necesita una instancia de la clase Producto pero, si observas bien, el constructor de Producto no tiene ningún modificador de acceso. Eso quiere decir que Producto solo puede ser construido desde clases que se encuentran en el mismo paquete. Lo más seguro es que Cliente se encuentre en un paquete distinto al de Producto.

Por otro lado, tenemos la clase Builder (que en este caso es concreta pero también podría ser una interface). Builder está en una situación similar a Producto, tiene un constructor sin modificador de acceso. Lo único con lo que cuenta Builder es con una serie de métodos que permiten establecer los valores de los atributos de Producto, representados en el diagrama con el método atributo (el cual en la implementación será reemplazado por cada uno de los nombres de los atributos de la clase Producto), y un método build que regresa una instancia de Producto con los valores establecidos.

Así que, el Cliente necesita del Builder para crear una instancia de Producto, pero no se puede crear una instancia de Builder directamente. ¿Qué podemos hacer en este caso? Bien, la solución se encuentra en la clase Producto, tiene un método estático, builder que regresa una nueva instancia de Builder. Con esta instancia podemos comenzar la construcción, la cual sería algo así:
Builder builder = Producto.builder();
Producto producto = builder.nombre("...").precio(0.0).categoria("...").build();


Los métodos nombre, precio y categoria son los que reemplazan el método atributo en el diagrama del patrón.

En este ejemplo el Builder es una clase concreta, pero bien podría ser una interface. Esto daría la ventaja de que no necesariamente debemos usar siempre la misma clase Builder, podríamos (a través de un patrón Factory) utilizar diferentes Builders dependiendo de distintas condiciones o situaciones.

Este patrón tiene la ventaja de que podemos extenderlo para guiar la construcción de objetos complejos. Pensemos que tenemos una clase Usuario que tiene una Direccion y varios Telefonos asociados, algo similar a esto:



En el diagrama anterior podemos ver que usamos un UsuarioBuilder para construir el Usuario, y el Builder ayuda también a construir las instancias correspondientes de Direccion y Telefono a través de un DireccionBuilder y un TelefonoBuilder. Cuando invocamos el método build de estos dos últimos, obtenemos de regreso un UsuarioBuilder. Al final incluso podemos validar que tengamos por lo menos un teléfono y una dirección (de hecho, esto es lo que haremos en el ejemplo del tutorial).

Esta estrategia es muy flexible y existen algunas variaciones la misma en las que el constructor de Usuario recibe al UsuarioBuilder y lo usa para inicializar sus datos, similar a esta forma, en la que solo dejo las partes más importantes del código:

public class Usuario {	

    private String nombre; 
    private String username; 
    private int edad; 
    private String password;

    Usuario(UsuarioBuilder builder) {
        this.nombre = builder.getNombre();
		this.username = builder.getUsername();
		this.edad = builder.getEdad();
		this.password = builder.getPassword();
    }
	
	public static UsuarioBuilder builder(){
		return new UsuarioBuilder();
	}
}

public class UsuarioBuilder {
    private String nombre; 
    private String username; 
    private int edad; 
    private String password;

    UsuarioBuilder() {

    }

    public Usuario build() {
        Usuario usuario = new Usuario(this);
		return usuario;
    }
}


Y lo usaríamos de la siguiente forma:
Usuario usuario = Usuario.builder()
	.edad(30)
	.username("javatutoriales")
	.nombre("Programador Java")
    .password("123456789")
	.build();

Con Builder incluso podemos forzar el que se establezcan los atributos obligatorios. Digamos que los campos nombre y username del Usuario son obligatorios. Lo único que debemos hacer es solicitarlos en el constructor de UsuarioBuilder:

UsuarioBuilder(String nombre, String username) {
    this.nombre = nombre;
    this.username = username;
}

Y lo usaríamos de esta forma:
Usuario usuario = Usuario.builder("Programador Java", "javatutoriales")
	.edad(30) 
    .password("123456789")
	.build();

Ahora hablemos de la Estrategia B.

La Estrategia B es muy parecida, al menos en los conceptos y las ideas de la Estrategia C. Veamos nuevamente su diagrama:

Prácticamente la única diferencia que existe en esta estrategia es que el Builder es una clase anidada dentro de la clase Producto. Esta estrategia ayuda mucho si siempre tendremos una sola forma de crear el Producto (lo cual es, a mi parecer, lo normal).

Con esta estrategia también podemos usar la variación en la que el constructor de Usuario recibe al UsuarioBuilder, pero con las clases anidadas podemos tomarnos algunas libertades en cuanto al uso de los modificadores de acceso. El constructor de UsuarioBuilder puede ser público. Sería algo como:
public class Usuario {	

    private String nombre; 
    private String username; 
    private int edad; 
    private String password;

    private Usuario(UsuarioBuilder builder) {
        this.nombre = builder.nombre;
		this.username = builder.username;
		this.edad = builder.edad;
		this.password = builder.password;
    }

	public static class UsuarioBuilder {
		private String nombre; 
		private String username; 
		private int edad; 
		private String password;

		public UsuarioBuilder() {

		}

		public Usuario build() {
			Usuario usuario = new Usuario(this);
			return usuario;
		}
	}
}


Y lo usaríamos de la siguiente forma:
Usuario usuario = new Usuario.UsuarioBuilder()
	.edad(30)
	.username("javatutoriales")
	.nombre("Programador Java")
    .password("123456789")
	.build();

Toda la explicación que dimos de las variaciones de la estrategia C aplica a la estrategia B, con la excepción de que aquí el Builder no puede ser una interface, forzosamente debemos usar una clase concreta.

Finalmente, hablemos de la Estrategia A. Veamos nuevamente el diagrama:



En esta estrategia tenemos un elemento adicional.
  • El Producto representa al objeto complejo que queremos generar.
  • El Builder es una interface o clase abstracta que define todos los pasos que se pueden seguir para crear de forma correcta el Producto.
  • El BuilderConcreto representa a una de varias posibles clases concretas que heredan de o implementan la interface Builder. Estas clases concretas contienen la lógica particular para crear el Producto. ¿Por qué podríamos necesitar varios tipos BuilderConcreto? Este es el secreto de la estrategia A, y es que podemos usar diferentes procesos de construcción o distintos "materiales" dependiendo de las características del Producto que queramos crear.
  • El Director es la parte menos clara de esta estrategia; este define el orden en el que se realizarán los pasos del proceso de construcción. Por lo tanto, controla el algoritmo para la creación del Producto final. Es a través del Director que usamos el BuilderConcreto para la construcción del Producto, y la interacción para obtener el Producto ocurre entre el Director y el BuilderConcreto. El Director también permite tener diferentes configuraciones de Productos.

Como te estarás imaginando, existen algunas variaciones de esta Estrategia, en algunas le damos más peso al Director y en otras más peso a los distintos BuilderConcretos.

Veamos rápidamente dos de estas variaciones, en la primera le daremos más peso a los BuilderConcretos.

Digamos que tenemos como Producto una Casa. Todas las Casas tienen más o menos los mismos elementos: unos cimientos, una estructura, techo, e interior. Para facilitar la explicación, todos los atributos serán de tipo String, pero bien podrían ser interfaces o clases abstractas para que pudiéramos tener distintos tipos de Cimiento, Techo, etc.

public class Casa {
    private String cimientos;
    private String estructura;
    private String techo;
    private String interior;
}

La interface CasaBuilder permite establecer los valores de cada uno de los atributos definidos y obtener la Casa final ensamblada:
public interface CasaBuilder {
 
    public void construyeCimientos();
 
    public void construyeEstructura();
 
    public void construyeTecho();
 
    public void construyeInterior();
 
    public Casa getCasa();
}

Antes de pasar a los Builder concretos veamos al Director, que en este caso estará representado por un IngenieroCivil, quien ejecutará los pasos para la construcción de la Casa:
class IngenieroCivil {
 
    private CasaBuilder casaBuilder;
 
    public IngenieroCivi(CasaBuilder casaBuilder){
        this.casaBuilder = casaBuilder;
    }
 
    public Casa getCasa(){
        return this.casaBuilder.getCasa();
    }
 
    public void construyeCasa(){
        this.casaBuilder.construyeCimientos();
        this.casaBuilder.construyeEstructura();
        this.casaBuilder.construyeTecho();
        this.casaBuilder.construyeInterior();
    }
}

Como podemos ver, el IngenieroCivil recibe una instancia de CasaBuilder en su constructor. Esto permite que, por composición, el IngenieroCivil pueda construir distintos tipos de Casa.

Vemos ahora un par de ejemplos de implementaciones de CasaBuilder. Pensemos que podemos construir dos tipos de Casa, un Iglú y una casa de Madera. Así que tenemos dos clases concretas, la primera es IgluBuilder:
class IgluBuilder implements CasaBuilder {
    private Casa casa;
 
    public IgluBuilder() {
        this.casa = new Casa();
    }
 
    public void construyeCimientos() {
        casa.cimientos = "Barillas de hielo";
    }
 
    public void construyeEstructura(){
        casa.estructura = "Bloques de hielo";
    }
 
    public void construyeInterior() {
        casa.interior = "Decoraciones de hielo";
    }
 
    public void construyeTecho() {
        casa.techo = "Domo de hielo";
    }
 
    public Casa getCasa(){
        return this.casa;
    }
}

Y ahora, para la casa de madera:
class CasaMaderaBuilder implements CasaBuilder {

    private Casa casa;
 
    public CasaMaderaBuilder() {
        this.casa = new Casa();
    }
 
    public void construyeCimientos() {
        casa.cimientos = "Vigas de madera";
    }
 
    public void construyeEstructura(){
        casa.estructura = "Tablones de madera";
    }
 
    public void construyeInterior() {
        casa.interior = "Decoraciones grabadas";
    }
 
    public void construyeTecho() {
        casa.techo = "Tablones de madera";
    }
 
    public Casa getCasa(){
        return this.casa;
    }
}

Al pasar una instancia diferente de CasaBuilder al IngenieroCivil este seguirá el proceso de construcción y creará una Casa diferente dependiendo del Builder.

Su uso sería de la siguiente forma:
CasaBuilder builder = new IgluBuilder();
IngenieroCivil ingeniero = new IngenieroCivil(builder);
 
ingeniero.construyeCasa();
 
Casa casa = ingeniero.getCasa();

El proceso es igual, aunque el Builder hará que los materiales usados para construir la Casa sean diferentes.

Veamos una variación de esta estrategia en la cual le damos una importancia mayor al Director.

Digamos que nuestro Producto es un Curso y un Curso esta formado por diversas Partes, así que podemos tener Partes como: Introduccion, Evaluacion, Ejemplo, Contenido, ExamenFinal.

Ahora el componente de Director será implementado por la clase Instructor, el cual puede armar distintos tipos de Cursos. Para no complicar la explicación lo dejaremos en dos tipos, un curso gratuito y un curso pagado. Ambos generarán objetos Curso pero sus Partes establecidas serán diferentes, los cursos gratuitos constarán de Introduccion, Ejemplo y Evaluación; los cursos pagados estarán formados por todas las partes.

Omitiré algunos (muchos) detalles en el siguiente código para centrarme solo en las partes importantes; un Curso debería estar formado por más detalles como el nombre, las descripciones, el tema, etc.; pero me centro solo en la información referente al patrón. El código sería algo como:
public interface CursoBuilder {
	addIntroduccion(Introduccion introduccion);
	
	addContenido(Contenido contenido);
	
	addEvaluacion(Evaluacion evaluacion);

	addEjemplo(Ejemplo ejemplo);

	addExamenFinal(ExamenFinal examenFinal);

	Curso build();
}

Y el Instructor algo como:
public class Instructor {
	private CursoBuilder builder;

	public void setBuilder(CursoBuilder builder){
		this.builder = builder;
	}
	
	public Curso creaCursoGratuito(){
		builder.addIntroduccion(new Introduccion());
		builder.addEjemplo(new Ejemplo());
		builder.addEvaluacion(new Evaluacion());

		return builder.build();
	}

	public Curso creaCursoPagado(){
		builder.addIntroduccion(new Introduccion ());
	
		builder.addContenido(new Contenido());

		builder.addEjemplo(new Ejemplo());
	
		builder.addEvaluacion(new Evaluacion());

		builder.addExamenFinal(new ExamenFinal());

		return builder.build();
	}	
}

Y su uso sería:
CursoBuilder builder = new CursoBuilderConcreto();
Instructor instructor = new Instructor();
instructor.setBuilder(builder);

Curso cursoGratuito = instructor.creaCursoGratuito();

La Estrategia C puede tener algunas variaciones más, pero estas que hemos comentado con las más comunes.

En muchos lugares he visto que, aunque usan el diagrama de la estrategia A, la explicación que dan corresponde a la estrategia C, 🤦‍♂️. Así que, por favor, tú no te confundas 😊.

He intentado mostrar bastante código en esta sección, además de para aclarar cada una de las Estrategias, porque en los ejemplos solo mostraré 2, la estrategia C y la estrategia B.

¿Dónde se usa Builder en el JDK?

Builder se usa en la clase StringBuilder (supongo que de ahí viene el nombre). Cada vez que invocamos el método append recibimos una nueva instancia de StringBuilder. Intérname va generando una nueva cadena la cual obtenemos al invocar el método toString (que es el equivalente de build).

Además, también podemos encontrarlo en Stream.Builder

Ejemplo

Para los ejemplos uso Java 17 y Gradle. No uso ninguna característica particular de esta versión de Java, por lo que debe funcionar en cualquier versión (incluso en la 5). También, uso Lombok para evitar escribir código repetitivo de forma innecesaria.

Para validar el correcto funcionamiento de la aplicación usaré JUnit y AssertJ. Agrega las siguientes dependencias en el archivo build.gradle (si usas Maven, coloca las dependencias correspondientes en el archivo pom.xml).

dependencies {
    compileOnly 'org.projectlombok:lombok:1.18.22'
    annotationProcessor 'org.projectlombok:lombok:1.18.22'
 
    testImplementation 'org.junit.jupiter:junit-jupiter-api:5.8.1'
    testImplementation 'org.assertj:assertj-core:3.21.0'
    testRuntimeOnly 'org.junit.jupiter:junit-jupiter-engine:5.8.1'
}

Ahora sí, al código.

Primero mostraré un ejemplo de la Estrategia C. De hecho, será el mismo del que hablé en el diagrama. Crearemos un Usuario el cual tiene una Direccion y varios Telefonos. El Usuario tendrá dos atributos obligatorios: nombre y username, y varios atributos opcionales. La Dirección también tendrá dos atributos obligatorios: ciudad y codigoPostal. En el caso del Telefono todos sus atributos son obligatorios. Después de construir al Usuario validaremos que tenga al menos un Telefono y una Direccion.

Comenzamos entonces con la clase Usuario. Esta tiene dos atributos obligatorios, nombre y username. El resto de sus atributos son opcionales. Los atributos obligatorios los marcaré como final. Coloco también un atributo de tipo Direccion y un Set de tipo Telefono:
@Data
public class Usuario {
    private final String nombre;
    private final String username;
    private String password;
    private short edad;
    private String nickname;

    private Direccion direccion;
    private Set<Telefono> telefonos;
}

Como los primeros dos atributos son obligatorios, crearé un constructor que obligue a establecerlos al momento de crear una nueva instancia:
📌Nota: Estoy creando el constructor de forma explícita para tener mayor claridad del tutorial, podría usar la anotación @lombok.RequiredArgsConstructor para que este constructor sea creado automáticamente por Lombok.
Usuario(String nombre, String username) {
    this.nombre = nombre;
    this.username = username;
}

El constructor no es público, de hecho no tiene modificar de acceso. Eso quiere decir que solo puede ser usado por clases que se encuentren en el mismo paquete que Usuario.

Dejaremos por el momento esta clase y regresaremos a ella un poco más adelante. Por ahora, creamos las otras dos clases de datos: Direccion y Telefono siguiendo el mismo patrón, los atributos obligatorios los marcamos como final y creamos un constructor sin modificador de acceso que establezca estos valores:
@Data
public class Direccion {
    private final String ciudad;
    private final String codigoPostal;
    private String calle;
    private int numeroExterior;
    private int numeroInterior;

    Direccion(String ciudad, String codigoPostal) {
        this.ciudad = ciudad;
        this.codigoPostal = codigoPostal;
    }
}

@Data
public class Telefono {
    private final String numero;
    private final TipoTelefono tipoTelefono;

    Telefono(String numero, TipoTelefono tipoTelefono) {
        this.numero = numero;
        this.tipoTelefono = tipoTelefono;
    }
}

Ahora pasamos a la parte interesante del ejemplo. Creamos una nueva clase UsuarioBuilder en el mismo paquete en el que se encuentra la clase Usuario. Esta clase tiene un solo atributo, el Usuario que será creado, al cual le estableceremos sus valores, y que regresaremos una vez que terminemos su construcción.
public class UsuarioBuilder {
    private final Usuario usuario;
}

El Builder tendrá un constructor sin modificador de acceso y recibirá dos parámetros, los necesarios para crear la instancia de Usuario. Dentro del constructor creamos la nueva instancia de Usuario (lo cual podemos hacer ya que ambas clases están en el mismo paquete) e inicializamos sus valores:
UsuarioBuilder(String nombre, String username) {
    this.usuario = new Usuario(nombre, username);
    this.usuario.setTelefonos(new HashSet<>());
}

Ahora, creamos una serie de métodos públicos que permitan establecer el valor de cada uno de los atributos no obligatorios del usuario (password, edad y nickname). Algo importante es que, por convención, estos métodos deben tener el mismo nombre que la propiedad que van a establecer (así es, aunque son una especie de métodos setter estos no comienzan por la palabra set), es por eso que en diagrama los representamos como "atributo()". Además de establecer el valor del atributo correspondiente del usuario, también regresarán la misma instancia de UsuarioBuilder con la que estamos trabajando (o sea, this). Esto facilitará el encadenar las llamadas a los métodos para simplificar la estructura del código:
public UsuarioBuilder password(String password) {
    usuario.setPassword(password);

    return this;
}

public UsuarioBuilder edad(short edad) {
    usuario.setEdad(edad);

    return this;
}

public UsuarioBuilder nickname(String nickname) {
    usuario.setNickname(nickname);

    return this;
}

Aún falta establecer dos valores importantes del usuario, la Direccion y el Telefono. Para ello vamos a crear dos nuevos métodos, estos no serán públicos sino que también tienen un nivel de acceso por default, ya que buscamos que solo los Builders correspondientes puedan usarlos. Serán como métodos auxiliares para establecer esos valores. En el caso del telefono, como podemos tener más de uno, lo que haremos es agregar un nuevo teléfono al Set de teléfonos del usuario:
void addTelefono(Telefono telefono) {
    usuario.getTelefonos().add(telefono);
}

void addDireccion(Direccion direccion) {
    usuario.setDireccion(direccion);
}

Por el momento dejamos esta clase como está y creamos una nueva clase DireccionBuilder. Esta sigue el mismo patrón inicial que UsuarioBuilder: tiene un constructor sin modificador de acceso y en su constructor recibe un objeto de tipo UsuarioBuilder, ya que al momento de terminar de construir la instancia de Direccion debemos continuar con el proceso de construcción del Usuario usando el Builder original y para eso necesitamos mantener una referencia a este:
public class DireccionBuilder {

    private final UsuarioBuilder usuarioBuilder;
    private final Direccion direccion;

    DireccionBuilder(UsuarioBuilder usuarioBuilder, String ciudad, String codigoPostal) {
        this.direccion = new Direccion(ciudad, codigoPostal);
        this.usuarioBuilder = usuarioBuilder;
    }
}

A continuación, agregamos un método para establecer cada uno de los valores de los atributos opcionales de Direccion. Seguimos la misma convención de hace un momento, el nombre del método será el mismo nombre del atributo que queremos establecer y además de establecer el valor correspondiente en la instancia de Direccion regresará una referencia al mismo DireccionBuilder que estamos usando:
public DireccionBuilder calle(String calle) {
    direccion.setCalle(calle);
    return this;
}

public DireccionBuilder numeroExterior(int numeroExterior) {
    direccion.setNumeroExterior(numeroExterior);
    return this;
}

public DireccionBuilder numeroInterior(int numeroInterior) {
    direccion.setNumeroInterior(numeroInterior);
    return this;
}

Una vez que hayamos establecido los valores correspondientes de Direccion y estemos listos para obtener su instancia debemos invocar un método que, por convención, llamamos build. Este método establecerá el valor de la nueva Direccion en el Usuario que hemos estado construyendo. Para eso usamos el método auxiliar addDireccion de UsuarioBuilder. También, como después de crear y establecer la Direccion queremos continuar con la construcción del Usuario, debemos regresar la referencia al UsuarioBuilder original:
public UsuarioBuilder build() {
    usuarioBuilder.addDireccion(this.direccion);
    return usuarioBuilder;
}

La clase DireccionBuilder completa queda de la siguiente forma:
public class DireccionBuilder {

    private final UsuarioBuilder usuarioBuilder;
    private final Direccion direccion;

    DireccionBuilder(UsuarioBuilder usuarioBuilder, String ciudad, String codigoPostal) {
        this.direccion = new Direccion(ciudad, codigoPostal);
        this.usuarioBuilder = usuarioBuilder;
    }

    public DireccionBuilder calle(String calle) {
        direccion.setCalle(calle);
        return this;
    }

    public DireccionBuilder numeroExterior(int numeroExterior) {
        direccion.setNumeroExterior(numeroExterior);
        return this;
    }

    public DireccionBuilder numeroInterior(int numeroInterior) {
        direccion.setNumeroInterior(numeroInterior);
        return this;
    }

    public UsuarioBuilder build() {
        usuarioBuilder.addDireccion(this.direccion);
        return usuarioBuilder;
    }
}

Ahora, ya tenemos una forma de crear una nueva instancia de Direccion y de establecer esa instancia en el Usuario. Lo único que nos hace falta es una forma de obtener una referencia a DireccionBuilder. Para lograrlo creamos un nuevo método público en la clase UsuarioBuilder el cual recibirá los parámetros obligatorios para crear la instancia de DireccionBuilder. Una vez creada esta instancia la regresará como valor de retorno del método:
public DireccionBuilder direccion(String ciudad, String codigoPostal) {
    return new DireccionBuilder(this, ciudad, codigoPostal);
}

Creo que esto puede parecer un poco confuso en este punto, pero cuando comencemos a construir al Usuario en unos momentos quedará mucho más claro el por qué lo hacemos de esta forma.

Creamos la clase TelefonoBuilder la cual, como puedes imaginar por el nombre, será el Builder que usaremos para construir los objetos de tipo Telefono. Este Builder sigue el mismo patrón inicial que UsuarioBuilder y DireccionBuilder: tiene un constructor sin modificador de acceso que recibe los parámetros obligatorios para crear un nuevo Telefono y también recibe un objeto de tipo UsuarioBuilder:
class TelefonoBuilder {
    private final Telefono telefono;
    private final UsuarioBuilder usuarioBuilder;

    TelefonoBuilder(UsuarioBuilder usuarioBuilder, String numero, TipoTelefono tipoTelefono) {
        this.telefono = new Telefono(numero, tipoTelefono);
        this.usuarioBuilder = usuarioBuilder;
    }
}

Como en este caso desde el constructor creamos el Telefono estableciendo todos sus valores, lo único que nos queda es implementar el método que relaciona la instancia de Telefono que acabamos de construir con el Usuario. Como ya sabemos, por convención el nombre de este método es build. Cuando sea invocado vamos a agregar un teléfono al Set de teléfonos del usuario usando el método auxiliar addTelefono de la clase UsuarioBuilder. Al final de la invocación regresamos el UsuarioBuilder con el que estábamos trabajando:
public UsuarioBuilder build(){
    usuarioBuilder.addTelefono(this.telefono);

    return usuarioBuilder;
}

La clase completa queda de la siguiente forma:
class TelefonoBuilder {
    private final Telefono telefono;
    private final UsuarioBuilder usuarioBuilder;

    TelefonoBuilder(UsuarioBuilder usuarioBuilder, String numero, TipoTelefono tipoTelefono) {
        this.telefono = new Telefono(numero, tipoTelefono);
        this.usuarioBuilder = usuarioBuilder;
    }

    public UsuarioBuilder build(){
        usuarioBuilder.addTelefono(this.telefono);

        return usuarioBuilder;
    }
}

Nuevamente, ya tenemos una forma de crear el teléfono (usando TelefonoBuilder), de asociar el nuevo teléfono con el usuario (usando el método auxiliar addTelefono), lo único que nos falta en este momento es una forma de obtener un TelefonoBuilder. Agregamos un nuevo método en UsuarioBuilder al cual llamamos telefono. Este método recibirá los parámetros obligatorios para crear el nuevo Telefono. Como en el caso de Telefono todos sus parámetros son obligatorios y al momento de crear el Builder ya tenemos todos los datos necesarios para obtener y establecer la nueva instancia, dentro de este mismo método invocamos al método build y regresamos al UsuarioBuilder. Eso quiere decir que el Cliente nunca usará o trabajará directamente con TelefonoBuilder:
public UsuarioBuilder telefono(String numero, TipoTelefono tipoTelefono) {
    return new TelefonoBuilder(this, numero, tipoTelefono).build();
}

Ya tenemos una forma de establecer los valores del Usuario, de la Direccion y del Telefono. Ahora solo falta parte final del Builder en donde obtenemos el objeto Usuario completamente ensamblado con los valores que hemos establecido. Para esto creamos el método build de UsuarioBuilder. En el enunciado del problema indicamos que debemos validar que el Usuario tenga una Direccion y al menos un Telefono establecido. Estas validaciones las realizamos dentro del mismo método build antes de regresar la instancia de Usuario:
public Usuario build() {
    validaUsuario();

    return this.usuario;
}

private void validaUsuario() {
    if (usuario.getDireccion() == null || usuario.getTelefonos().isEmpty()) {
        String mensaje = String.format("El usuario debe tener una dirección y al menos un telefono. Dirección: %s. Teléfono: %s",
                usuario.getDireccion(), usuario.getTelefonos());

        throw new IllegalStateException(mensaje);
    }
}

La clase UsuarioBuilder completa queda así:
public class UsuarioBuilder {
    private final Usuario usuario;

    UsuarioBuilder(String nombre, String username) {
        this.usuario = new Usuario(nombre, username);
        this.usuario.setTelefonos(new HashSet<>());
    }

    public UsuarioBuilder password(String password) {
        usuario.setPassword(password);

        return this;
    }

    public UsuarioBuilder edad(short edad) {
        usuario.setEdad(edad);

        return this;
    }

    public UsuarioBuilder nickname(String nickname) {
        usuario.setNickname(nickname);

        return this;
    }

    public UsuarioBuilder telefono(String numero, TipoTelefono tipoTelefono) {
        return new TelefonoBuilder(this, numero, tipoTelefono).build();
    }

    public DireccionBuilder direccion(String ciudad, String codigoPostal) {
        return new DireccionBuilder(this, ciudad, codigoPostal);
    }

    void addTelefono(Telefono telefono) {
        usuario.getTelefonos().add(telefono);
    }

    void addDireccion(Direccion direccion) {
        usuario.setDireccion(direccion);
    }

    public Usuario build() {
        validaUsuario();

        return this.usuario;
    }

    private void validaUsuario() {
        if (usuario.getDireccion() == null || usuario.getTelefonos().isEmpty()) {
            String mensaje = String.format("El usuario debe tener una dirección y al menos un telefono. Dirección: %s. Teléfono: %s",
                    usuario.getDireccion(), usuario.getTelefonos());

            throw new IllegalStateException(mensaje);
        }
    }
}

Ahora, esta es una forma de implementar esta estrategia en la que al momento de construir el UsaurioBuilder creamos una instancia de Usuario y cada vez que invocamos a los diferentes métodos del Builder vamos estableciendo los valores correspondientes de los atributos de Usuario. Hay una variación de esto en las que almacenamos de forma temporal los valores en el Builder y es hasta que invocamos al método build que creamos la instancia de Usuario. Sería algo parecido a esto:
public class UsuarioBuilder {

    private final String nombre;
    private final String username;
    private String password;
    private short edad;
    private String nickname;

    private Direccion direccion;
    private Set<Telefono> telefonos;
    
    UsuarioBuilder(String nombre, String username) {
        this.nombre = nombre;
        this.username = username;
    }

    public UsuarioBuilder password(String password) {
        this.password = password;
        return this;
    }

    public UsuarioBuilder edad(short edad) {
        this.edad = edad;
        return this;
    }

    public UsuarioBuilder nickname(String nickname) {
        this.nickname = nickname;
        return this;
    }

    public UsuarioBuilder telefono(String numero, TipoTelefono tipoTelefono) {
        return new TelefonoBuilder(this, numero, tipoTelefono).build();
    }

    public DireccionBuilder direccion(String ciudad, String codigoPostal) {
        return new DireccionBuilder(this, ciudad, codigoPostal);
    }

    void addTelefono(Telefono telefono) {
        this.telefonos.add(telefono);
    }

    void addDireccion(Direccion direccion) {
        this.direccion = direccion;
    }

    public Usuario build() {

        Usuario usuario = new Usuario(this.nombre, this.username);
        usuario.setPassword(this.password);
        usuario.setEdad(this.edad);
        usuario.setNickname(this.nickname);
        usuario.setTelefonos(this.telefonos);
        usuario.setDireccion(this.direccion);
        
        validaUsuario(usuario);

        return usuario;
    }

    private void validaUsuario(Usuario usuario) {
        if (usuario.getDireccion() == null || usuario.getTelefonos().isEmpty()) {
            String mensaje = String.format("El usuario debe tener una dirección y al menos un telefono. Dirección: %s. Teléfono: %s",
                    usuario.getDireccion(), usuario.getTelefonos());

            throw new IllegalStateException(mensaje);
        }
    }
}

Ambas variaciones son ligeramente diferentes aunque el resultado final es el mismo. ¿Cuál es mejor? Como siempre digo: depende del problema que estés tratando de resolver. Solo quería presentar esta segunda forma para mostrar que este patrón es bastante flexible.

Ahora validemos el funcionamiento de nuestro Builder. Para eso creamos una nueva clase de pruebas. Haremos tres pruebas; en la primera estableceremos algunos de los valores del Usuario, incluyendo una Direccion y Telefono, y validaremos que el Usuario que obtenemos al final tenga los valores que hemos establecido. En la segunda prueba omitiremos la Direccion, con lo cual esperamos recibir una excepción, y en la tercera no colocaremos ningún Telefono, con lo cual también esperamos recibir una excepción.

Lo primero que debemos hacer es obtener una instancia de UsuarioBuilder. Como vimos, la única forma que tenemos de hacerlo es invocando el método builder de la clase Usuario. Como builder es un método estático podemos hacer la invocación de la siguiente forma:
UsuarioBuilder usuarioBuilder = Usuario.builder("Programador Java", "programadorjava");

Ya con el UsuarioBuilder podemos comenzar a establecer los valores del Usuario llamando a los métodos correspondientes del Builder.
usuarioBuilder.password("123456");
usuarioBuilder.nickname("Programador");

Para agregar un nuevo Telefono solo tenemos que invocar al método telefono:
usuarioBuilder.telefono("12345780", TipoTelefono.MOVIL);
usuarioBuilder.telefono("87654321", TipoTelefono.FIJO);

Y para la Direccion el método direccion:
DireccionBuilder direccionBuilder = usuarioBuilder.direccion("Ficticia", "12345");
direccionBuilder.calle("Calle");
direccionBuilder.numeroExterior(13);
direccionBuilder.build();

Es en el bloque anterior en donde podemos ver de forma clara un ejemplo de cuando decimos que "Builder nos guía en la construcción del objeto". Una vez que indicamos que queremos crear una instancia de Direccion obtenemos un nuevo DireccionBuilder el cual usamos para establecer todos los valores de la Direccion, y únicamente cuando hemos establecido sus valores es cuando podemos continuar con la construcción del Usuario.

Una vez que hemos terminado, obtenemos la instancia de Usuario invocando al método build de UsuarioBuilder:
Usuario usuario = usuarioBuilder.build();

Y listo, el método completo se ve así:
@Test
void usuarioCorrecto(){
    UsuarioBuilder usuarioBuilder = Usuario.builder("Programador Java", "programadorjava");

    usuarioBuilder.password("123456");
    usuarioBuilder.nickname("Programador");

    usuarioBuilder.telefono("12345780", TipoTelefono.MOVIL);
    usuarioBuilder.telefono("87654321", TipoTelefono.FIJO);

    DireccionBuilder direccionBuilder = usuarioBuilder.direccion("Ficticia", "12345");
    direccionBuilder.calle("Calle");
    direccionBuilder.numeroExterior(13);
    direccionBuilder.build();
	
	Usuario usuario = usuarioBuilder.build();
}

Aquí estoy usando la referencia usuarioBuilder en cada uno de los pasos, pero como cada método regresa la misma referencia de usuarioBuilder, puedo encadenar todas las invocaciones:
UsuarioBuilder usuarioBuilder = Usuario.builder("Programador Java", "programadorjava")
        .password("123456")
        .nickname("Programador")
        .telefono("12345780", TipoTelefono.MOVIL)
        .telefono("87654321", TipoTelefono.FIJO)
        .direccion("Ficticia", "12345")
        .calle("Calle")
        .numeroExterior(13).build();

Usuario usuario = usuarioBuilder.build();

Esto es mucho más simple y claro que si usáramos directamente los constructores y métodos setters correspondientes. Además de que es claro qué atributo estamos estableciendo. Recordemos que anteriormente el constructor que teníamos para Usuario era algo así:
new Usuario ("Alfonso", "Olaguibert", 34, 42, "pollo");

Terminemos el método de prueba validando que el usuario obtenido tiene los valores correspondientes.
@Test
void usuarioCorrecto() {
    UsuarioBuilder usuarioBuilder = Usuario.builder("Programador Java", "programadorjava")
            .password("123456")
            .nickname("Programador")
            .telefono("12345780", TipoTelefono.MOVIL)
            .telefono("87654321", TipoTelefono.FIJO)
            .direccion("Ficticia", "12345")
            .calle("Calle")
            .numeroExterior(13).build();

    Usuario usuario = usuarioBuilder.build();

    assertThat(usuario.getNombre()).isEqualTo("Programador Java");
    assertThat(usuario.getUsername()).isEqualTo("programadorjava");
    assertThat(usuario.getPassword()).isEqualTo("123456");

    assertThat(usuario.getTelefonos()).hasSize(2);

    assertThat(usuario.getDireccion().getCiudad()).isEqualTo("Ficticia");
    assertThat(usuario.getDireccion().getCodigoPostal()).isEqualTo("12345");
    assertThat(usuario.getDireccion().getCalle()).isEqualTo("Calle");
    assertThat(usuario.getDireccion().getNumeroExterior()).isEqualTo(13);
}

Si ejecutas el método de prueba anterior puedes comprobar que el Usuario se ha creado de forma correcta.

Finalmente, validemos que se lanza una excepción si no establecemos la Direccion y al menos un Telefono:
@Test
void excepcion_cuandoNoHayDireccion() {
    UsuarioBuilder usuarioBuilder = Usuario.builder("Programador Java", "programadorjava")
            .password("123456")
            .nickname("Programador")
            .telefono("12345780", TipoTelefono.MOVIL)
            .telefono("87654321", TipoTelefono.FIJO);


    assertThatThrownBy(() -> {
        Usuario usuario = usuarioBuilder.build();
    }).isExactlyInstanceOf(IllegalStateException.class);
}

@Test
void excepcion_cuandoNoHayTelefono() {
    UsuarioBuilder usuarioBuilder = Usuario.builder("Programador Java", "programadorjava")
            .password("123456")
            .nickname("Programador")
            .telefono("12345780", TipoTelefono.MOVIL)
            .telefono("87654321", TipoTelefono.FIJO);


    assertThatThrownBy(() -> {
        Usuario usuario = usuarioBuilder.build();
    }).isExactlyInstanceOf(IllegalStateException.class);
}

El diagrama del ejemplo que acabamos de implementar queda de la siguiente forma:



Con esto damos por concluido el ejemplo de la Estrategia C.

Para la Estrategia B haremos un ejemplo parecido, pero mucho más sencillo ya que solo usaremos la clase Usuario excluyendoDireccion y Telefono, ya que creo que con el ejemplo anterior quedó claro cómo podemos construirlos.

Para el ejemplo de la Estrategia B crearemos nuevamente un Usuario usando un Builder. La diferencia será que ahora ese Builder será una clase anidada dentro de la clase Usuario. Así que comenzamos creando la representación del Usuario, el cual tendrá los mismos atributos que en el ejemplo anterior:
@Getter
public class Usuario {
    private final String nombre;
    private final String username;
    private String password;
    private short edad;
    private String nickname;
}

Agregamos un constructor que reciba los atributos obligatorios. Este constructor será private, ya que solo el UsuarioBuilder debe poder crear instancias:
private Usuario(String nombre, String username) {
    this.nombre = nombre;
    this.username = username;
}

Lo siguiente es crear la clase UsuarioBuilder la cual, como había dicho, será una clase anidad dentro de Usuario:
@Data
public class Usuario {

	public static class UsuarioBuilder {

	}	

}

UsuarioBuilder tendrá como único atributo la instancia de Usuario a la cual establecerá sus valores. Además, tendrá un constructor que también será private y recibirá los valores necesarios para inicializar la instancia de Usuario:
@Data
public class Usuario {

	public static class UsuarioBuilder {

		private final Usuario usuario;

		private UsuarioBuilder(String nombre, String username) {
			usuario = new Usuario(nombre, username);
		}
	}
}

El resto es muy parecido a lo que teníamos anteriormente, una serie de métodos para establecer los atributos no obligatorios de Usuario, y un método build que regresa la instancia ensamblada del Usuario. Una de las ventajas de esta estrategia es que no necesitamos métodos setter en los atributos de Usuario, como UsuarioBuilder está dentro de esta clase, puede acceder y establecer directamente los valores de sus atributos:
public static class UsuarioBuilder {

    private final Usuario usuario;

    private UsuarioBuilder(String nombre, String username) {
        usuario = new Usuario(nombre, username);
    }

    public UsuarioBuilder password(String password) {
        usuario.password = password;
        return this;
    }

    public UsuarioBuilder edad(short edad) {
        usuario.edad = edad;
        return this;
    }

    public UsuarioBuilder nickname(String nickname) {
        usuario.nickname = nickname;
        return this;
    }

    public Usuario build() {
        return this.usuario;
    }
}

Lo único que nos hace falta es una forma de obtener una instancia de UsuarioBuilder. Para esto nuevamente creamos un método estático en Usuario que reciba los valores necesarios para inicializar el Builder y regresar su instancia:
public static UsuarioBuilder builder(String nombre, String username){
    return new UsuarioBuilder(nombre, username);
}

Y eso es todo, como vemos esta estrategia es un poco más compacta que la anterior. La clase Usuario completa queda de la siguiente forma:
@Getter
public class Usuario {
    private final String nombre;
    private final String username;
    private String password;
    private short edad;
    private String nickname;

    private Usuario(String nombre, String username) {
        this.nombre = nombre;
        this.username = username;
    }

    public static UsuarioBuilder builder(String nombre, String username){
        return new UsuarioBuilder(nombre, username);
    }

    public static class UsuarioBuilder {

        private final Usuario usuario;

        private UsuarioBuilder(String nombre, String username) {
            usuario = new Usuario(nombre, username);
        }

        public UsuarioBuilder password(String password) {
            usuario.password = password;
            return this;
        }

        public UsuarioBuilder edad(short edad) {
            usuario.edad = edad;
            return this;
        }

        public UsuarioBuilder nickname(String nickname) {
            usuario.nickname = nickname;
            return this;
        }

        public Usuario build() {
            return this.usuario;
        }
    }
}

Lo único que nos falta es validar que la instancia de Usuario efectivamente tiene todos sus valores establecidos al momento de invocar a build. Para ello creamos una prueba unitaria:
@Test
void usuarioCorrecto() {
    Usuario.UsuarioBuilder usuarioBuilder = Usuario.builder("Programador Java", "programadorjava")
            .password("123456")
            .nickname("Programador");

    Usuario usuario = usuarioBuilder.build();

    assertThat(usuario.getNombre()).isEqualTo("Programador Java");
    assertThat(usuario.getUsername()).isEqualTo("programadorjava");
    assertThat(usuario.getPassword()).isEqualTo("123456");
}

Y listo, si ejecutas la prueba anterior podrás verificar que la instancia es creada e inicializada de forma correcta.

Para terminar, este es el diagrama de este ejemplo:



Ahora si lo deseas, puedes regresar a leer las secciones de los diagramas y la implementación del patrón, después de haber visto los ejemplos deben quedar mucho más claros.

Ventajas y desventajas

  • ➕Proporciona una separación clara entre la construcción y la representación o uso de un objeto.
  • ➕Proporciona un mejor control sobre la construcción de un objeto, escondiendo los detalles de su implementación, además de que nos guía en la construcción de este.
  • ➕Tiene muchas estrategias y variaciones para su implementación.
  • ➕Soporta cambios en la representación interna del objeto sin afectar a los clientes que lo usan.
  • ➖Como pudimos ver en los ejemplos, dependiendo de la estrategia se necesita mucho código para poder implementar este patrón.
  • ➖Si lo vamos a usar para diferentes familias de productos, se debe crear (y mantener) una clase Builder por cada tipo concreto.

Otros nombres

Ninguno


Conclusión

Como pudimos ver a lo largo de este tutorial, Builder es un patrón que simplifica mucho la construcción e inicialización de objetos, brindando una forma muy sencilla de establecer tanto los atributos obligatorios como los opcionales, además de hacerlo de una forma muy sencilla y fácil de entender. La realidad es que es más fácil implementarlo y aplicarlo que explicarlo. Este patrón es muy flexible. Vimos tres de las estrategias de implementación más utilizadas, pero aún dentro de las estrategias pudimos ver que existen variaciones a las mismas. Esto nos permite ajustar la implementación de Builder a prácticamente todas nuestras necesidades.

Como nota adicional, en el repositorio dejo, además del código, el archivo original en draw.io con los esquemas de los patrones.

Espero que este tutorial les sea de utilidad. Si tienen alguna duda, sugerencia, comentario o aclaración, pueden dejarla en la sección de comentarios o enviar un correo a programadorjavablog@gmail.com (pueden agregarme al chat de gmail). También puedes seguir JavaTutoriales en las siguientes redes sociales: Saludos y gracias.

Descarga los archivos de este tutorial desde mi repositorio en GitHub:
Entradas relacionadas: