· 7 min read

The case against `kotlin-dsl`

I fear no man. But that thing... It scares me!

I fear no man. But that thing... It scares me!

I love Kotlin.

I love Gradle.

But that kotlin-dsl thing… I don’t love.

After more than 5 years of writing Gradle plugins in Kotlin, I keep getting into the same arguments about kotlin-dsl. I realize I never took the time to properly organize my thoughts around this.

Better late than never, this post is an attempt at listing everything that I dislike about kotlin-dsl. Hopefully it can also convince some of you to switch to org.jetbrains.kotlin.jvm.

Note: This post is specifically about kotlin-dsl. There are a lot of things to be said about the Gradle Kotlin runtime, but this has been discussed a lot already and is not the focus here.

What is kotlin-dsl?

kotlin-dsl is the Gradle recommended solution to build Gradle plugins in Kotlin.

The documentation is very clear about what kotlin-dsl does. In a nutshell:

  • Applies the Kotlin Gradle Plugin using the kotlin version embedded by Gradle.
  • Applies the java-gradle-plugin plugin.
  • Adds kotlin-stdlib and kotlin-reflect dependencies, using that same kotlin embedded version.
  • Adds gradleKotlinDsl() dependency.
  • Configures Kotlin compiler flags and plugins.
  • Adds support for precompiled scripts plugins.

You can add it to your buildSrc/build.gradle.kts and write perfect Gradle plugins using the language you love. Sounds like a good deal, right?

Unfortunately, that deal comes with a lot of fine print. I stopped using it long ago and you should too. Let’s see why…

It’s a bad name

When it comes to Kotlin and Gradle everything is “Kotlin DSL”. But what “DSL” means exactly? It’s unclear.

Kotlin has @DslMarker (doc) to define scopes. You would think the Gradle Kotlin DSL uses it.

But the Gradle Kotlin DSL doesn’t use @DslMarker. It’s really easy to resolve the wrong scope:

testing {
    suites {
        named("test") { // Should be named<JvmTestSuite>("test")
            dependencies {
                // Oops, this is the top-level one!
            }
        }
    }
}

Similarly, you would think DSL has some declarative aspect to it but it’s mostly programmatic under the hood. The order of your blocks is important.

dependencies {
  // Don't even think of using "foo" here
}
configurations {
  // This looks like a declarative block but it's just imperative code
  val foo by creating {
    isCanBeResolved = false
    isCanBeConsumed = false
  }
}

In the example above, your foo configuration only exists when that code is executed. Try to reference foo before that happens and you will get an error.

The Gradle Kotlin DSL tries very hard to make the syntax look declarative, but it’s all imperative under the hood.

This is why I like to talk about Kotlin build scripts. Kotlin build scripts are great. But what exactly is the Kotlin DSL? The line between imperative and declarative is blurred.

Of course a declarative approach would be nice and allow us to clearly separate concerns. I hope Declarative Gradle will fix this situation.

It fragments the ecosystem

By introducing things like sam-with-receiver and assignment compiler plugins, your code is not regular Kotlin anymore, it needs some context.

If you copy/paste some internet code without those plugins enabled, it’s just going to fail:

  // This doesn't compile
  publications {
    // Unresolved reference 'create'.
    create("default", MavenPublication::class.java) {
      // ...
    }
  }

This is because publication signature is void publications(Action<PublicationContainer> configure). The sam-with-receiver plugin turns the Action<> type parameter into a receiver.

Without the plugin, you need to reference the parameter:

  // This does compile
  publications {
    // By using 'it.' here, it compiles again.
    it.create("default", MavenPublication::class.java) {
      // ...
    }
  }

Same is true for the assignment compiler plugin.

If you want to talk with someone else, that someone else needs to know about all of these. You’re not all talking Kotlin anymore, you’re talking 2 dialects of Kotlin.

This wouldn’t be an issue if the dialect was required to write Gradle plugins. Coming from Occitania, I enjoy a good regional dialect. After all, everyone shares Jetpack Compose code happily. @Composable is vocabulary you need to have to talk Compose Kotlin. But you also can’t really do Jetpack Compose without the Compose compiler plugin.

A lot of folks write Gradle plugins without the sam-with-receiver and assignment plugins, Gradle included. So now kotlin-dsl just makes it harder to share knowledge with other people in the community.

