One Source to Rule Them All

Kotlin DSLs as a Single Source of Truth for Multiple Tasks

Ivan Ponomarev / NDC Copenhagen 2025

ivan

Ivan Ponomarev

  • Staff Engineer @ Synthesized.io

  • Teaching Java & CS at universities

In this talk we will discuss

  • Strengths and weaknesses of Kotlin DSL compared to other options for creating DSLs

  • Several examples of tasks solved using Kotlin DSL

  • Some advanced specifics of designing Kotlin DSL

NOT in this talk

  • Basics of creating Kotlin DSL (but there will be references to relevant sources)

  • This talk is AI free!

DSLs external and internal

Diagram

Martin Fowler, Rebecca Parsons
Domain-Specific Languages

fowler dsl

Types of external DSLs

Diagram
Designed from scratch
  • DOT (GraphViz), PlantUML, gnuplot…​

  • HCL (Hashicorp Configuration Language)

  • Gherkin (Cucumber framework’s language)

Types of external DSLs

Diagram
Based on general purpose markup language
  • YAML: OpenAPI, Ansible, GithubActions, k8s definitions…​

  • XML: XSLT, XSD

Internal DSLs

Diagram
Subset of a dynamically typed language
  • Lisp (historically first): Emacs Lisp, Symbolic Mathematics etc.

  • Ruby: Rails, RSpec, Chef…​

  • Groovy: Spock, Ratpack, Grails, Gradle, Jenkinsfile…​

Internal DSLs

Diagram
Subset of a statically typed language
  • Scala: Scalatest, Akka HTTP…​

  • Haskell: Parsec

  • Kotlin: Kotlinx.html, Ktor, Gradle, Koog…​

Restrictions

Diagram

Restrictions

Diagram

Restrictions

Diagram

Restrictions

Diagram

IDE support

Diagram

IDE support

Diagram

IDE support

Diagram

IDE support

Diagram

Security Concerns

Diagram

* Does not mean that you are safe, e.g. google for "Billion_laughs_attack".

Interim Conclusions

  • Kotlin DSLs belong to the class of internal DSLs based on statically typed programming languages

  • This has both advantages and disadvantages

  • The choice is yours!

How do we build Kotlin DSL?

jemerov kotlin

Dmitry Zhemerov, Svetlana Isakova
Kotlin in Action
(2nd edition)

How do we build Kotlin DSL?

arkhipov

Kotlin language features for DSL building

Tool

DSL syntax

General syntax

Extension functions

mylist.first();
/* there isn’t first() method
in mylist collection*/
ListUtlis.first(mylist)

Infix functions

1 to "one"
1.to("one")

Operators overloading

collection += element
collection.add(element)

Type aliases

typealias Point = Pair

Creating empty inheritors classes and other duct tapes

Kotlin language features for DSL building (continued)

Tool

DSL syntax

General syntax

get/set methods convention

map["key"] = "value"
map.put("key", "value")

Destructuring declaration

val (x, y) = Point(0, 0)
val p = Point(0, 0)
val x = p.first
val y = p.second

Lambda out of parentheses

list.forEach { ... }
list.forEach({...})

Lambda with receiver (the main tool)

Person().apply { name = "John" }

N/A

Context control

@DslMarker

N/A

Demo time!

  • Our domain:

    • Conditions

    • Transformations

    • Rules

  • If a set of conditions is met, the necessary transformation is triggered

Initial State: Imperative Code

fun main() {
    if (conditionOneMet() && conditionTwoMet()) {
        runTransformationA()
    } else if ((ConditionThreeMet() || ConditionFourMet()) && conditionOneMet()) {
        runTransformationB()
    } else if (conditionTwoMet()) {
        runTransformationC()
    }
}
  • Step-by-step debugging!

  • Testing with an eye on coverage (although substituting conditionXmet() and runTransformationX() with mocks may be challenging)

  • The code grows and quickly becomes difficult to read/maintain

Class model

Diagram

"Strategy" Pattern, Declarative Code

