In this topic, we will build a simple Domain Specific Language (DSL) in Kotlin. Rather than implementing a complete solution, we’ll focus on the core principles that power Kotlin DSLs.
DSL-Specific Kotlin Features
Kotlin offers several features that make DSL design easy and elegant:
Lambda with receiver - Allows for scoped configuration blocks. Learn more.
Scope functions - Functions like
apply,run,with, andalsohelp create fluent builder-style APIs. Learn more.@DslMarker - A special annotation that prevents conflicts between nested DSL scopes. We'll explore this in more detail below.
Building the Validation DSL step-by-step
Our goal is to write clear Kotlin DSL code like:
validation {
field("email") {
mustNotBeEmpty()
mustContain("@")
}
field("age") {
mustBeGreaterThan(18)
}
}In this DSL block, we configure two fields (email and age) and assign rules to each. These rules will later be used to validate user input.
To implement this DSL, we must define:
A
ValidationBuilderto manage overall structure and store fieldsA
FieldBuilderto manage the rules for a specific fieldFunctions like
mustNotBeEmpty()andmustContain()to define the actual rules
Rules
A Rule is composition of an error message and a function that checks whether a given value is valid.
Here's how we define the structure for these rules:
typealias ErrorMessage = String
typealias ValidationResult = (String) -> Boolean
data class Rule(val errorMessage: ErrorMessage, val check: ValidationResult)The ValidationResult() is a function that takes a String and returns true if the value passes the check. This design allows us to store the logic and execute it later.
FieldBuilder
The first required builder is responsible for defining and collecting the validation rules for a single field.
annotation class ValidationDsl // our custom annotation
@ValidationDsl
class FieldBuilder(val name: String) {
private val _rules = mutableListOf<Rule>()
val rules: List<Rule> get() = _rules.toList()Such classes are common for DSL building. Each block of the DSL corresponds to a class that collects configuration logic. Learn more about builder pattern here.
We use a private a mutable list to store rules internally and expose an immutable view to the outside.
Functions in this class adds a rule to a list of rules. Let's start with simple rule, that checks if the value is not empty:
fun mustNotBeEmpty() {
_rules.add(Rule("$name cannot be empty") {
it.isNotEmpty() // Check if field is not empty
})
}Pretty easy, right! The next rule parses the string to an integer and checks if it exceeds a given threshold:
fun mustBeGreaterThan(number: Int) {
_rules.add(Rule("$name must be greater than $number") {
val parseResult = it.toIntOrNull() // Try to parse to Int
parseResult != null && parseResult > number // Compare if valid
})
}And the last required rule checks if the string includes a specific character:
fun mustContain(char: Char) {
_rules.add(Rule("$name must contain $char") {
it.contains(char)
})
}ValidationBuilder
This other necessary builder will be responsible for managing all the fields and validating them against the registered rules.
@ValidationsDsl
class ValidationBuilder {
private val _fields = mutableListOf<FieldBuilder>()The builder tracks all fields (FieldBuilder) in a list. We need to add fields somehow. Let's add to ValidationBuilder a function for this! Each field() call creates a new builder for that field, applies the configuration block, and stores it.
fun field(name: String, block: FieldBuilder.() -> Unit) {
val fieldBuilder = FieldBuilder(name)
block(fieldBuilder)
_fields.add(fieldBuilder)
}Also, we need to check our rules. Let's add a function that receives input data and applies all rules, collecting any errors.
fun validate(data: Map<String, String>): List<String> {
val errors = mutableListOf<String>()
// Iterate over all fields and apply rules
for (field in _fields) {
val value = data[field.name] ?: ""
for (rule in field.rules) {
if (!rule.check(value)) errors.add(rule.errorMessage.format(field.name))
}
}
return errors
}DSL Entry Point
We need to connect our DSL somehow. Let's add a function that enables the top-level validation() block.
fun validation(block: ValidationBuilder.() -> Unit): ValidationBuilder {
val builder = ValidationBuilder()
block(builder)
return builder
}This function creates and returns a fully configured ValidationBuilder .
Usage example:
fun main() {
// Create the validation DSL structure
val validation = validation {
field("email") {
mustNotBeEmpty() // Rule: email must not be empty
mustContain('@') // Rule: email must contain @
}
field("age") {
mustBeGreaterThan(18) // Rule: age must be greater than 18
}
}
// Example input data to validate
val data = mapOf(
"email" to "userexample.com",
"age" to "17"
)
// Run the validation and collect the result
val result = validation.validate(data)
if (result.isEmpty()) {
println("All validations passed!")
} else {
println("Validation failed:")
result.forEach { println("- $it") } // Print each error message
}
}This shows how the DSL can be used to validate data in practice.
Output:
Validation failed:
- email must contain '@'
- age must be greater than 18This confirms that the rules are working as expected.
Controlling Scope with @DslMarker
When designing complex and nested DSLs, it’s important to prevent unintended access to outer scopes.
Kotlin provides the @DslMarker annotation to help enforce scope rules. Let’s redefine our annotation:
@DslMarker
annotation class ValidationDslWe then apply this annotation to our builder classes:
@ValidationDsl
class ValidationBuilder { }
@ValidationDsl
class FieldBuilder(val name: String) { }Without @DslMarker we can accidentally create DSL like this:
// Assume basic DSL functions are defined like this:
fun validation(block: ValidationBuilder.() -> Unit) = ...
fun ValidationBuilder.field(name: String, block: FieldBuilder.() -> Unit) = ...
validation {
field("name") {
field("nested") {
validation {
// This is confusing and should not be allowed
}
}
}
}This type of nesting doesn’t make sense in our DSL. Without the @DslMarker, the compiler allows it. With this annotation applied, Kotlin will raise an error like:
// Compiler error:
fun field(name: String): Unit can’t be called in this context by implicit receiver. Use the explicit one if necessaryThis ensures that each block can access only its relevant scope.
Key principle: How a DSL Works
We just build a DSL, now let's see how it works under the hood. A DSL is essentially a configuration block that collects and stores logic to be executed later.
When we call validation, we build an internal object using a lambda with a receiver: the ValidationBuilder becomes the receiver (this) of that block. This allows us to write DSL-style code like field("email") directly, instead of calling methods on an object explicitly. Each field call creates a new context, a sub-builder, responsible for gathering rules related to that specific field.
Each rule, like mustNotBeEmpty(), is simply a lambda or function call that gets added to a list of validation checks. These rules are not executed immediately: when we later call something like validate(data), that’s when the rules are evaluated. The DSL just collects all the logic, storing it in a structured way.
This separation between configuration and execution is what makes DSLs powerful: they allow users to express intent clearly, while the actual logic stays hidden inside well-designed builder classes.
Benefits of using a DSL vs Traditional Code
Traditional validation approaches often involve imperative code with multiple if statements:
if (email.isEmpty()) {
errors.add("email cannot be empty")
}
if (!email.contains('@')) {
errors.add("email must contain '@'")
}
if (age.toIntOrNull()?.let { it <= 18 } != false) {
errors.add("age must be greater than 18")
}This can become verbose and harder to maintain as the number of validations grows.
Using a DSL:
Improves readability: Validation rules are declared in a structured and human-readable format.
Enhances maintainability: Adding or modifying rules is straightforward and localized.
Promotes reusability: Common validation logic can be encapsulated within DSL functions.
The DSL approach aligns the code structure closely with the domain logic, making it more intuitive and less error-prone.
Extending the DSL
Once our DSL is structured, extending it is straightforward.
For example, we can define additional rule functions like mustBeLessThan() or mustStartWith() and add them to the FieldBuilder. These functions follow the same pattern as the ones we defined and allow the DSL to support more cases.
Another interesting improvement is making the DSL type-safe by passing a data class as a generic type to the validator. This would allow the DSL to access the fields directly, making validation safer and more expressive.
These kinds of extensions demonstrate how DSLs in Kotlin can evolve: we don’t need to build everything at once.
Conclusion
In this topic, we've learned how to structure a simple internal DSL in Kotlin.
We looked at how to design a minimal DSL using configuration blocks with lambdas. We also saw how DSL functions like mustNotBeEmpty() can make validation code expressive and easy to read.
The key takeaway is that a DSL is just a well-designed builder pattern, powered by Kotlin’s language features. DSLs are not limited to validation. We can apply these techniques to domains like HTML generation, configuration, or workflow orchestration.