Introduction
This tutorial teaches you how to implement the Type-safe Builder pattern using Kotlin. This pattern allows developers to create declarative and concise DSLs. Our implementation will be a Burger Builder that enables our users to create Burger objects expressively.
Goals
At the end of the tutorial, you would have learned:
- A general understanding of DSLs.
- How to combine various Kotlin features to create DSLs.
Prerequisite Knowledge
- Basic Kotlin syntax.
- Good understanding of these Kotlin concepts: extension functions, higher-order functions, function types with receiver, lambdas with receiver, lambda expressions, passing trailing lambdas.
Tools Required
An IDE that supports Kotlin, such as IntelliJ Community Edition.
What is a DSL?
In simple terms, DSLs are mini-languages that simplify complex programs for the end users in a specific domain. Most of us are already using DSLs daily without realizing it. Examples of DSLs are SQL, shell commands, HTML, CSS, etc.
Database administrators can interact with databases using SQL commands that look almost like English without having to know any programming.
DELETE FROM users where username = 'monkey';
System administrators can call Bash or Powershell commands with just one word or more.
reboot
history
ping 192.168.0.1
Internal vs External DSLs
The second concept about DSL that we need to understand for the purpose of this tutorial is the difference between internal and external DSL.
External DSLs, such as SQL, require a parser into our programming language to be able to work. Internal DSLs, however, utilizes the compiler and do not require a parser. The program that we will create in this tutorial would provide an internal DSL, therefore we would still be bound to compiler checks.
Fluency
Kotlin has many features such as infix functions, operator overloading, receivers, and passing-trailing-lambdas that make function callers look almost identical to English.
Passing-trailing-lambda allows us to skip the last comma and the function call’s parentheses.
Function with receivers provide an implicit this
, so code callers can skip using the reference to access the object instance.
Our tutorial today will not use infix functions or operator overloading, but we will make use of functional types with receiver and the passing-trailing-lambda syntax.
Design our Models
The goal of our application is to allow users to create a customized burger. The user can choose whatever toppings(parent category for meat and veggie), condiments, and cheese they want.
Because the implementation of the type-safe builder pattern involves quite a lot of Kotlin concepts, I prefer to write out the desired DSL first before the implementations, which will not compile of course.
fun main(){
val customBurger = burger {
toppings {
meat { "Bacon" }
meat { "Beef Patty" }
veggie { "Mushroom" }
veggie { "Lettuce" }
}
condiment { "Mayonnaise" }
cheese { "Pepper Jack" }
}
}
Implementation
To begin creating our builder, we would start from the top call first, which is a higher-order function burger
fun burger(init: Burger.()->Unit): Burger {
val builder = Burger()
builder.init()
return builder.build()
}
These are the two most important points about this method:
- The function parameter is a function-type-with-receiver. This allows the function to receive the lambda expression right after the
burger
method call(based on our models described in the previous section). - But passing in the lambda expression is not enough. You still have to execute it using the
init()
call, otherwise the lambda(the one that callstoppings()
,condiment()
, andcheese()
) will not be executed. Nobody wants to eat an empty burger.
The next steps would be to create the Burger class.
class Burger(){
private lateinit var toppings: Toppings
private lateinit var condiment: Condiment
private lateinit var cheese: Cheese
private constructor(toppings: Toppings, condiment: Condiment, cheese: Cheese) : this(){
this.toppings = toppings
this.condiment = condiment
this.cheese = cheese
}
fun build(): Burger = Burger(toppings, condiment, cheese)
fun toppings(init: Toppings.()->Unit) {
toppings = Toppings()
toppings.init()
}
fun condiment(init:()->String) {
condiment = Condiment(init())
}
fun cheese(init:()->String) {
cheese = Cheese(init())
}
override fun toString(): String {
return "Burger(toppings=$toppings, condiment=$condiment, cheese=$cheese)"
}
}
There are a couple of important points to note in the Burger class.
- The primary empty constructor is public, which allows anybody to create an empty Burger, but this is only required for the extension function(the function-type-with-receiver)
init()
to work. - The secondary constructor is
private
, and the only way to obtain a Burger instance is through thebuild()
function. - The properties of Burger are
lateinit
and you can check if the properties have been initialized using.isInitialized
on the reference to that property. You can implement this check into your own builder if you wish, but for brevity, I am skipping that logic. - Notice how the
toppings()
function follow the same pattern as the higher-order functionburger()
. This is the proper way to chain function calls. - The
condiment()
andcheese()
functions do not follow the same pattern above because there are no sub categories in them, so they are only taking a function type lambda(without receiver). You might be able to just require a String as the parameter, but then your DSL grammar would be inconsistent.
Next is the content of the Toppings class, which follows a similar pattern as the Burger class. The only difference is that the properties are always initialized(although just empty sets), so we do not need a build()
function for this.
class Toppings(){
private val meats = mutableSetOf<Meat>()
private val veggies = mutableSetOf<Veggie>()
fun meat(init:()->String) {
meats.add(Meat(init()))
}
fun veggie(init:()->String) {
veggies.add(Veggie(init()))
}
override fun toString(): String {
return "Toppings(meats=$meats, veggies=$veggies)"
}
}
The rest of the classes are just simple data classes.
data class Meat(val name: String)
data class Veggie(val name: String)
data class Condiment(val name: String)
data class Cheese(val name: String)
Solution Code
fun main(){
val customBurger = burger {
toppings {
meat { "Bacon" }
meat { "Beef Patty" }
veggie { "Mushroom" }
veggie { "Lettuce" }
}
condiment { "Mayonnaise" }
cheese { "Pepper Jack" }
}
println(customBurger)
}
class Burger(){
private lateinit var toppings: Toppings
private lateinit var condiment: Condiment
private lateinit var cheese: Cheese
private constructor(toppings: Toppings, condiment: Condiment, cheese: Cheese) : this(){
this.toppings = toppings
this.condiment = condiment
this.cheese = cheese
}
fun build(): Burger = Burger(toppings, condiment, cheese)
fun toppings(init: Toppings.()->Unit) {
toppings = Toppings()
toppings.init()
}
fun condiment(init:()->String) {
condiment = Condiment(init())
}
fun cheese(init:()->String) {
cheese = Cheese(init())
}
override fun toString(): String {
return "Burger(toppings=$toppings, condiment=$condiment, cheese=$cheese)"
}
}
class Toppings(){
private val meats = mutableSetOf<Meat>()
private val veggies = mutableSetOf<Veggie>()
fun meat(init:()->String) {
meats.add(Meat(init()))
}
fun veggie(init:()->String) {
veggies.add(Veggie(init()))
}
override fun toString(): String {
return "Toppings(meats=$meats, veggies=$veggies)"
}
}
data class Meat(val name: String)
data class Veggie(val name: String)
data class Condiment(val name: String)
data class Cheese(val name: String)
fun burger(init: Burger.()->Unit): Burger {
val builder = Burger()
builder.init()
return builder.build()
}
Summary
You have probably realized that in order to create this API, a lot of boilerplate code had to be written. I personally think it is only worth the effort if the code gets reused a lot. There are other features in Kotlin such as named parameters with default arguments, infix functions, and operator overloading that allows your code to be short and concise as well.
The source code for the project can be downloaded here https://github.com/dmitrilc/DaniWebTypesafeBuilderKotlin