private fun rules(): List<Rule> = listOf(
    Rule(ConditionII, TransformationC),
    Rule(Not(ConditionIV), TransformationB),
    Rule(And(ConditionI, ConditionII), TransformationA),
    Rule(Or(And(ConditionIII, ConditionIV), ConditionI), TransformationB)
)


fun main() {
    rules()
        .firstOrNull { it.condition.met() }
        ?.transformation?.run()
}
  • This can be written in Java, but it turned out more compact in Kotlin due to the absence of new.

  • The visual connection between rules and transformations is now obvious

  • Step-by-step debugging has turned into a nightmare

Kotlin DSL in All Its Glory

val rules: List<Rule> =
    // @formatter:off
    rules {
        ConditionI and ConditionIV         invokes TransformationA
        ConditionII                        invokes TransformationC
        not(ConditionIV)                   invokes TransformationB
        (ConditionI and not(ConditionIII)) invokes TransformationA
        (ConditionIII
                and ConditionIV
                or ConditionI)             invokes TransformationB
    }
   // @formatter:on
  • The method parameter rules is a lambda with a receiver

  • and, or, not, invokes are infix extension functions

Execution

fun main() {
    rules.firstOrNull { it.condition.met() }?.transformation?.run()
}
  • Advantage: all the code is a one-liner

  • Disadvantage: step-by-step debugging is impossible (but this can be compensated, see below)

Visualization: Transpilation to DOT

for (rule in rules) {
    rule.visit(::visitor)
}

class Rule(
    val condition: Condition,
    val transformation: Transformation) : Element {
    override fun visit(visitor: (Element) -> Unit) {
        condition.visit(visitor)
        transformation.visit(visitor)
        visitor.invoke(this)
    }
}

Intricacies of Rules and Transformations

Diagram

Documentation: Transpilation to Asciidoctor

asciidoctor

Serialization

  • "Almost free" representation of DSL in JSON/YAML/XML formats using tools like FasterXML Jackson.

  • Ideal for building WebUI with forms for displaying/editing settings (projectional editors)

- condition:
    And:
      a:
        ConditionI: {}
      b:
        ConditionIV: {}
  transformation:
    TransformationA: {}

Testing complex restrictions

Code that generates 2N combinations of sets of fulfilled conditions,
where N is the number of subclasses of BasicCondition

private val conditions = BasicCondition::class.sealedSubclasses
fun outcomes(): Sequence<Set<ConditionClass>> = sequence {
    for (i in 0L until (1L shl conditions.size)) { // this is where 2^N emerges
        val activeConditions = mutableSetOf<ConditionClass>()
        for (j in 0 until conditions.size) {
            if ((i and (1L shl j)) != 0L) {
                activeConditions.add(conditions[j])
            }
        }
        yield(activeConditions)
    }
}

Model Consistency Check

  • "There are no unreachable rules"

  • "For a fixed condition, every rule of a certain class is achievable"

  • …​ etc. — it all depends on your task

We test the model itself, defined in the DSL, not the result of its interpretation!

One source to be used in

  1. Execution of rules

  2. Documentation

  3. Visualization

  4. Validation

  5. Serialization ("free" JSON/YAML based DSL version for our Kotlin DSL)

Advanced topics of DSL building

DSL and imperative code Mix & Match

// Extension point in the builder
fun customCondition(lambda: () -> Boolean): Condition =
    Condition(lambda)

// Using the extension point
customCondition { Random.nextDouble() < .88} invokes TransformationC


// Some other DSL
customBusinessRule { checkSmthProgramatically() }

DSL and imperative code Mix & Match

  • A powerful approach for modeling business rules (e.g. state transitions), especially when a DSL can capture only part of the logic.

  • Depending on context, this can be a good practice — or a pitfall.

  • Combined with Kotlin Scripting, it unlocks scripting capabilities for your applications.

Annoying Kotlin limitation

In Groovy we can omit parentheses when calling a method!

Groovy Gradle DSL:

implementation 'com.acme:example:1.0'

Kotlin Gradle DSL:

implementation ("com.acme:example:1.0")

Infix Functions Do Not Work on this

