You need to think of your developer as a DSL with a series of classes instead of a single class; even if you stick to the builder pattern. The context of the grammar changes that the builder class is currently active.
Start with a simple option that expands the builder class only when the user chooses between HTTP (default) and HTTPS, while maintaining the builder:
A quick extension function that we will use to make quick methods prettier:
fun <T: Any> T.fluently(func: ()->Unit): T { return this.apply { func() } }
Now the main code:
// our main builder class class HttpServerBuilder internal constructor () { private var host: String = "localhost" private var port: Int? = null private var context: String = "/" fun withHost(host: String) = fluently { this.host = host } fun withPort(port: Int) = fluently { this.port = port } fun withContext(context: String) = fluently { this.context = context } // !!! transition to another internal builder class !!! fun withSsl(): HttpsServerBuilder = HttpsServerBuilder() fun build(): Server = Server(host, port ?: 80, context, false, null, null) // our context shift builder class when configuring HTTPS server inner class HttpsServerBuilder internal constructor () { private var keyStore: String? = null private var keyStorePassword: String? = null fun withKeystore(keystore: String) = fluently { this.keyStore = keyStore } fun withKeystorePassword(password: String) = fluently { this.keyStorePassword = password } // manually delegate to the outer class for withPort and withContext fun withPort(port: Int) = fluently { this@HttpServerBuilder.port = port } fun withContext(context: String) = fluently { this@HttpServerBuilder.context = context } // different validation for HTTPS server than HTTP fun build(): Server { return Server(host, port ?: 443, context, true, keyStore ?: throw IllegalArgumentException("keyStore must be present for SSL"), keyStorePassword ?: throw IllegalArgumentException("KeyStore password is required for SSL")) } } }
And a helper function to start the builder so that it matches your code in the question above:
fun serverBuilder(): HttpServerBuilder { return HttpServerBuilder() }
In this model, we use an inner class that can continue to work with some constructor values and does not have to have its own unique values and a unique check of the final build() . The builder translates the user context into this inner class by calling withSsl() .
Therefore, the user is limited only by the options allowed for each "fork in the road." Calling withKeystore() before withSsl() no longer allowed. You have the error you want.
The problem is that you must manually delegate from the inner class back to the outer class any parameters that you want to continue working. If it were a large amount, it could cause irritation. Instead, you can make general settings in the interface and use class delegation to delegate from a nested class to an outer class.
So, here is the builder reorganized to use a common interface:
private interface HttpServerBuilderCommon { var host: String var port: Int? var context: String fun withHost(host: String): HttpServerBuilderCommon fun withPort(port: Int): HttpServerBuilderCommon fun withContext(context: String): HttpServerBuilderCommon fun build(): Server }
With a nested class delegating external through this interface:
class HttpServerBuilder internal constructor (): HttpServerBuilderCommon { override var host: String = "localhost" override var port: Int? = null override var context: String = "/" override fun withHost(host: String) = fluently { this.host = host } override fun withPort(port: Int) = fluently { this.port = port } override fun withContext(context: String) = fluently { this.context = context } // transition context to HTTPS builder fun withSsl(): HttpsServerBuilder = HttpsServerBuilder(this) override fun build(): Server = Server(host, port ?: 80, context, false, null, null) // nested instead of inner class that delegates to outer any common settings class HttpsServerBuilder internal constructor (delegate: HttpServerBuilder): HttpServerBuilderCommon by delegate { private var keyStore: String? = null private var keyStorePassword: String? = null fun withKeystore(keystore: String) = fluently { this.keyStore = keyStore } fun withKeystorePassword(password: String) = fluently { this.keyStorePassword = password } override fun build(): Server { return Server(host, port ?: 443, context, true, keyStore ?: throw IllegalArgumentException("keyStore must be present for SSL"), keyStorePassword ?: throw IllegalArgumentException("KeyStore password is required for SSL")) } } }
We get the same pure effect. If you have additional forks, you can continue to open the interface for inheritance and add settings for each level in a new descendant for each level.
Although the first example may be smaller due to the small number of settings, it can be the other way around when there are a lot more settings and we had more forks on the road that created more and more settings, then the interface + delegation cannot save a lot code, but this will reduce the likelihood that you will forget a specific delegation method or have a different method signature than expected.
This is the subjective difference between the two models.
About using a DSL style constructor:
If you used a DSL model, for example:
Server { host = "localhost" port = 80 context = "/secured" ssl { keystore = "mystore.kstore" password = " p@ssw0rd !" } }
You have the advantage that you don’t have to worry about delegating settings or the order of method calls, because in DSL you tend to enter and exit the partial builder scope and therefore already have some context shift. The problem here is that since you are using implied receivers for each part of the DSL, the scope may expire from the external object to the internal object. That would be possible:
Server { host = "localhost" port = 80 context = "/secured" ssl { keystore = "mystore.kstore" password = " p@ssw0rd !" ssl { keystore = "mystore.kstore" password = " p@ssw0rd !" ssl { keystore = "mystore.kstore" password = " p@ssw0rd !" port = 443 host = "0.0.0.0" } } } }
Therefore, you cannot prevent some of the HTTP properties that bleed into the HTTPS area. This should be fixed in KT-11551 , see here for more details: Kotlin - Restrict expansion area .