Don't Use The Builder Pattern in Kotlin
Autor
Marcus HeldThis week I was taught again that you should be thoughtful when applying patterns you read on the internet (which is ironic - because the same applies to this post ;-) ). This time colleagues heavily applied the Creational Design Patterns in Kotlin: Builder from Baeldung. Using the builder pattern in Kotlin in this example is an anti-pattern and in this post I will reason why you’ll end up with safer, less error-prone and less boilerplate when properly applying Kotlins language features instead of this builder pattern.
Before we dive into the code let’s revisit the reasons why the builder pattern gained popularity and which problem it solves.
Creating immutable and consistent objects on initialization drastically reduces the possibility of wrong API handling and therefore reduces the possibility of bugs. Out of that reason derives the idea of only providing constructors which don’t allow inconsistent object states. Usually you can already do a lot by properly providing constructors that check for certain rules as well as only provide constructors which accept parameter combinations that don’t break the objects contract. This works fine until you have a certain amount of parameters which can be in many relations to each other. This is the point where the builder pattern becomes handy. You can provide an API which leads you through the creation of the object and makes optional and mandatory objects discoverable. I already discussed this topic in Distinguish Between Optional and Mandatory Parameters in the Builder Pattern .
For the further discussion I won’t use the example from Baeldung
because the object does not have a good contract. Allowing all attributes to be nullable without defining the semantic meaning and representing something complex like meat
as String does not give any context how this is supposed to be interpreted. Therefore, we go with an example I saw in another project:
data class Customer(
val id: UUID = UUID.randomUUID(),
val username: String,
val auth0Id: String
) {
class Builder {
var id: UUID? = null
var username: String? = null
var auth0Id: String? = null
fun id(id: UUID) = apply { this.id = id }
fun username(username: String) = apply { this.username = username }
fun auth0Id(auth0Id: String) = apply { this.auth0Id = auth0Id }
fun build() = Customer(id!!, username!!, auth0Id!!)
fun randomBuild() = id(id ?: UUID.randomUUID())
.username(username ?: RandomStringUtils.randomAlphanumeric(10))
.auth0Id(auth0Id ?: RandomStringUtils.randomAlphanumeric(10))
.build()
}
}
We can use this builder like this:
Customer.Builder()
.id(UUID.randomUUID())
.username("user name")
.auth0Id("auth0id")
.build()
Problem 1: NullPointerExceptions
As you might have noticed it is possible with this implementation that you create a NullPointerException
. The compiler allows that one of the parameters are omitted. E.g. the following code would compile:
Customer.Builder()
.id(UUID.randomUUID())
.auth0Id("auth0id")
.build()
This is problematic. Imagine you extend this class at some point with another attribute. At this point the compiler won’t tell you where you need to adjust the creation of this object, but you would experience a NullPointerException
at runtime. You could solve it by applying the Robust Builder Pattern
, but it would mean a lot more code that is required to achieve that.
Problem 2: Undiscoverable Behavior
Without looking at the implementation - would you know which object you create here:
Customer.Builder()
.name("foo")
.randomBuild()
You might expect that you create a Customer
with the name “foo” but with random values on the other attributes. But it would also be absolutely reasonable to expect that the builder is just doing a complete random build. The only way to figure out that this is not the case is by looking into the implementation. That is unnecessary effort for the caller and should be avoided by the API.
Problem 3: Omitted Default Values
Looking at the constructor of the object you see that id
has a default value with UUID.randomUUID()
. But when using the Builder you are forced to reassign the id. Otherwise, you produce a NullPointerException as explained in Problem 1. The default value is essentially dead code when this class is supposed to be only be initializable by its constructor.
Problem 4: Non-private Constructor
A client that uses this class can still construct it by using the constructor instead of the builder. Since using a constructor is the natural way of initializing an Object another developer might do this and therefore bypass the builder in the first place which could lead to inconsistent objects when you do validation in your builder.
Problem 5: Boilerplate
As you can see for a single attribute in the class you need to write a lot of duplicated code. I don’t want to do that.
Solution
All the above problems can be solved by properly using Kotlins language features. Let’s get rid of the Builder.
data class Customer(
val id: UUID = UUID.randomUUID(),
val username: String,
val auth0Id: String
)
We can construct the object like this now Customer(UUID.randomUUID(), "username", "auth0Id")
. Of course using the constructor like this comes with one problem that the Builder pattern solves which is that this code would still compile when you change the order of username
and authOId
in the constructor since both are from the type String
. This is a very valid concern which brings me to the principle:
With named parameters the constructor reads:
Customer(
id = UUID.randomUUID(),
username = "username",
auth0Id = "auth0Id"
)
Now the order of the attributes don’t matter to the caller anymore and additionally you can’t confuse the two String
parameters.
Additionally we now respect the default value that is set for id
in the Customer
. We can also call:
Customer(
username = "username",
auth0Id = "auth0Id"
)
This API is also discoverable. The IDE tells us which parameters are mandatory and which are optional.
But now we lost one feature. We can’t do a random build anymore. For this we should implement a static factory method in the companion object
:
data class Customer(
val id: UUID = UUID.randomUUID(),
val username: String,
val auth0Id: String
) {
companion object {
fun random() =
Customer(username = RandomStringUtils.randomAlphanumeric(10), auth0Id = RandomStringUtils.randomAlphanumeric(10))
}
}
We can easily and unambiguously create a random object by calling Customer.random()
If you want to do validation of the parameters you can use Kotlins init
block:
data class Customer(
val id: UUID = UUID.randomUUID(),
val username: String,
val auth0Id: String
) {
init {
require(username.length > 8) { "The username must be larger than 8 characters." }
}
}
Overall we achieved the same functionality and even improved the API by just relying on Kotlins language features. The only reason to introduce a Builder is when you have a complex object creation which enforces certain attributes in relation to each other. In that case the Robust Builder Pattern is your friend. But also in that case you should check if a secondary constructor already fulfills your needs.