val ndcCopenhagen = conference {
    //Cannot be called without parentheses (unlike Groovy)
    session("Edge-native applications - what happened to cloud-native?") deliveredBy {
        speaker("Mikkel Mørk Hegnhøj")
        speaker("Thorsten Hans")
    } inRoom ("Room 3")

    session("One Source to Rule Them All: Kotlin DSLs") deliveredBy {
        speaker("Ivan Ponomarev")
    //May be called without (..) -- infix fun
    } inRoom "Room 3"
}

Infix Functions Do Not Work on this:
a workaround

Is it cleaner or is it less intutitive?

val ndcCopenhagen = conference {
    + "Edge-native applications - what happened to cloud-native?" deliveredBy {
        + "Mikkel Mørk Hegnhøj"
        + "Thorsten Hans"
    } inRoom "Room 3"

    + "One Source to Rule Them All: Kotlin DSLs" deliveredBy {
        + "Ivan Ponomarev"
    } inRoom "Room 3"
}

@DslMarker

For the DSL we wouldn’t want this, but a lambda with a receiver does not prohibit it:

conference { //this: ConferenceBuilder
    session ("Session 1") deliveredBy {
        //this: ConferenceBuilder.SpeakersBuilder,
        //but methods from ConferenceBuilder are also available
        session (...) // ???!!!
    }
}

@DslMarker

@DslMarker
annotation class ConferenceDsl

@ConferenceDsl
class ConferenceBuilder { ... }

@ConferenceDsl
class SpeakersBuilder { ... }
  • Extended IDE support is promised (though not documented), so it makes sense to annotate DSL builders with @DslMarker in any case.

Cross references

val ndcCopenhagen = conference {
    val ip by speaker("Ivan Ponomarev", "Synthesized")
    val mj by speaker("Mark Jervelund", "Microsoft")

    session("One Source to Rule Them All: Kotlin DSLs") deliveredBy {
        +ip
    } inRoom "Room 6"
    session("Part 1/2: Introduction to capture the flag (CTF)") deliveredBy {
        +mj
    } inRoom "Room 4"
    session("Part 2/2: Introduction to capture the flag (CTF)") deliveredBy {
        +mj
    } inRoom "Room 4"
}
  • val ip = …​ looks like imperative code, but there’s nothing to be done about it.

  • Groovy offers much more possibilities here.

Capturing the variable name through delegation

//ConferenceBuilder
val speakers = mutableMapOf<String, Speaker>()
//The function returns a delegate for a read-only property
fun speaker(name: String, company: String): ReadOnlyProperty<Nothing?, Speaker> =
    ReadOnlyProperty { _, property ->
        //property.name contains the variable's name
        speakers.computeIfAbsent(property.name) { Speaker(it, name, company) }
    }

Capturing the variable name through delegation

//The language syntax itself will ensure the uniqueness of identifiers
val mmh by speaker("Mikkel Mørk Hegnhøj", "Fermyon")
val th by speaker("Thorsten Hans", "Fermyon")

session("Edge-native applications - what happened to cloud-native?") deliveredBy {
        +mmh
        +th
    } inRoom "Room 3"

Invoke operator

Classic pattern

fun verb(action: EntityBuilder.()->Unit) {
   val builder = EntityBuilder()
   builder.action()
   ...
}

allows verb { …​action…​ } but not verb.property = value

Invoke operator

object conference {
   //The default method with a lambda parameter
    operator fun invoke(action: ConferenceBuilder.() -> Unit): List<Talk> {
val builder = ConferenceBuilder()
builder.action()
return builder.talks;
}
    //additional properties
    var name: String = ""
}

Examples for your inspiration

Conclusions

  • DSL combined with design patterns is a powerful tool for solving multiple tasks.

  • Creating DSLs in Kotlin is not scary. You can improve parts of your existing internal APIs today making them "DSL-like".

  • Internal Kotlin DSLs are not the only way to implement DSLs, but definitely not the worst one in many scenarious.

Thanks for listening!

qr

Code and slides are available on GitHub https://github.com/inponomarev/dsl-talk

@inponomarev