Skip to content

Multi-Module Gradle Project

Build and explore a multi-module Gradle project: a root build that shares configuration across subprojects, a common library module holding the domain models, and an app module that depends on common. Along the way you wire up a version catalog for centralized dependency management and a couple of custom Gradle tasks.

If you’ve used a TypeScript monorepo (pnpm/Turborepo workspaces) or Go modules with a shared internal package, this is the JVM equivalent — except Gradle gives you typed inter-module dependencies and one place to pin every version.

  • A root project with shared configuration applied to every subproject.
  • A library module (common) exposing shared models and a repository.
  • An application module (app) that depends on common via project(":common").
  • A version catalog (gradle/libs.versions.toml) for type-safe, centralized versions.
  • Two custom Gradle tasks wired into the build.

The whole project is two modules under one root. The root build holds the shared config; each module carries only what is unique to it.

  • Directorymulti-module-project/
    • settings.gradle.kts declares both modules
    • build.gradle.kts shared config for all subprojects
    • gradle.properties build properties
    • Directorygradle/
      • libs.versions.toml centralized dependency versions
    • Directorycommon/ shared library module
      • build.gradle.kts
      • Directorysrc/main/kotlin/com/example/common/
        • Models.kt Task, TaskStatus, Priority
        • TaskRepository.kt in-memory repository
    • Directoryapp/ application module
      • build.gradle.kts
      • Directorysrc/
        • Directorymain/kotlin/com/example/app/
          • App.kt entry point
        • Directorytest/kotlin/com/example/app/
          • TaskRepositoryTest.kt

settings.gradle.kts — what modules exist

Section titled “settings.gradle.kts — what modules exist”

settings.gradle.kts is the single source of truth for the project’s shape. The include(...) calls register each subdirectory as a Gradle subproject. Nothing gets built unless it’s declared here.

settings.gradle.kts
rootProject.name = "multi-module-project"
include("common")
include("app")

Root build.gradle.kts — shared config via subprojects { }

Section titled “Root build.gradle.kts — shared config via subprojects { }”

The root build doesn’t produce any code of its own. Instead it declares the Kotlin plugin with apply false (so it’s on the classpath but not applied to the root), then uses a subprojects { } block to apply common configuration — group, version, repositories, the JVM toolchain, and JUnit Platform — to every module at once. This is the DRY mechanism: change the toolchain in one place and every module follows.

build.gradle.kts
plugins {
kotlin("jvm") version "2.1.0" apply false
}
subprojects {
apply(plugin = "org.jetbrains.kotlin.jvm")
group = "com.example"
version = "1.0.0"
repositories {
mavenCentral()
}
configure<org.jetbrains.kotlin.gradle.dsl.KotlinJvmProjectExtension> {
jvmToolchain(21)
}
tasks.withType<Test> {
useJUnitPlatform()
}
}

gradle/libs.versions.toml — the version catalog

Section titled “gradle/libs.versions.toml — the version catalog”

The version catalog centralizes every dependency coordinate and version into one TOML file. Modules then reference libraries through a generated, type-safe libs accessor (e.g. libs.kotlinx.coroutines.core) instead of hardcoded group:name:version strings. The [bundles] section groups related libraries so a module can pull a whole set in one line, and [plugins] does the same for plugin coordinates.

gradle/libs.versions.toml
[versions]
kotlin = "2.1.0"
coroutines = "1.9.0"
serialization = "1.7.3"
logback = "1.5.15"
[libraries]
kotlinx-coroutines-core = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-core", version.ref = "coroutines" }
kotlinx-serialization-json = { group = "org.jetbrains.kotlinx", name = "kotlinx-serialization-json", version.ref = "serialization" }
logback-classic = { group = "ch.qos.logback", name = "logback-classic", version.ref = "logback" }
kotlin-test = { group = "org.jetbrains.kotlin", name = "kotlin-test", version.ref = "kotlin" }
[bundles]
common = ["kotlinx-coroutines-core", "kotlinx-serialization-json"]
[plugins]
kotlin-jvm = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" }
kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" }

Performance and style flags that apply to the whole build live here. Parallel execution and the build cache are the wins that matter once a project has more than one module.

gradle.properties
org.gradle.jvmargs=-Xmx2g -XX:+UseParallelGC
org.gradle.parallel=true
org.gradle.caching=true
kotlin.code.style=official