Every time you share a snippet, you also need to share the surrounding context. This makes communication harder.

Similarly, gradleKotlinDsl() introduces alternative ways to achieve the same result:

kotlin {
  sourceSets {
    // Using gradleKotlinDsl()
    val concurrentMain by creating {
      // ...
    }
    // This is the exact same thing
    create("concurrentMain") {
      // ...
    }

    // Which one is better? 🤷‍♂️
  }
}

I like the later, others will like the typesafe illusion of the former. But the fact that there are now 2 solutions to the same problem creates unneeded cognitive load.

As the Python folks say:

There should be one— and preferably only one —obvious way to do it.

It’s slow

kotlin-dsl is slower due to “precompiled script plugins”. Generating the accessor, etc… This all adds up.

At the end of the day, Now In Android configuration time went from 12.724s to 0.765s by removing the precompiled script plugins.

It’s stuck on older tools

kotlin-dsl by default forces you on the same KGP and kotlinc version that your Gradle version is using.

This is not needed. You can use newer tools and have them target older versions.

Forcing an older version of KGP/kotlinc is the toolchain problem all over again. Just because you want to target an older runtime doesn’t mean you need to use an older tool.

It creates unnecessary coupling

In addition to forcing you to use older KGP, kotlin-dsl also forces the version of gradleKotlinDsl().

Why is it gradleKotlinDsl() and not something like org.gradle.kotlin:kotlin-dsl-extensions, I don’t know.

Back to my initial point about fragmenting the ecosystem, how would you consume these symbols if you are not using Gradle? Or even if you are using a different version of Gradle? I don’t know.

It’s doing too many things

Overall, kotlin-dsl is making a lot of decisions for you. Some of them you might be comfortable with (Do you need the java-gradle-plugin applied? Probably?) some maybe not (Are the Kotlin compilers plugin really mandatory? Personal preference?) while some of them are not so clear (Do I need -java-parameters? Maybe?).

Not everything is bad but because it’s all bundled together, it makes it hard to adopt only the parts that you need. And using an older compiler is definitely a part you don’t need!

So what? (conclusion)

Alright, so if kotlin-dsl isn’t the solution, what should we all use?

Turns out the answer is the same thing we use every day: org.jetbrain.kotlin.jvm.

org.jetbrain.kotlin.jvm is first party and uncoupled from the Gradle releases.

You can use the latest version as soon as it goes out. And it supports outputting compatible libraries using languageVersion and apiVersion:

plugins {
  id("org.jetbrains.kotlin.jvm").version("$latest")
}

kotlin {
  compilerOptions {
    /**
     * Yay! Latest tools with compat flags 🎉
     *
     * See also https://docs.gradle.org/current/userguide/compatibility.html
     */
    languageVersion.set(KotlinVersion.KOTLIN_2_0)
    apiVersion.set(KotlinVersion.KOTLIN_2_0)
    coreLibrariesVersion = "2.0.0"
  }
}

And voilà! Your plugin can now run in Gradle 8.11+

If you fancy the sam-with-receiver and assignment plugins, you can opt in support:

plugins {
  id("org.jetbrains.kotlin.jvm").version("2.2.0")
  id("org.jetbrains.kotlin.plugin.sam.with.receiver").version("2.2.0")
  id("org.jetbrains.kotlin.plugin.assignment").version("2.2.0")
}

samWithReceiver {
  annotation(HasImplicitReceiver::class.qualifiedName!!)
}
assignment {
  annotation(SupportsKotlinAssignmentOverloading::class.qualifiedName!!)
}

Just beware that the casual reader might find this code harder to read.

Same with everything else. If you really want to, you can even go back to almost the same setup as kotlin-dsl except that you can now use your KGP and kotlinc version of choice.

But really, just using the plain org.jetbrains.kotlin.jvm plugin is simpler, faster and easier to evolve and share.

If you’re looking for a sample plugin that does’t use kotlin-dsl, nmcp is a good example.

In the future, I hope Declarative Gradle can undo some of that confusion and draw a clear line between the declarative and the imperative worlds. Probably the topic of another post!


Photo from Miguel A Amutio on Unsplash

    Share:
    Back to Blog