How Gradle works in a Kotlin project

Issue #1054

Open almost any Kotlin project and you find two files: settings.gradle.kts and build.gradle.kts. It is rarely obvious which one does what. To clear that up, we will build a tiny Kotlin app, add an external dependency on http4k, then split part of the code into a local module.

The .kts extension means these files use the Kotlin DSL rather than the older Groovy syntax. Kotlin gives you type safety and IDE autocompletion, so it is the better choice for new projects.

settings and build

The simplest way to keep them straight is this. settings.gradle.kts describes the build as a whole: its name and which modules belong to it. build.gradle.kts describes a single module: which plugins it applies, what it depends on, and how it is built.

Think of settings as the map and build as one location on that map. A project has exactly one settings file at its root, but it can have many build files, one per module. When Gradle starts, it reads the settings file first to learn the shape of the build, then reads each module’s build file to learn how to compile it.

A minimal single-module app

Start with the smallest thing that runs. The layout looks like this:

my-app/
├── settings.gradle.kts
├── build.gradle.kts
└── src/main/kotlin/Main.kt

The settings file only needs to name the build:

// settings.gradle.kts
rootProject.name = "my-app"

The build file declares the plugins and where to fetch dependencies, then sets the entry point:

// build.gradle.kts
plugins {
    kotlin("jvm") version "2.0.21"
    application
}

repositories {
    mavenCentral()
}

application {
    mainClass.set("MainKt")
}

The kotlin("jvm") plugin teaches Gradle how to compile Kotlin for the JVM. The application plugin adds a run task and knows how to start your program. The repositories block tells Gradle where to look for anything you depend on, and mavenCentral() is the public repository most libraries publish to.

The code itself is ordinary Kotlin:

// src/main/kotlin/Main.kt
fun main() {
    println("Hello from Gradle")
}

Run it with ./gradlew run. The gradlew script is the Gradle wrapper, a small launcher checked into the project that downloads the exact Gradle version the project expects. Using it means everyone builds with the same Gradle, so you never depend on whatever version happens to be installed on the machine.

Adding an external dependency

A real app needs libraries. Let us turn this into a tiny web server with http4k, a Kotlin HTTP toolkit. Dependencies go in a dependencies block in the build file:

// build.gradle.kts
dependencies {
    implementation("org.http4k:http4k-core:5.32.0.0")
    implementation("org.http4k:http4k-server-undertow:5.32.0.0")

    testImplementation(kotlin("test"))
}

Each line is a coordinate in the form group:name:version. Here org.http4k is the group, http4k-core is the artifact, and 5.32.0.0 is the version. Gradle takes that coordinate, looks in the repositories you declared, downloads the jar along with anything it transitively needs, and puts it on the compile classpath.

The word in front of each coordinate is the configuration, and it controls visibility.

  • implementation means the dependency is used internally and is not exposed to anyone who depends on your module.
  • api means the dependency leaks into your public surface, so consumers see it too.
  • testImplementation means the dependency is only on the classpath when compiling and running tests, never in the shipped artifact.

Reach for implementation by default and only widen to api when a type from the library appears in your own public signatures. Narrow visibility keeps rebuilds fast, because a change to an implementation dependency does not force every downstream module to recompile.

With the dependency in place, the server is a few lines:

// src/main/kotlin/Main.kt
import org.http4k.core.Method.GET
import org.http4k.core.Response
import org.http4k.core.Status.Companion.OK
import org.http4k.routing.bind
import org.http4k.routing.routes
import org.http4k.server.Undertow
import org.http4k.server.asServer

fun main() {
    val app = routes(
        "/ping" bind GET to { Response(OK).body("pong") }
    )
    app.asServer(Undertow(9000)).start()
    println("Listening on http://localhost:9000/ping")
}

http4k-core gives you the routing and the request and response model. http4k-server-undertow provides the actual server backend that listens on a port. Splitting a library into a small core plus optional backends is common, and it is the reason you often add more than one coordinate for a single tool.

Splitting out a local module

As an app grows you want to separate concerns into modules. Modules compile independently, can be tested on their own, and only see each other when you explicitly wire them together. Let us pull the domain logic into a core module and keep the web layer in app.

The layout gains a second module:

my-app/
├── settings.gradle.kts
├── app/
│   ├── build.gradle.kts
│   └── src/main/kotlin/Main.kt
└── core/
    ├── build.gradle.kts
    └── src/main/kotlin/Greeter.kt

The root settings file is where modules are registered. This is the part people miss: a module does not exist as far as Gradle is concerned until the settings file includes it.

// settings.gradle.kts
rootProject.name = "my-app"

include("app")
include("core")

The string passed to include is a project path. The leading colon you see elsewhere, as in :core, is just how Gradle writes that path from the root. Each included module gets its own build file describing only itself. The core module is a plain Kotlin library, so it does not need the application plugin:

// core/build.gradle.kts
plugins {
    kotlin("jvm")
}

repositories {
    mavenCentral()
}
// core/src/main/kotlin/Greeter.kt
class Greeter {
    fun greet(name: String) = "Hello, $name"
}

The app module then depends on core using the project(...) notation instead of a remote coordinate:

// app/build.gradle.kts
dependencies {
    implementation(project(":core"))
    implementation("org.http4k:http4k-core:5.32.0.0")
    implementation("org.http4k:http4k-server-undertow:5.32.0.0")
}

implementation(project(":core")) tells Gradle that app needs core built first and on its classpath. Gradle works out the order automatically from these declarations, builds core, then app, and you can call Greeter from your server code as if it were any other class.

An external dependency points at a coordinate Gradle downloads; a local dependency points at a project path Gradle builds from source in the same run.

Written by

I’m open source contributor, writer, speaker and product maker.

Start the conversation