common/build.gradle.kts — the library module

Section titled “common/build.gradle.kts — the library module”

The common module is a plain Kotlin library. Notice it declares its serialization dependency with api(...) rather than implementation(...): api exposes the dependency to consumers, so app gets kotlinx-serialization-json on its compile classpath transitively (it uses Json directly). implementation would hide it. The bare kotlin("jvm") carries no version — it inherits from the root.

common/build.gradle.kts
plugins {
kotlin("jvm")
kotlin("plugin.serialization") version "2.1.0"
}
dependencies {
api(libs.kotlinx.coroutines.core)
api(libs.kotlinx.serialization.json)
testImplementation(libs.kotlin.test)
}

common — the shared models and repository

Section titled “common — the shared models and repository”

Models.kt holds the domain types every module agrees on — in a real service these would be your API contracts. They’re @Serializable so the app module can turn them into JSON for free.

common/src/main/kotlin/com/example/common/Models.kt
package com.example.common
import kotlinx.serialization.Serializable
import java.util.UUID
@Serializable
data class Task(
val id: String = UUID.randomUUID().toString(),
val title: String,
val description: String = "",
val status: TaskStatus = TaskStatus.TODO,
val priority: Priority = Priority.MEDIUM
)
@Serializable
enum class TaskStatus {
TODO, IN_PROGRESS, DONE
}
@Serializable
enum class Priority {
LOW, MEDIUM, HIGH, CRITICAL
}

TaskRepository.kt is a simple in-memory store — the reusable component the library exposes. The update method takes a transform function (update: (Task) -> Task), so callers mutate by producing a copy rather than editing in place.

common/src/main/kotlin/com/example/common/TaskRepository.kt
package com.example.common
class TaskRepository {
private val tasks = mutableMapOf<String, Task>()
fun create(task: Task): Task {
tasks[task.id] = task
return task
}
fun findById(id: String): Task? = tasks[id]
fun findAll(): List<Task> = tasks.values.toList()
fun update(id: String, update: (Task) -> Task): Task? {
val existing = tasks[id] ?: return null
val updated = update(existing)
tasks[id] = updated
return updated
}
fun delete(id: String): Boolean = tasks.remove(id) != null
fun findByStatus(status: TaskStatus): List<Task> =
tasks.values.filter { it.status == status }
fun count(): Int = tasks.size
}

app/build.gradle.kts — the application module and custom tasks

Section titled “app/build.gradle.kts — the application module and custom tasks”

The app module is where the inter-module wiring happens: implementation(project(":common")) declares the dependency on the sibling module — that’s the line that makes this a multi-module build rather than two unrelated projects. The application plugin gives you ./gradlew run, and mainClass points at the generated AppKt class.

The two tasks.register(...) blocks add custom tasks under a custom group. showDeps resolves and prints the runtime classpath; generateBuildInfo writes a build-info.txt and is wired in as a dependency of the jar task with tasks.jar { dependsOn("generateBuildInfo") }, so it runs automatically whenever the JAR is built.

app/build.gradle.kts
plugins {
kotlin("jvm")
application
}
dependencies {
implementation(project(":common"))
implementation(libs.logback.classic)
testImplementation(libs.kotlin.test)
}
application {
mainClass.set("com.example.app.AppKt")
}
// Custom task: print all tasks in the common module this app depends on
tasks.register("showDeps") {
group = "custom"
description = "Shows the runtime dependencies of this module"
doLast {
configurations.getByName("runtimeClasspath").resolvedConfiguration
.resolvedArtifacts.forEach { artifact ->
println(" ${artifact.moduleVersion.id}")
}
}
}
// Custom task: generate a build info file
tasks.register("generateBuildInfo") {
group = "custom"
description = "Generates a build-info.txt with version and timestamp"
val outputFile = layout.buildDirectory.file("build-info.txt")
outputs.file(outputFile)
doLast {
outputFile.get().asFile.parentFile.mkdirs()
outputFile.get().asFile.writeText(
"""
|name=${project.name}
|version=${project.version}
|group=${project.group}
|built=${java.time.Instant.now()}
""".trimMargin()
)
println("Build info written to ${outputFile.get().asFile.absolutePath}")
}
}
// Make the JAR task depend on generateBuildInfo
tasks.jar {
dependsOn("generateBuildInfo")
}

