One of the Kotlin programming language’s strengths is the ability to easily define a language for a specific use case, or a domain-specific language (DSL). In this post, I will showcase how we can define a domain-specific language to create objects with nested hierarchies. Imagine that we have a list of authors and each author can have more than one book and each book consists of several chapters:
data class Author(
val id: String,
val firstName: String,
val lastName: String,
val books: List<Book>
)
data class Book(
val id: String,
val title: String,
val chapters: List<Chapter>
)
data class Chapter(
val id: String,
val number: Int,
val title: String
)
The goal of this post is to be able to easily create these objects and only provide the information that is available and have defaults for the information that is not.
Kotlin default parameters
One of the easiest and most straightforward ways to enable the creation of values in Kotlin is by defining functions with default values:
fun author(
id: String = UUID.randomUUID().toString(),
firstName: String = "",
lastName: String = "",
books: List<Book> = emptyList(),
): Author = Author(
id = id,
firstName = firstName,
lastName = lastName,
books = books,
)
fun book(
id: String = UUID.randomUUID().toString(),
title: String = "",
chapters: List<Chapter> = emptyList(),
): Book = Book(
id = id,
title = title,
chapters = chapters,
)
fun chapter(
id: String = UUID.randomUUID().toString(),
number: Int = 0,
title: String = "",
): Chapter = Chapter(
id = id,
number = number,
title = title,
)
With default parameters one can easily provide only the information that is available:
val authors = listOf(
author(
firstName = "John",
lastName = "McIntosh",
books = listOf(
book(
title = "Sunset",
chapters = listOf(
chapter(
number = 1,
title = "Afternoon"
),
chapter(
number = 2,
title = "Evening"
),
),
),
book(
title = "Sunset 2: The Rise of the Moon",
)
)
)
)
Hierarchical DSL modeling
The rest of the post will show how I modeled a DSL for creating authors, books, and chapters.
To begin, I needed a mechanism to create these objects and decided to abstract this concept with a simple Builder
interface:
interface Builder<T> {
fun build(): T
}
The way the builders will be used is by passing them in a function as the receiver of the function (e.g. SomeBuilder.() -> Unit
). To simplify writing these functions, I defined a typealias
so that they can be written as Dsl<SomeBuilder>
instead:
typealias Dsl<B> = B.() -> Unit
The Builder
implementations will provide the necessary fields specific to building an object. These properties are also mutable to allow the DSL to override them:
class ChapterBuilder : Builder<Chapter> {
var id: String = UUID.randomUUID().toString()
var number: Int = 0
var title: String = ""
override fun build(): Chapter {
return Chapter(id, number, title)
}
}
typealias ChapterDsl = Dsl<ChapterBuilder>
Because Author
and Book
both have children components (Book
and Chapter
, respectively) I wanted a base component that handled the parent-child Builder
interaction:
abstract class BaseBuilderWithChildren<Parent, Child, ChildBuilder : Builder<Child>>(
private val createChildBuilder: () -> ChildBuilder,
) : Builder<Parent> {
protected val children = mutableListOf<Child>()
protected fun child(dsl: Dsl<ChildBuilder>) {
children.add(
createChildBuilder()
.also(dsl)
.build()
)
}
}
This base builder class requires the type of the parent (e.g. Author
), the type of that type’s child (e.g. Book
) and the type of the child’s Builder
to facilitate the creation of the children objects via the child
function. The child
function could be part of the DSL, but I decided that I preferred explicit names such as book
and chapter
to define each object. As a result, the AuthorBuilder
and BookBuilder
classes must provide the name of the DSL function:
class BookBuilder : BaseBuilderWithChildren<Book, Chapter, ChapterBuilder>({ ChapterBuilder() }) {
var id: String = UUID.randomUUID().toString()
var title: String = ""
fun chapter(dsl: ChapterDsl) = child(dsl)
override fun build(): Book {
return Book(id, title, children)
}
}
typealias BookDsl = Dsl<BookBuilder>
class AuthorBuilder : BaseBuilderWithChildren<Author, Book, BookBuilder>({ BookBuilder() }) {
var id: String = UUID.randomUUID().toString()
var firstName: String = ""
var lastName: String = ""
fun book(dsl: BookDsl) = child(dsl)
override fun build(): Author {
return Author(id, firstName, lastName, children)
}
}
typealias AuthorDsl = Dsl<AuthorBuilder>
Additionally, because the goal is to create a list of authors, I created an additional Builder
for a list of authors alongside an entry-point function:
class AuthorListBuilder : BaseBuilderWithChildren<List<Author>, Author, AuthorBuilder>({ AuthorBuilder() }) {
fun author(dsl: AuthorDsl) = child(dsl)
override fun build(): List<Author> = children
}
fun createAuthors(dsl: Dsl<AuthorListBuilder>): List<Author> {
return AuthorListBuilder()
.also(dsl)
.build()
}
Altogether, these classes facilitate the following DSL:
val authors = createAuthors {
author {
firstName = "John"
lastName = "McIntosh"
book {
title = "Sunset"
chapter {
number = 1
title = "Afternoon"
}
chapter {
number = 2
title = "Evening"
}
}
book {
title = "Sunset 2: The Rise of the Moon"
}
}
}
Although the first approach using default function parameters is conceptually simpler to understand and faster to develop for simple models, the DSL approach provides more readable code due to the reduction of “bloat” (commas, listOf
, and parentheses).
Thanks for reading!