Functional builder design pattern with support for mandatory properties

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 a var, but fun is more consistent in the dualized representation.
  • Address: The only difference from the traditional realization of the builder pattern is the return type of host 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 the address function. Furthermore, the block parameter does not return Unit (as for the logging function), but the sealed interface from the Address model. This implies that when configuring Address within this block, the host function must be called and returned within the code block. As a last observation, the engine function has the return type of address 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")
        }
    }
}

Why? If you just had a constuctor with some required and some optional parameters you’d obtain the same.

To me builders are Java answer to the lack of default arguments in functions.
Since Kotlin has default arguments - why do we need builders at all?

Named argument requires specifying creation of instance, so it is more verbose than DSL style builder. Kotlin doc also recommends DSL if the target structure is nested.

I don’t think .engine behind address block is well readable. And even Address::engine extension property is hard to comprehend. (“What? address can have engine?”)

If you need change of property in builder block, just overloading or making another builder fun is more readable: addressWithCustomEngine

And if you also need preventing omission, you can let address receive each (named) argument. Yeah, that’s Kotlin DSL can’t do now.

The naming of the sealed interfaces can sure be improved. Their names really dont matter because they only play a technical role. Instead of SeverAddress we could choose ServerConfigStep or something.

The idea of putting mandatory arguments as normal parameters is a non-solution for me (and hence my proposal). Where would you have to put the host field in the above example? Into address fun of Server would not suffice, you would have to put it into server fun of EntryPoint. Any mandatory arg deeper in the hierarchy would have to be moved to top level.