App.kt imports straight from com.example.common.* — the types from the sibling module are just on the classpath, no publishing or path juggling. It builds a few Task objects, runs them through the TaskRepository, and serializes one to JSON using the Json instance it gets transitively because common exposed serialization with api.

app/src/main/kotlin/com/example/app/App.kt
package com.example.app
import com.example.common.*
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
private val json = Json { prettyPrint = true }
fun main() {
println("=== Multi-Module Gradle Project Demo ===\n")
val repo = TaskRepository()
val tasks = listOf(
Task(title = "Set up Gradle project", status = TaskStatus.DONE, priority = Priority.HIGH),
Task(title = "Learn version catalogs", status = TaskStatus.IN_PROGRESS, priority = Priority.HIGH),
Task(title = "Write custom tasks", status = TaskStatus.TODO, priority = Priority.MEDIUM),
Task(title = "Configure multi-module build", status = TaskStatus.TODO, priority = Priority.CRITICAL),
)
tasks.forEach { repo.create(it) }
println("All tasks (${repo.count()}):")
repo.findAll().forEach { task ->
println(" [${task.status}] ${task.title} (${task.priority})")
}
println("\nTODO tasks:")
repo.findByStatus(TaskStatus.TODO).forEach { task ->
println(" - ${task.title}")
}
println("\nFirst task as JSON:")
println(json.encodeToString(repo.findAll().first()))
println("\n=== Demo complete ===")
}

The test — JUnit Platform, kotlin-test, backtick names

Section titled “The test — JUnit Platform, kotlin-test, backtick names”

The app module’s test exercises the repository from common. Kotlin lets you write test names as backtick-quoted sentences, which read far better in test reports than shouldCreateAndRetrieveATask. The update test shows the copy-on-write pattern: it.copy(title = "Updated", status = TaskStatus.DONE).

app/src/test/kotlin/com/example/app/TaskRepositoryTest.kt
package com.example.app
import com.example.common.*
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNotNull
import kotlin.test.assertNull
import kotlin.test.assertTrue
class TaskRepositoryTest {
@Test
fun `should create and retrieve a task`() {
val repo = TaskRepository()
val task = Task(title = "Test task", description = "A test")
val created = repo.create(task)
val found = repo.findById(created.id)
assertNotNull(found)
assertEquals("Test task", found.title)
assertEquals(TaskStatus.TODO, found.status)
}
@Test
fun `should update a task`() {
val repo = TaskRepository()
val task = repo.create(Task(title = "Original"))
val updated = repo.update(task.id) { it.copy(title = "Updated", status = TaskStatus.DONE) }
assertNotNull(updated)
assertEquals("Updated", updated.title)
assertEquals(TaskStatus.DONE, updated.status)
}
@Test
fun `should filter by status`() {
val repo = TaskRepository()
repo.create(Task(title = "Todo 1", status = TaskStatus.TODO))
repo.create(Task(title = "Todo 2", status = TaskStatus.TODO))
repo.create(Task(title = "Done 1", status = TaskStatus.DONE))
assertEquals(2, repo.findByStatus(TaskStatus.TODO).size)
assertEquals(1, repo.findByStatus(TaskStatus.DONE).size)
}
}
  • settings.gradle.kts declares which modules exist (common, app).
  • The root build.gradle.kts uses subprojects { } to apply shared config to all of them.
  • app depends on common through implementation(project(":common")) — a typed, in-build dependency with no publishing step.
  • The version catalog gives every module a single, type-safe place to read dependency versions.
  • Custom tasks register with tasks.register("name") { doLast { } } and can be wired into the build graph with dependsOn(...).
  1. Build every module:

    Terminal window
    ./gradlew build
  2. Run the app (the leading :app: targets that subproject):

    Terminal window
    ./gradlew :app:run
  3. Run all tests, or just the app’s:

    Terminal window
    ./gradlew test
    ./gradlew :app:test
  4. Inspect the build — dependency tree, custom tasks, and project structure:

    Terminal window
    ./gradlew :app:dependencies --configuration runtimeClasspath
    ./gradlew :app:showDeps
    ./gradlew :app:generateBuildInfo
    ./gradlew tasks --all
    ./gradlew projects