zsmb.coEst. 2017



Kotlin DSL design with VillageDSL

2017-09-20 • Márton Braun

This is a companion article for a talk about Kotlin DSL design possibilities. There’s also an accompanying GitHub repository which contains the samples for the examples discussed below.

We’ll be looking at two main exercises: a simple and an advanced model, and then offering various DSL solutions for constructing structures within these models. Both models aim to simulate a fantasy game of some sorts where you have to describe a village and its contents.

The simple model

The simple exercise uses just three model objects: the village, the houses it contains, and the people who are in those houses. Here are the data classes representing these concepts:

data class Person(val name: String, val age: Int)
data class House(val people: List<Person>)
data class Village(val houses: List<House>)

Note that the examples included are larger than the code samples you see here below, look at the linked source files for the full examples.

Approaches without DSLs

First, let’s see how we can construct a hierarchy of these models without defining a DSL.

Traditional Java style construction

This is essentially the approach that we’d take if we had to use Java, and it’s translated to Kotlin syntax. We create mutable lists for everything, add items to them one by one on separate lines, and then create the objects that contain these lists.

val houses = mutableListOf<House>()

val people1 = mutableListOf<Person>()
people1.add(Person("Emily", 31))
people1.add(Person("Hannah", 27))
people1.add(Person("Alex", 21))
people1.add(Person("Daniel", 17))

val house1 = House(people1)
houses.add(house1)

val village = Village(houses)

This has all the usual pain points that constructing a hierarchy in Java entails: we can’t really see the hierarchy itself in the code, and we have to follow a weird, unnaturally twisted structure with our code because of the limitations of the API. More importantly, this style of code gets complicated to read and modify quite quickly.

Slightly improved construction, with more idiomatic Kotlin

We can improve on this by quite a bit by just nesting some of these calls and using the factory methods for collections provided by the Kotlin standard library.

val house1 = House(listOf(
        Person("Emily", 31),
        Person("Hannah", 27),
        Person("Alex", 21),
        Person("Daniel", 17)))

val village = Village(listOf(house1))

The code we have here is easier to write, read and maintain than the previous one. It still doesn’t show hierarchy very well however, and it will face some of the same problems as the previous code when the described model gets larger.

A “home-made” DSL

This “poor man’s DSL” solution is mostly included for good measure. It makes use of the previously mentioned collection factory methods, named parameters, and some formatting to create something resembling a DSL.

val village = Village(listOf(
            House(listOf(
                    Person(
                            name = "Emily",
                            age = 31
                    ),
                    Person(
                            name = "Hannah",
                            age = 27
                    ),
                    Person(
                            name = "Alex",
                            age = 21
                    ),
                    Person(
                            name = "Daniel",
                            age = 17
                    )
            ))
))

The problem here is that modifying the code is tedious compared to a real DSL, since you have to pay attention to where the listOf calls happen, and you can’t just move around pieces of the code without having to check that all your commas are in the right place.

DSL approaches

Below are various DSL approaches. Neither of these are supposed to be the solution to the posed exercise, they are just various examples of DSLs you can use to solve the problem. This document only contains small samples of how to use these DSLs, check the linked packages for the implementations and full examples.

The usual DSL

To start, here’s the DSL that follows the conventions most often used by DSL authors: it makes use of function literals with receivers that put you into the scope of builders that have the appropriate properties, as well as default and named arguments.

val v = village {
    house {
        person {
            name = "Emily"
            age = 31
        }
        person(name = "Hannah") {
            age = 27
        }
        person("Alex", 21)
        person(age = 17, name = "Daniel")
    }
}

Note that while the code here showcases a variety of different ways for calling the same person function, sticking to one of these styles at a time is of course recommended.

You can see that even this simplest DSL gets rid of the modification woes of the non-DSL approaches, as the blocks here can be moved around freely and easily.

A more interesting DSL with operator overloading

Instead of doing the same thing over and over on every level of the hierarchy as before, we can construct some of our models (usually the leafs of the hierarchy) in a more direct way by just calling their constructors, and adding them to the right parent using overloaded operators. (A good example of this is how text can be appended to elements in the kotlinx.html library.)

