Loading...

Don't Use The Builder Pattern in Kotlin

Clean Code
23. Januar 2021
6 Minuten Lesezeit
Beitrag teilen:
Gefällt dir der Beitrag?
Du wirst den Newsletter lieben!

This 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.

Kennst du schon Marcus' Backend Newsletter?

Neue Ideen. Jede Woche!
Top