The builder design pattern helps to create instances of classes using class methods rather than the class constructors. It is particularly useful in cases where a class might need multiple versions of its constructor in order to allow different usage scenarios. Moreover, in some cases, it might not even be possible to define all combinations or they might not be known. The builder design pattern uses an extra object, called builder
, in order to receive and store initialization parameters before building the final version of an object.
In this subsection, we will provide a class diagram for the builder pattern the way it was classically defined and the way it looks in other languages, including Java. Later, we will present different versions of the code on the basis of them being more appropriate for Scala and of the observations and discussions we will have around them.
Let's have a Person
class with different parameters: firstName
, lastName
, age
, departmentId
, and so on. We will show the actual code for it in the next subsection. Creating a concrete constructor, especially if those fields might not always be known or required might take too much time. It would also make the code extremely hard to maintain in the future. A builder pattern seems like a good idea and the class diagram for it will look like the following:
As we already mentioned , this is how the builder pattern looks in purely object-oriented languages (not Scala). There could be different representations of it where the builder is abstracted and then, there are concrete builders. The same is true for the product that is being built. In the end, they all aim to get to the same point—make object creation easier.
In the next subsection, we will provide code implementations that will show exactly how to use and write the builder design pattern in Scala.
There are actually three main ways in which we can represent the builder design pattern in Scala:
We will focus on these in the next few subsections. In order to keep things short and simple, we will have fewer fields in our classes; however, it has to be noted that the builder design pattern really shines when there is a large number of fields. You can experiment by adding more fields to the code examples that are provided with this book.
This implementation directly reflects what we have in the previous diagram. First, let's see how our Person
class will look like:
class Person(builder: PersonBuilder) { val firstName = builder.firstName val lastName = builder.lastName val age = builder.age }
As we can see in the preceding code, it takes a builder and uses the values set in the builder for initialization of its fields. The builder code will look like the following:
class PersonBuilder { var firstName = "" var lastName = "" var age = 0 def setFirstName(firstName: String): PersonBuilder = { this.firstName = firstName this } def setLastName(lastName: String): PersonBuilder = { this.lastName = lastName this } def setAge(age: Int): PersonBuilder = { this.age = age this } def build(): Person = new Person(this) }
Our builder has methods that can set each corresponding field of the Person
class. These methods return the same instance of the builder and this allows us to chain many calls together. Here is how we can use our builder:
object PersonBuilderExample { def main(args: Array[String]): Unit = { val person: Person = new PersonBuilder() .setFirstName("Ivan") .setLastName("Nikolov") .setAge(26) .build() System.out.println(s"Person: ${person.firstName} ${person.lastName}. Age: ${person.age}.") } }
This is how to use the builder design pattern. Now we can create a Person
object and provide whatever data we have for it—even if we have a subset of all possible fields, we can specify them and the rest will have a default value. There is no need to create new constructors if other fields are being added to the Person
class. They just need to be made available through the PersonBuilder
class.
The preceding builder design pattern looks nice and clear but it requires writing some extra code and creating boilerplate. Moreover, it requires us to have mutable fields in the PersonBuilder
class, which is against some of the principles in Scala.
Scala has case classes, which make the implementation of the builder pattern much simpler. Here is how it will look like:
case class Person( firstName: String = "", lastName: String = "", age: Int = 0 )
The use of this case class is similar to how the preceding builder design pattern is used:
object PersonCaseClassExample { def main(args: Array[String]): Unit = { val person1 = Person( firstName = "Ivan", lastName = "Nikolov", age = 26 ) val person2 = Person( firstName = "John" ) System.out.println(s"Person 1: ${person1}") System.out.println(s"Person 2: ${person2}") } }
The preceding code is much shorter and easier to maintain than the first version. It allows the developer to do absolutely the same as the original builder pattern, but with a shorter and cleaner syntax. It also keeps the fields of the Person
class immutable, which is a good practice to follow in Scala.
One drawback of the preceding two approaches is that there is no validation. What if some components depended on each other and there are specific variables that require initialization? In the cases that use the preceding two approaches, we could run into runtime exceptions. The next subsection will show us how to make sure that validation and requirement satisfactions are implemented.
In many cases in which we create objects in software engineering, we have dependencies. We either need to have something initialized in order to use a third component, or we require a specific order of initialization, and so on. Both builder pattern implementations we looked at earlier lack the capability to make sure something is or isn't initialized. This way we need to create some extra validation around the builder design pattern in order to make sure everything will work as expected. Yet, we will see whether it is safe to create an object only during runtime.
Using some of the techniques we already looked at earlier in this book, we can create a builder that validates whether all requirements are satisfied during compile time. This is called a type-safe builder and in the next example, we will present this pattern.
First of all, we start with the same classes as we have in the example where we showed the way in which Java uses the builder pattern. Let's now put a constraint on the example and say that every person must have at least firstName
and lastName
specified. In order to make the compiler aware that fields are being set, this needs to be encoded as a type. We will be using ADTs for this purpose. Let's define the following:
sealed trait BuildStep sealed trait HasFirstName extends BuildStep sealed trait HasLastName extends BuildStep
The preceding abstract data types define the different steps of the build progress. Let's now make some refactoring to the builder class and the Person
class:
class Person( val firstName: String, val lastName: String, val age: Int)
We will use the full constructor for the Person
class rather than passing a builder. This is to show another way to build instances and keep the code simpler in the later steps. The change would require the build
method in the PersonBuilder
to change as well to:
def build(): Person = new Person( firstName, lastName, age )
Let's now change the PersonBuilder
class declaration to the following:
class PersonBuilder[PassedStep <: BuildStep] private ( var firstName: String, var lastName: String, var age: Int )
This would require all the methods we have, which returned PersonBuilder
before, to return PersonBuilder[PassedStep]
now. Also, this would make it impossible to create a builder using the new
keyword because the constructor now is private. Let's add some more constructor overloads:
protected def this() = this("","",0) protected def this(pb: PersonBuilder[_]) = this( pb.firstName, pb.lastName, pb.age )
We will see how these overloads are used later. We need to allow our users to create a builder using another method, since all constructors are invisible to the outside world. That's why we should add a companion object, as shown here:
object PersonBuilder { def apply() = new PersonBuilder[BuildStep]() }
The companion object uses one of the constructors we previously defined and it also makes sure the object returned is at the right build step.
What we have so far, however, is still not going to satisfy our requirements regarding what every Person
object should have initialized. We would have to change some methods in the PersonBuilder
class. These methods are setFirstName
, setLastName
, and build
. Here are the changes to the set methods:
def setFirstName(firstName: String): PersonBuilder[HasFirstName] = { this.firstName = firstName new PersonBuilder[HasFirstName](this) } def setLastName(lastName: String): PersonBuilder[HasLastName] = { this.lastName = lastName new PersonBuilder[HasLastName](this) }
The interesting part comes with the build
method. Let's have a look at the following initial implementation:
def build()(implicit ev: PassedStep =:= HasLastName): Person = new Person( firstName, lastName, age )
The preceding syntax sets a generalized type constraint and says that build
can only be called on a builder, which has passed the HasLastName
step. Seems like we are coming close to what we wanted to achieve, but now build
will only work if setLastName
was the last of those four methods called on the builder and it will still not validate the other fields. Let's use a similar approach for the setFirstName
and setLastName
methods and chain them up so that each one will require the previous one to be called before. Here is how the final code for our PersonBuilder
class looks like (notice the other implicits in the set
methods):
class PersonBuilder[PassedStep <: BuildStep] private ( var firstName: String, var lastName: String, var age: Int ) { protected def this() = this("","",0) protected def this(pb: PersonBuilder[_]) = this( pb.firstName, pb.lastName, pb.age ) def setFirstName(firstName: String): PersonBuilder[HasFirstName] = { this.firstName = firstName new PersonBuilder[HasFirstName](this) } def setLastName(lastName: String)(implicit ev: PassedStep =:= HasFirstName): PersonBuilder[HasLastName] = { this.lastName = lastName new PersonBuilder[HasLastName](this) } def setAge(age: Int): PersonBuilder[PassedStep] = { this.age = age this } def build()(implicit ev: PassedStep =:= HasLastName): Person = new Person( firstName, lastName, age ) }
We can now use the builder to create a Person
object:
object PersonBuilderTypeSafeExample { def main(args: Array[String]): Unit = { val person = PersonBuilder() .setFirstName("Ivan") .setLastName("Nikolov") .setAge(26) .build() System.out.println(s"Person: ${person.firstName} ${person.lastName}. Age: ${person.age}.") } }
If we omit one of our two required methods or rearrange them in some way, we will get a compilation error similar to the following (the error is for the missing first name):
Error:(103, 23) Cannot prove that com.ivan.nikolov.creational.builder.type_safe.BuildStep =:= com.ivan.nikolov.creational.builder.type_safe.HasFirstName. .setLastName("Nikolov") ^
The order requirement could be considered a slight drawback, especially if it's not needed.
Here are some observations about our type-safe builder:
Scala allows us to have a pretty nice and clean implementation of a builder design pattern, which also has requirements for order and what is initialized. This is a good feature, even though sometimes it could be tedious and limiting in terms of how exactly methods are being used.
The type-safe builder we showed previously is nice, but it has some drawbacks:
It, however, could be quite useful because it allows us to write code that will be checked for correct usage as soon as we compile it. Sometimes, compile-time validation is not required, though. If this is the case, we can make things extremely simple and get rid of the entire complexity using the already known case classes and the require
statements:
case class Person( firstName: String = "", lastName: String = "", age: Int = 0 ) { require(firstName != "", "First name is required.") require(lastName != "", "Last name is required.") }
If the preceding Boolean conditions are not satisfied, our code will throw an IllegalArgumentException
with the correct message. We can use our case class the same way as before:
object PersonCaseClassRequireExample { def main(args: Array[String]): Unit = { val person1 = Person( firstName = "Ivan", lastName = "Nikolov", age = 26 ) System.out.println(s"Person 1: ${person1}") try { val person2 = Person( firstName = "John" ) System.out.println(s"Person 2: ${person2}") } catch { case e: Throwable => e.printStackTrace() } } }
As we can see, things here are much simpler, fields are immutable, and we don't actually have any special order of initialization. Moreover, we can put meaningful messages that could help us diagnose potential issues. As long as compile-time validation is not required, this should be the preferred method.
The builder design pattern is really good for cases in which we need to create a complex object and would otherwise have to define many constructors. It makes the creation of objects easier and somewhat cleaner and more readable using a step-by-step approach.
As we saw in our type-safe builder example, adding more advanced logic and requirements could involve quite a bit of work. Without this possibility, the developer will risk the users of their classes to make more mistakes. Also, the builder contains quite a lot of seemingly duplicate code, especially when it is implemented using a Java-like code.
3.145.109.234