Lessons Learned from Kotlin: Kotlin DSL and Annotations
Recently, I developed a small daily task management tool using Kotlin. The primary goal was to make it easier for backend colleagues to maintain it together, while also gaining new sparks and perspectives from learning a new language—plus seizing the opportunity to learn from the many Java and JVM pros at our company.
The features of this daily task tool are quite simple:
- CRUD operations for TODOs; each Todo is scheduled upon creation, and if not completed within the allotted time, a Slack notification is triggered.
- Set up recurring schedules, such as checking Pull Requests or Jira progress daily.
Writing in Kotlin is very pleasant. It preserves the safety of static typing and compiler benefits on one hand, while reducing the verbosity typical of Java on the other. Moreover, Kotlin offers plenty of handy syntax features that help simplify code. In this article, I’ll introduce some of the concepts I learned while picking up Kotlin.
Kotlin DSL
The code below is valid Kotlin syntax:
Bot.sendMessage(channelId) {
section {
plainText("This is a plain text section block.")
}
section {
markdownText("This is a mrkdwn section block :ghost: *this is bold*, and ~this is crossed out~, and <https://google.com|this is a link>")
}
divider()
}
After executing the code, it converts to something like this:
{
"blocks": [
{
"type": "section",
"text": {
"type": "plain_text",
"text": "This is a plain text section block.",
"emoji": true
}
},
{
"type": "section",
"text": {
"type": "mrkdwn",
"text": "This is a mrkdwn section block :ghost: *this is bold*, and ~this is crossed out~, and <https://google.com|this is a link>"
}
}
]
}
You can compose messages by defining the format specified in Slack’s Block Kit.
Suppose you want to send a message to Slack today; you can use Block Kit to construct messages in Slack. According to the official API guide, you can write it like this:
val slack = Slack.getInstance()
val token = System.getenv("token")
val response = slack.methods(token).chatPostMessage { req -> req
.channel("C1234567")
.blocks {
section {
// "text" fields can be constructed via `plainText()` and `markdownText()`
markdownText("*Please select a restaurant:*")
}
divider()
actions {
button {
text("Farmhouse", emoji = true)
value("v1")
}
button {
text("Kin Khao", emoji = true)
value("v2")
}
}
}
}
It looks clean and intuitive, almost like writing some kind of Component, which immediately made me fall in love with Kotlin’s syntax.
Additionally, in the exposed library, you can write SQL queries like this:
val status = "DONE"
Todos.select {
Todos.status eq (status)
}
This also leverages Kotlin DSL capabilities to create the feeling of “writing native SQL.”
So how is Kotlin DSL achieved under the hood? It mainly combines a few concepts:
- extension functions
- infix notation
- lambda expressions
- function literals with receiver
Extensions
In Kotlin, you can add a new method to a class without using inheritance or decorator patterns, as shown in the syntax below:
fun Int.add2() {
this + 2
}
This way, you can call it directly on the Int type:
fun add() {
val a = 100
a.add2()
}
An extension’s implementation doesn’t literally cram the method into the class; instead, it allows you to invoke the extension implementation as if it were a member function using dot notation. So why not just implement it inside the class directly? Sometimes you can’t easily modify the code, such as with third-party libraries, making extensions an extremely handy tool.
Infix notation
In Kotlin, if you mark a function with infix, it means the function can be called omitting the dot and parentheses. To achieve this, infix notation must meet a few requirements:
- It must be a member function or an extension function.
- It must have a single parameter.
- The parameter must not accept a default value.
The result looks like this:
infix fun Int.addSome(num: Int) {
this + num
}
fun add() {
val a = 100
a.addSome(10)
a addSome 10
}
Here, a.addSome(10) and a addSome 10 are completely identical. Of course, 10 addSome a would also be a valid expression.
The most common infix function in Kotlin is probably to. In Kotlin, you can declare a map like this:
val map = mapOf("name" to "kalan", "age" to 25)
to is actually an infix function—syntactic sugar for conveniently creating a Pair. Its implementation looks like this:
public infix fun <A, B> A.to(that: B): Pair<A, B> = Pair(this, that)
Through this definition, we can reduce visual noise in the code as much as possible, significantly improving readability.
Lambda Expressions
Lambda expressions in Kotlin are quite flexible. You can write:
fun test(a: Int, block: () -> Unit) {
}
test(1, {
println("hello")
})
When a lambda expression is the last parameter, you can move the lambda outside the parentheses, like this:
fun test(a: Int, block: () -> Unit) {
}
test(1) {
println("hello")
}
Both are completely equivalent. Furthermore, Kotlin performs type inference on your lambda function, so if you write it like this:
fun test(a: Int, block: (i: Int) -> Unit) {
}
test(1) {
println("hello")
}
In the IDE, it will be displayed as the value parameter, where it is the i passed into the block.

