The builder design pattern

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.

Class diagram

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:

Class diagram

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.

Code example

There are actually three main ways in which we can represent the builder design pattern in Scala:

  • The classical way, as shown in the preceding diagram, is like other object-oriented languages. This way is actually not recommended, even though it is possible in Scala. It uses mutability in order to work, which contradicts the immutability principle of the language. We will show it here for completeness and in order to point out how much easier it is to achieve the builder design pattern using simple features of Scala .
  • Using case classes with default parameters. We will see two versions: one that validates the parameters and another one that doesn't.
  • Using generalized type constraints.

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.

Java-like implementation

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.

Implementation with a case 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.

Tip

Prefer immutability

Immutability is an important principle in Scala and it should be preferred. The builder design pattern with case classes uses immutable fields and this is considered a good practice.

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.

Using generalized type constraints

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.

Changing the Person class

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
)

Changing the PersonBuilder class

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.

Adding generalized type constraints to the required methods

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
  )
}

Using the type-safe builder

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.

Note

Here are some observations about our type-safe builder:

  • Using a type-safe builder, we can require a specific call order and certain fields to be initialized.
  • When we require multiple fields, we have to chain them, which makes the order of calls important. This could make the library hard to use in some cases.
  • Compiler messages, when the builder is not used correctly, are not really informative.
  • The code looks pretty much similar to how it would be implemented in Java.
  • The similarity in code with Java leads to relying on mutability, which is not recommended.

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.

Using require statements

The type-safe builder we showed previously is nice, but it has some drawbacks:

  • Complexity
  • Mutability
  • A predefined order of initialization

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.

What is it good for?

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.

What is it not so good for?

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.

..................Content has been hidden....................

You can't read the all page of ebook, please click here login for view all page.
Reset
3.145.109.234