I've recently been re-reading this discussion about the Builder pattern in Java, and I've been reflecting a bit on the use of this pattern in modern languages like Kotlin. In languages supporting named and default parameters one of the use cases for the Builder pattern no longer exists. You can have a constructor that accepts a long list of parameters, most of them with default values, and you can invoke it passing named parameters. Kotlin constructors are perfectly friendly for that, same as dataclasses in Python.
That said, there are still other use cases for the Builder pattern. Let's say you want to build your object over time, as the data needed for it gets ready. OK, we could use that constructor with default parameters to start off, and then set other properties as the data for it is obtained (even using the apply scope function). If you want an immutable object that approach is not valid, and there's another main problem that applies both if immutability is or is not a concern to you. That problem is that we have an object in an incomplete/inconsistent state, that we could start to use before time just by error. Having a builder with a build() method that checks the consistency of the object and throws an exception if necessary is the way to go. Also, when the initialization logic is complex, not just setting properties with parameters, but doing some validations, calculations, conditional logic... having that logic as methods of the builder is pretty neat.
OK, so now that it's clear that the Builder pattern is still necessary for certain cases, I'm going to delve a bit into a way of using it that confused me a bit. When using the Kotlin Ktor library for doing an http request, you find code like this:
client.request {
url("https://api.example.com/users")
headers {
append("Authorization", "Bearer token")
append("Accept", "application/json")
if (someCondition) append("X-Custom", "value")
}
timeout {
requestTimeoutMillis = 5000
connectTimeoutMillis = 3000
}
}
That code corresponds to this signature (that I'll call the "complex" one):
inline suspend fun HttpClient.request(block: HttpRequestBuilder.() -> Unit): HttpResponse
So the request method creates a HttpRequestBuilder object and passes it over (as receiver) to the block that it has got as argument. That block configures the Builder and I guess then the request method will end up invoking the build() method on the constructed builder. That feels like a bit overcomplicated, why not just create the builder, configure it and pass it over to request()? Well, indeed there's another overload for request() that just expects that, a Builder object. This is the corresponding signature (that I'll call the "simple" one):
inline suspend fun HttpClient.request(builder: HttpRequestBuilder = HttpRequestBuilder()): HttpResponse
So what are the pros and cons of each overload?
The "simple" one is perfect when we want a builder that we will reuse on several calls. We create the builder first and pass it over to the different calls. However, if we have a one-shot builder, we'll want to create/configure it in place, and the HttpRequestBuilder is not that nice for that. It does not come with a fluid interface, mainly I guess because it's expected that part of the chaining could be conditional, and for that we use apply(). This means having a slightly more verbose code:
client.request(HttpRequestBuilder().apply {
url("https://api.example.com/users")
headers {
append("Authorization", "Bearer token")
append("Accept", "application/json")
if (someCondition) append("X-Custom", "value")
}
timeout {
requestTimeoutMillis = 5000
connectTimeoutMillis = 3000
}
})
Asking a GPT for his opinion, the main conclusion is that the "complex" approach is:
More idiomatic Kotlin. It follows Ktor's DSL patterns used throughout the library.
So while functionally similar to apply(), the DSL overload is specifically designed for Ktor's fluent API style and is generally preferred for inline request building. It's a great example of Kotlin's DSL capabilities making APIs more expressive and readable.
I rather agree. The "complex" approach is more complex in terms of reasoning about how it works, but it's more expressive and looks more natural.
No comments:
Post a Comment