The Builder pattern is a design pattern used to construct complex objects step by step. It separates the construction of an object from its representation, allowing the same construction process to create different types.
When creating complex objects, direct construction using constructors might involve many parameters, leading to unclear code and difficult error handling. Also, in languages that does not have features like named parameters and default values, construction through constructors or factory methods leads to lots of overloading.
The Builder pattern solves this by providing a clear, step-by-step approach to object construction. It uses a separate builder class to construct the object. The builder class has methods to set the object's parameters and a method to finalize the construction process.
Traditional Approach in Java:
In Java, a separate Builder
class is used. It's usually an inner static class that allows setting various properties step by step and then builds the final Car
object.
public class Car {
private final String make;
private final String model;
private final int year;
// Getters ommited.
private Car(Builder builder) {
this.make = builder.make;
this.model = builder.model;
this.year = builder.year;
}
public static class Builder {
private String make;
private String model;
private int year;
public Builder withMake(String make) {
this.make = make;
return this;
}
public Builder withModel(String model) {
this.model = model;
return this;
}
public Builder withYear(int year) {
this.year = year;
return this;
}
public Car build() { return new Car(this); }
}
}
// Usage
Car car1 = new Car.Builder()
.withMake("Honda")
.withModel("Civic")
.withYear(2020)
.build();
Car car2 = new Car.Builder()
.withMake("Audi")
.withModel("RS8")
.build();
This patterns replaces the need for multiple constructors, like in this example below, and allow for more readable and expressive attribute setting.
public class Car {
private final String make;
private final String model;
private final int year;
// Getters ommited.
public Car(String make, String model, int year) { /* Set member fields. */ }
public Car(String make, String model) { /* Set member fields. */ }
public Car(String model, int year) { /* Set member fields. */ }
public Car(String make) { /* Set member fields. */ }
public Car(int year) { /* Set member fields. */ }
}
Kotlin's Approach:
Kotlin provides named parameters and default arguments, which can make the Builder pattern unnecessary in most cases.
In Kotlin, the data class
is used with named parameters and default arguments, making the construction of objects straightforward and clear without a separate builder.
data class Car(
val make: String = "N/A",
val model: String = "N/A",
val year: Int? = null,
)
// Usage example:
val car1 = Car(
make = "Honda",
model = "Civic",
year = 2024,
)
val car2 = Car(
make = "Audi",
model = "RS8",
)
If you need to validate the arguments, you can still use features like init
blocks or custom setter logic. Check those examples:
Using init
Blocks for Arguments Validation:
data class Car(
val make: String = "N/A",
val model: String = "N/A",
val year: Int? = null,
) {
init {
require(make.isNotEmpty()) { "Make cannot be empty" }
require(model.isNotEmpty()) { "Model cannot be empty" }
}
}
// Usage example:
try {
val car = Car(
make = "Honda",
model = " ", // This will throw an IllegalArgumentException
)
} catch (e: IllegalArgumentException) {
println(e.message)
}
Using Custom Setter Logic for Property Validation:
class Car(make: String = "N/A", model: String = "N/A", year: Int?) {
var make: String = make
set(value) {
require(value.isNotEmpty()) { "Make cannot be empty" }
field = value
}
var model: String = model
set(value) {
require(value.isNotEmpty()) { "Model cannot be empty" }
field = value
}
}
// Usage
val car = Car(make = "Honda", model = "Civic")
car.model = " " // This will throw an IllegalArgumentException
Setting Object Properties After Instantiation with Kotlin's apply
In some cases, you may want to create an object first and then set its properties later. Kotlin provides a concise and expressive way to achieve this using the apply
function. The apply
function allows you to execute a block of code on an object and then return the object itself. This is especially useful when you want to set multiple properties of an object after it has been created.
Let's adapt our Car
example to demonstrate this approach:
data class Car(var make: String = "N/A", var model: String = "N/A", var year: Int? = null)
// Create a car object without setting properties initially
val car = Car().apply {
make = "Honda"
year = 2023
}
// And, of course, you can always do it the traditional way:
car.model = "Civic"
Kotlin's features simplifying Builder Pattern:
- Named Arguments: Allow specifying which parameter you are setting, enhancing readability.
- Default Arguments: Let you omit some arguments, using default values instead.
- Data Classes: Provide a concise way to create classes holding data (getters, settets,
toString
,equals
,hashCode
, destructuring methods etc. automatically implemented by the compiler).
Final Thougths
Kotlin's features like named parameters and default arguments simplify object construction compared to the traditional Builder pattern. This leads to simpler, concise, more readable and maintainable code.
--
This article was originally posted to my Lucas Fugisawa on Kotlin blog, at: https://fugisawa.com/kotlin-design-patterns-simplifying-the-builder-pattern/
To explore more about design patterns and other Kotlin-related topics, subscribe to my newsletter on https://fugisawa.com/ and stay tuned for more insights and updates.
Top comments (0)