By leveraging the Kotlin Language features of data classes, named parameters and default values, we can address the the majority of use cases that the Builder Pattern addresses for Java, but with much less code.
I saw a Tweet by Hannes Dorfmann asking if people still used the Builder Pattern in Kotlin. I realized that on the projects I’ve been working on lately, we don’t use it and I wanted to share why we don’t, and how you can do the same.
No more builder pattern here. With Kotlin we use named arguments and provide default values. So much cleaner 💯 https://t.co/vYoxdYTQZL
— Sam Edwards (@HandstandSam) August 20, 2018
In this post we’ll be building a NetworkRequest
object which holds the information needed to make an HTTP networking request.
Goals
- Emulate the short, readable syntax of the Builder Pattern in Java, but without the boilerplate code.
- Default values for optional arguments.
- Validation of business rules during object creation.
We’ll walk through a few steps, each which leverages a different Kotlin language feature to help accomplish our goals.
Step 1 – Using a Kotlin Data Class
In Kotlin, required, non-null parameters can be achieved by requiring a val
in the constructor. In our NetworkRequest
object, all fields are required, except the body
string which is nullable
because of the ?
we see in String?
.
data class NetworkRequest(
val url : String,
val method: String,
val headers : Map<String, String>,
val body : String?
)
Kotlin data classes give us a really clean, concise way to define objects that hold values and force immutable, non-null values. This simple data class is short, readable code, but it doesn’t provide default values or argument validation out of the box.
Step 2 – Providing Default Values
Kotlin allows us to provide default values for a parameter with the syntax you see below.
data class NetworkRequest(
val url : String,
val method: String = "GET",
val headers : Map<String, String> = mapOf(),
val body : String? = null
)
All parameters have default values provided in the NetworkRequest
object except for url
. This means that the only parameter which is required is url
. This means that if the default values are okay for us, then we don’t need to specify specific values. With this implementation, we can build instances of the NetworkRequest object, and only specify the required url parameter since default values are provided for the other parameters. Here is an example of creating a NetworkRequest
object where the url
is the only parameter specified:
NetworkRequest("http://localhost:8080")
As you have more arguments, it’s hard to tell which argument is which, especially if the fields are fairly similar. Some values could both be ""
or null
and you wouldn’t know which argument is what.
NetworkRequest(
"http://localhost:8080",
"GET",
mapOf("Content-Type" to "application/json"),
null
)
Android Studio has tooling to show you parameter names, but these are not available in GitHub or other tools. The parameter name tooltips in Android Studio are AWESOME during development, but as soon as someone is doing a code review outside of Android Studio (like on GitHub), they won’t be able to tell which parameter is which. So, while this is nice during development, and slightly less code, I feel like it’s not a great experience for future developers on your project, so I recommend a coding style where you use named parameters when you can.
Step 3 – Using Named Parameters
Named parameters allow us to very easily see what each value is. Here are a couple of examples:
NetworkRequest(url = "http://localhost:8080")
NetworkRequest(
url = "http://localhost:8080",
method = "GET",
headers = mapOf("Content-Type" to "application/json"),
body = null
)
Named parameters are nice because they allow us to specify parameters in any order. These following two code examples create the exact same object even though url
and headers
are in a different order:
// `url` then `headers`
NetworkRequest(
url = "http://localhost:8080",
headers = mapOf("Content-Type" to "application/json")
)
// `headers` then `url`
NetworkRequest(
headers = mapOf("Content-Type" to "application/json"),
url = "http://localhost:8080"
)
Step 4 – Business Logic Validation
A major benefit of using something like the Builder Pattern is for argument and business logic validation. In our Kotlin Data Class, this can be done in the init {}
block which is called immediately after the default constructor is invoked.
init {
if (url.isEmpty()) {
throw IllegalArgumentException("Invalid `url`")
}
if (method == "POST" && body == null) {
throw IllegalArgumentException("`body` cannot be `null` for a `POST`")
}
}
Here is the full version of our data class with the init {} block which ensures we have a non-empty url and a body if the method is POST:
data class NetworkRequest(
val url: String,
val method: String = "GET",
val headers: Map<String, String> = mapOf(),
val body: String? = null
) {
init {
if (url.isEmpty()) {
throw IllegalArgumentException("Invalid `url`")
}
if (method == "POST" && body == null) {
throw IllegalArgumentException("`body` cannot be `null` for a `POST`")
}
}
}
Decompiling Kotlin -> Java to See How it Works
If we decompile our Kotlin NetworkRequest
data class to Java code, here are the resulting code blocks that are generated from the Kotlin compiler.
Variable definition
@NotNull
private final String url;
@NotNull
private final String method;
@NotNull
private final Map headers;
@Nullable
private final String body;
Non-null checks and business logic validation.
public NetworkRequest(@NotNull String url, @NotNull String method, @NotNull Map headers, @Nullable String body) {
Intrinsics.checkParameterIsNotNull(url, "url");
Intrinsics.checkParameterIsNotNull(method, "method");
Intrinsics.checkParameterIsNotNull(headers, "headers");
super();
this.url = url;
this.method = method;
this.headers = headers;
this.body = body;
CharSequence var5 = (CharSequence)this.url;
if(var5.length() == 0) {
throw (Throwable)(new IllegalArgumentException("Invalid `url`"));
} else if(Intrinsics.areEqual(this.method, "POST") && this.body == null) {
throw (Throwable)(new IllegalArgumentException("`body` cannot be `null` for a `POST`"));
}
}
Default Values of "GET"
, an empty Map
and a null
body.
if((var5 & 2) != 0) {
var2 = "GET";
}
if((var5 & 4) != 0) {
var3 = MapsKt.emptyMap();
}
if((var5 & 8) != 0) {
var4 = (String)null;
}
Conclusion
Kotlin Data classes, named parameters and default values give us a concise way to build objects and perform validations without the boilerplate needed in Java. This allows us to meet all of our requirements, and keeps our code concise, and readable.
NOTE to Java Developers: If you still need to have Java code that will create these Kotlin objects that you are building, it can get ugly, so be aware of that if the person using your code is not using Kotlin. I’m assuming that you are 100% Kotlin, or the code that will call this is written in Kotlin.
I wanted to share this because coming from the Java world, these Kotlin language features are non-obvious to help in building objects. If you have any questions, feel free to reach out on Twitter at @HandstandSam.