There is the well known builder design pattern based on lambdas with receivers: Type-safe builders | Kotlin
It has a drawback, though, as mandatory properties do no scale well with it. Usually one solves this by expecting values of those properties as arguments to the builder functions (those functions which then also accept the configuration code block). However, such mandatory values must all resurface at the root of the configuration tree, since, once in a code block, everything is optional.
I was wondering whether this can be repaired in some way. I want to show my approach. Please give feedback or show your own solutions.
We start by setting the stage with an example in the form of POJOs forming a model tree.
class Server(
val address: Address,
val engine: Engine,
val logging: Logging = Logging()
)
class Address(
val host: String,
val port: Int = 80
)
enum class Engine {
NETTY, JETTY, TOMCAT
}
class Logging(
val file: String = "log.txt"
)
It has mandatory (no defaults) as well as optional (with defaults) properties. One can already work with this concrete implementation, thanks to Kotlin’s support for named arguments. However, we want to configure such a model tree with the help of nested lambdas, as is the concept of functional builder design pattern. In the end, such a configuration could look as follows.
server {
logging {
file("my.log")
}
address {
port(8080)
host("0.0.0.0")
}
engine(Engine.NETTY)
}
As already explained above, the problem here is that I could drop the line setting the host
. Most probably, the chosen implementation will throw an error at runtime in this case. We want to do better and force an error at compile time. I will start with an implementation-free, dualized version of the POJOs from above (explanations follow):
interface Server {
fun address(block: Address.() -> AddressFinal): ServerAddress
fun ServerAddress.engine(value: Engine): ServerFinal
fun logging(block: Logging.() -> Unit)
}
sealed interface ServerAddress
sealed interface ServerFinal
interface Address {
fun host(value: String): AddressFinal
fun port(value: Int)
}
sealed interface AddressFinal
enum class Engine {
NETTY, JETTY, TOMCAT
}
interface Logging {
fun file(value: String)
}
Some explanations from bottom to top:
-
Logging
: Nothing special here. Would could have used avar
, butfun
is more consistent in the dualized representation. -
Address
: The only difference from the traditional realization of the builder pattern is the return type ofhost
function which is a sealed interface. From API perspective, this is the only way an instance of this type can be created. -
Server
: Same observation at theaddress
function. Furthermore, theblock
parameter does not returnUnit
(as for thelogging
function), but the sealed interface from theAddress
model. This implies that when configuringAddress
within this block, thehost
function must be called and returned within the code block. As a last observation, theengine
function has the return type ofaddress
as an extension receiver, forcing the latter to be called before the first.
Let’s turn to the implementations, starting with Logging
.
private interface LoggingData {
val file: String
}
private class LoggingImpl : Logging, LoggingData {
private var _file: String = "log.txt"
override val file get() = _file
override fun file(value: String) {
_file = value
}
}
Nothing special here. LoggingImpl
is a bridge between the POJO world (represented by LoggingData
) and the dual functional world (represented by Logging
). This is also where the default value for file
is stored. Next is Address
.
private interface AddressData {
val port: Int
}
private interface AddressFinalData : AddressData {
val host: String
}
private class AddressFinalImpl(
override val host: String,
private val delegate: AddressData
) : AddressFinal, AddressFinalData, AddressData by delegate
private class AddressImpl : Address, AddressData {
private var _port: Int = 80
override val port get() = _port
override fun port(value: Int) {
_port = value
}
override fun host(value: String) = AddressFinalImpl(value, this)
}
What we do here is a layering of the model. All the optional properties are stored in the base layer (here AddressData
). Each mandatory property gets its own layer (here only AddressFinalData
). On the implementation side, the layering is realized via delegation, using Kotlin’s by
feature. Each implementation layer is also the one and only implementation of the corresponding sealed API interface. The implementation of the base layer is again the bridge between POJO and functional world. Next is Server
.
private interface ServerData {
val logging: LoggingData
}
private interface ServerAddressData : ServerData {
val address: AddressFinalData
}
private interface ServerFinalData : ServerAddressData {
val engine: Engine
}
private class ServerFinalImpl(
override val engine: Engine,
private val delegate: ServerAddressData,
) : ServerFinal, ServerFinalData, ServerAddressData by delegate
private class ServerAddressImpl(
override val address: AddressFinalData,
private val delegate: ServerData
) : ServerAddress, ServerAddressData, ServerData by delegate
private class ServerImpl : Server, ServerData {
private var _logging: LoggingData = LoggingImpl()
override val logging get() = _logging
override fun logging(block: Logging.() -> Unit) {
_logging = LoggingImpl().apply(block)
}
override fun address(block: Address.() -> AddressFinal) = ServerAddressImpl(
when (val it = AddressImpl().block()) {
is AddressFinalImpl -> it
}, this
)
override fun ServerAddress.engine(value: Engine) = ServerFinalImpl(
value, when (this) {
is ServerAddressImpl -> this
}
)
}
Since we have two mandatory properties here, we have two layers plus the base layer. The base layer implementation exploits the sealedness of the API interfaces representing the layers. The two when
blocks with single branches help the compiler to see that there is only one implementation (this could be a feature of the compiler itself, rendering the when
blocks obsolete, but this does not yet exist).
The only thing missing now is an entry point, provided by the framework. We just use a dummy here for demonstration. Note here again that we demand ServerFinal
as return type of the configuration block, forcing all the mandatory properties in the model.
interface EntryPoint {
fun server(block: Server.() -> ServerFinal)
}
private class EntryPointImpl : EntryPoint {
var server: ServerFinalData? = null
override fun server(block: Server.() -> ServerFinal) {
server = when (val it = ServerImpl().block()) {
is ServerFinalImpl -> it
}
}
}
Now a user could configure like this:
val userConfiguration: EntryPoint.() -> Unit = {
server {
logging {
file("my.log")
}
address {
port(8080)
host("0.0.0.0")
}.
engine(Engine.NETTY)
}
}
The only difference to the desired configuration code (mentioned at the beginning of this post) is the dot behind the address
block. However, removing the host
line would turn into a compiler error.
We can test the configuration with the following dummy framework code:
fun main() {
val base = EntryPointImpl()
base.userConfiguration()
base.server?.apply {
println("address=")
address.apply {
println("host=$host")
println("port=$port")
}
println("engine=$engine")
println("logging=")
logging.apply {
println("file=$file")
}
}
}