Build a successful Kotlin DSL for your business domain
Kotlin is a great language for building custom Domain Specific Languages (DSL). Thanks to many features, building a DSL in Kotlin is a piece of cake… is it? The language is just a tool, and it is not the only tool needed to build a successful DSL. In this introductory article, I’d like to show a couple of aspects to consider when building a custom DSL in Kotlin. It is a good starting point to deep dive into more detailed topics later.
tl;dr;
A DSL in Kotlin may be useful both in production and testing code. Kotlin provides many features helpful in DSL design. We also need to think about the execution engine. We can find inspiration in actual interpreters, which separate the code parsing, Abstract Syntax Tree and execution phases.
Why DSL in Kotlin?
Kotlin is a programming language originated in JVM world, which now targets JS and native platforms, too. It was designed with building Domain Specific Languages in mind. But why should we actually bother? Why does our project need any DSL? Generally, we want to hide boilerplate code and reduce the maintenance cost. There are two areas where we might want to apply it:
- business logic in the production code,
- automatic tests.
Kotlin DSL in the production code…
Consider a service that requires some configuration of the business objectives. The configuration changes rarely enough that it is not feasible to build admin tools for it. However, if it changes, we want to change it quickly and avoid silly mistakes. The DSL can hide the complexity of an actual programming language. In return, it could bring the configuration close to the business language. Imagine that you give your configuration for a review directly to the product manager…
Keep in mind that a DSL is an investment. Not every problem is worth the cost. Take a look at a couple of aspects. How many developers are going to use it? Is there a lot of boilerplate code to repeat every time? How critical is it to capture business expectations correctly? If it targets many developers and a critical business feature, the successful DSL will quickly pay off.
In short…
Before creating a DSL in Kotlin, try to understand the cost of building it and check if it has a chance to pay off.
Kotlin DSL in tests…
Even if you don’t face the situation above, you likely use automated tests. The proven given-when-then convention assumes that we specify the business use case in the given section. Here, we usually don’t want to call the service API directly. Tests bring yet another reason to use DSL: resilience to changes. Imagine that you add a new feature to the service. This is a new variable that can affect the test outcome. Here’s what might happen with our tests:
- we forget to apply the feature in the existing tests,
- we accidentally change the idea of the existing tests.
Both of them are quite dangerous. Why? Over time, our tests may no longer cover the business use cases they were designed to. Now imagine that instead of changing tests, we extend the DSL to apply the new feature under the hood to the whole test suite. Not only this is much easier to maintain. We can also verify how our feature affects the existing functionality!
Expressive power of the DSL
To meet the objectives, our DSL must have sufficient expressive power. Eventually, if the developers find it too limiting or error-prone, they won’t want to use it! The common mistake I observe is associating the DSL expressive power with the language syntax. It’s true that it helps. Kotlin was designed with building DSL-s in mind. But you can create a nice-looking DSL even in Java. I’d even say that well-designed DSL in Java can beat poorly designed DSL in Kotlin. The real expressive power lies elsewhere.
Separation of concerns
At some point, the DSL must do something useful. Trigger some action, apply the configuration. It’s very easy to hide the actual commands directly under the DSL syntax:
class Domain {
fun operation() {
println("Doing something useful")
}
}
class DomainDsl(private val domain: Domain) {
fun doSomething() {
domain.operation()
}
}
fun setupDomain(configuration: DomainDsl.() -> Unit) {
val domain = Domain()
val dsl = DomainDsl(domain)
configuration(dsl)
}
This approach is easy to implement and works for simple use cases. Unfortunately, this is where we loose much of the expressive power. At some point we notice that we have to decouple the execution from the DSL description. Otherwise our DSL will strongly depend on the implementation details we try to hide. To deal with this, we can take some inspiration from the world of interpreters and compilers.
Hardly any interpreter executes the script code directly. Most designs assume building some form of intermediate Abstract Syntax Tree (AST). Parsing of the script produces the AST. Next, we can do some extra analysis, validation or optimization, and then we execute it. The same idea works in the world of DSL.
Note that the three components talk to each other only through a set of interfaces. In addition, the execution component does not interact with the DSL at all. Here’s an example realization:
// Business logic
class Domain {
fun operation(message: String) {
println("Doing something useful: $message")
}
}
// ************************
// Domain specific language
// ************************
interface DomainDsl {
fun doSomething(message: String)
}
interface DomainDefinition {
fun fetchActions(): List<DomainAction>
}
// ********************
// Abstract Syntax Tree
// ********************
class ActualDomainDsl : DomainDsl, DomainDefinition {
private val actions: MutableList<DomainAction> = ArrayList()
override fun doSomething(message: String) {
actions.add(DoSomethingAction(message))
}
override fun fetchActions(): List<DomainAction> = ArrayList(actions)
}
sealed interface DomainAction {
fun accept(visitor: DomainActionVisitor)
}
class DoSomethingAction(val message: String) : DomainAction {
override fun accept(visitor: DomainActionVisitor) {
visitor.visitDoSomethingAction(this)
}
}
// *********
// Execution
// *********
interface DomainActionVisitor {
fun visitDoSomethingAction(action: DoSomethingAction)
}
class DefaultDomainActionVisitor(val domain: Domain) : DomainActionVisitor {
override fun visitDoSomethingAction(action: DoSomethingAction) {
domain.operation(action.message)
}
}
// ***************
// DSL entry point
// ***************
fun setupDomain(configuration: DomainDsl.() -> Unit) {
val domain = Domain()
val dsl = ActualDomainDsl()
val visitor = DefaultDomainActionVisitor(domain)
configuration(dsl)
dsl.fetchActions().forEach { action ->
action.accept(visitor)
}
}
fun main() {
setupDomain {
doSomething("test")
}
}
The increase in code size is evident. However, we can do much more with such a starting point:
- validate the entire setup before execution,
- add some default actions, unless explicitly configured in the DSL,
- change the execution target by writing another visitor implementation (imagine using the same testing DSL for both unit and integration tests)
In short…
Separation of concerns between the DSL syntax, Abstract Syntax Tree and execution is very successful in interpreters and compilers. It also works well in the DSL world.
Know Kotlin DSL syntax
The Kotlin syntax offers several tools that help us with building a custom DSL:
- named arguments
- high-order functions and lambdas
- function literals with receiver
- infix functions
- limited operator overloading
- extension functions
- generics
@DslMarker
annotation
The danger of having so many features is that they can be easily abused. In another article, I showed the example of extension functions, a nice tool for enhancing third-party libraries and integrating our DSL with other classes. However, if we use them to extend our own classes, we effectively destroy the code organization. The logic that appears to belong to class X, is smashed across the entire project. Over time, figuring out where different pieces of logic are, becomes a challenge. Before building a DSL, take a look at Kotlin documentation and the intended use cases for each feature.
Another thing to consider are automatic code formatters. Kotlin comes with its own coding style guidelines, and tools to enforce them, such as Ktlint. For this reason, we should avoid inventing custom formatting for our DSL. Those tools will not handle it.
In short…
Avoid abusing Kotlin syntax, and inventing custom formatting for your DSL.
Use the right testing tool
The last point is important, when the DSL is a part of the production code. Some projects use more than one programming language, for example – Kotlin for business logic and Groovy with Spock framework for testing. Spock is particularly interesting example, because it is tightly coupled to Groovy language. Both Groovy and Kotlin are sister JVM languages. Java is the lingua franca of the entire JVM ecosystem, and we can expect that every JVM language would support Java language constructs. However, other JVM languages are not required to understand each other’s constructs.
Here’s the point. Kotlin is not the lingua franca of the JVM world. Therefore, if you write your tests in a different language, some parts of Kotlin syntax will be poorly supported. It’s not that you won’t be able to use them. You will, but you’d have to learn some Kotlin internals. And in case of the DSL, the outcome would be simply ugly. To the newcomers, the tests would give no idea how the DSL looks in practice and how to use it. This defeats the whole purpose of building the DSL in Kotlin. Effectively, you throw away a part of Kotlin power.
In short…
In order to use a Kotlin DSL in the production code, you should write your tests in Kotlin, too!
Conclusion
Keep in mind that building a successful DSL is a kind of art. Don’t expect that your first DSL will meet the goals. In most cases, it will not 🙂 – even if you are a senior developer! Designing a DSL is somehow similar to the expertise in UI/UX. It takes time to learn how to combine different elements to create something useful and not limiting. How to leave a room for expansion without reworking. Which elements might change often, which won’t. And when you finally know it all, you encounter a new business situation that requires a fresh approach. Don’t be overhelmed with that. It’s normal!
This article is just an introduction to the fascinating world of domain specific languages. Over the next weeks, I want to make a deep-dive into the covered topics, especially the separation of concerns. Stay tuned and follow zone84!