This is equivalent to:
fun test(a: Int, block: (i: Int) -> Unit) {
}
test(1) { i ->
println("hello", i)
}
For this part, you can refer to Scope Functions in Kotlin to understand how they work.
Function Literals with Receiver
Let’s introduce this directly using an example from the official documentation. In Kotlin, a function can be written like this:
fun html(init: HTML.() -> Unit): HTML {
val html = HTML()
html.init()
return html
}
Let’s look at the init parameter first. It’s declared as a function typed as HTML.() -> Unit, known as a function type with receiver. This means that at some point, we need to pass html as the context to the init function—somewhat like apply(context) in JavaScript, but with the added benefit of being type-safe. T.() -> K means executing a member function on type T that returns K.
Next, let’s look at another example. In kotlinx.serialization, we can declare a Json instance like this:
val serializer = Json {
ignoreUnknownKeys = true
encodeDefaults = true
coerceInputValues = true
}
Under the hood, the implementation looks like this:
public fun Json(from: Json = Json.Default, builderAction: JsonBuilder.() -> Unit): Json {
val builder = JsonBuilder(from.configuration)
builder.builderAction()
val conf = builder.build()
return JsonImpl(conf)
}
In other words, in the code above, the actual execution looks like this:
val builder = JsonBuilder()
builder.ignoreUnkownKeys = true
builder.encodeDefaults = true
builder.coerceInputValues = true
builder.build()
By leveraging these Kotlin syntax features combined with a bit of imagination, you can build simple yet powerful DSLs to streamline your code—and everything remains type-safe!
Annotations
When implementing the Cronjob feature, I created a cronjob annotation for better readability, inspired by Spring Boot’s @Scheduled:
@CronSchedule("00 18 * * 1-5")
class NotifyMeCheck: Scheduler(), Workable {
override fun work(args: Arg) {
// job implementation
}
}
@Target(AnnotationTarget.CLASS)
@MustBeDocumented
annotation class CronSchedule(val cronExpression: String) {}
Then at runtime, it scans all nested classes in CronWorkEntry for those marked with CronSchedule and schedules the cron jobs, roughly like this:
CronWorkEntry::class.nestedClasses.forEach {
val annotation = it.findAnnotation<CronSchedule>()
if (annotation != null) {
val worker = it.createInstance() as Scheduler
worker.performCron(annotation.cronExpression,"${CronWorkEntry::class.qualifiedName!!}$${it.simpleName!!}", "", true)
}
}
However, it.createInstance() as Scheduler feels a bit off here. Because there is no compile-time constraint enforcing that the class must be a Scheduler, failing to inherit from it would throw an Exception directly at runtime. I wonder if there is a way to restrict an annotation so that it can only be applied to certain class types.
Related Posts
- When a Measure Becomes a Target: From the Window Tax to Pull Request Counts I once wrote a script to tally how many PRs I contributed in a quarter, how many reviews I left, and how many tickets I closed, hoping to use numbers to prove my output to my manager. My manager simply remarked that performance isn't just about output. Years later, I finally understood—when a measure becomes a target, it ceases to be a good measure. From the British window tax and the Hanoi rat bounty to evaluating developers by PR counts today, the underlying mechanism is exactly the same.
- Using Cloudflare Images for Image Storage and Transformation Putting an image on a webpage is the simplest task in frontend development. But doing it properly—including resizing, generating multiple formats, and withstanding heavy traffic—is actually an entire end-to-end solution. Eventually, I offloaded everything to Cloudflare Images, keeping only a single original image.
- Stop Using AWS Access Keys Access Keys are an easily overlooked security risk in AWS. By pairing OIDC with IAM Roles, GitHub Actions can securely operate AWS resources without storing any secrets.
- Database Primary Keys: AUTO_INCREMENT, UUID, and UUIDv7 Backend developers often face the choice of primary keys: should you use auto-increment or UUID? What about collisions? How does UUIDv7 compare to created_at + index in performance? Here are the design decisions and benchmark results from testing 20 million rows.