val v = village {
    house {
        +Person("Emily", 31)
        +Person("Hannah", 27)
        +Person("Alex", 21)
        +Person("Daniel", 17)
    }
}

This is done by using an overloaded unaryPlus operator, that’s defined as a member of the class responsible for creating a House. This way, both the list containing people that will be in the House and the constructed Person object are in scope inside the function.

The same can be done using the unaryMinus operator, as you can see in the full sample. This can provide a nice “list” look in your DSL.

A slightly over-the-top DSL

Pushing the limits of the Kotlin language, using some dummy objects and infix functions can you can get pretty far with making your DSL fluent, and read almost like sentences. (A good official example is the KotlinTest library.)

val v = village containing houses {

    house with people {
        "Emily" age 31
        "Hannah" age 27
        "Alex" age 21
        "Daniel" age 17
    }

}

Note that while in the case of the “usual DSL” it’s verified that the blocks are nested in the expected order (by the @DslMarker annotation, see in the example here), these potentially chained infix function calls can lead to code that compiles but does nothing sensible.

The advanced model

The advanced example’s model extends the simple model with various types of loot that can be placed inside the houses.

data class Village(val houses: List<House>)
data class House(val people: List<Person>, val items: List<Item>)
data class Person(val name: String, val age: Int)

interface Item
data class Gold(val amount: Int) : Item

interface Weapon : Item
data class Sword(val strength: Double) : Weapon

interface Armor : Item
data class Shield(val defense: Double) : Armor

Just to reiterate before getting into the various approaches: the code examples here are just short snippets. The full example is included in the linked source files.

Approaches without DSLs

Again, let’s first take a look at how we can create instances of the above models and nest them appropriately without defining a DSL.

Traditional Java style construction

This approach doesn’t really deserve any more explanation than it got at the simple model. It’s tedious to write, hard to both read and modify.

val houses = mutableListOf<House>()

val people1 = mutableListOf<Person>()
people1.add(Person("Alice", 31))
people1.add(Person("Bob", 45))
val items1 = mutableListOf<Item>()
items1.add(Gold(500))
val house1 = House(people1, items1)
houses.add(house1)

val village = Village(houses)

A “home-made” DSL

Nesting these calls gets you a bit closer to a DSL, but this solution has the same issues as it had with the simple model. listOf calls are ugly, commas are easy to miss and difficult to maintain.

val village = Village(listOf(
            House(listOf(
                    Person(
                            name = "Alice",
                            age = 31
                    ),
                    Person(
                            name = "Bob",
                            age = 45
                    )
            ), listOf(
                    Gold(
                            amount = 500
                    )
            ))
))

DSL approaches

A traditional DSL

Same old, same old. Lambdas with receivers and builder classes. This is the no-thrills DSL for this problem.

val v = village {
    house {
        person {
            name = "Alice"
            age = 31
        }
        person {
            name = "Bob"
            age = 45
        }
        gold {
            amount = 500
        }
    }
}

A weird, overkill DSL

Here’s something you probably shouldn’t do. I included the entire village in this sample code so that you can see more of what this solution includes. While this is a really weird and unnecessary DSL, the extensible structure of its implementation is worth looking at.

val v = village {
    house {
        "Alice" age 31
        "Bob" age 45
        500.gold
    }
    house {
        sword with strength value 24.2
        sword with strength level 16.7
        shield with defense value 15.3
    }
    house()
    house {
        "Charles" age 52
        2500.gold
        sword
        shield
    }
}


You might also like...

Building a macOS screen saver in Kotlin

This article documents how I created my own custom screen saver for macOS, built almost entirely in Kotlin, using the powers of Kotlin Multiplatform compiled to macOS. Join me for a tale of Kotlin success and macOS failures.

All About Opt-In Annotations

Have you ever encountered APIs that show warnings or errors when you use them, saying that they're internal or experimental? In this guide, you'll learn everything you need to know about opt-in APIs in Kotlin: how to create and use them, and all their nuances.

The conflation problem of testing StateFlows

StateFlow behaves as a state holder and a Flow of values at the same time. Due to conflation, a collector of a StateFlow might not receive all values that it holds over time. This article covers what that means for your tests.

Wrap-up 2021

Another year over, a new one's almost begun. Here's a brief summary of what I've done in this one.