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.
What you’ll build
Section titled “What you’ll build”- 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 oncommonviaproject(":common"). - A version catalog (
gradle/libs.versions.toml) for type-safe, centralized versions. - Two custom Gradle tasks wired into the build.
The worked solution
Section titled “The worked solution”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.
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.
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.
[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" }gradle.properties — build-wide flags
Section titled “gradle.properties — build-wide flags”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.
org.gradle.jvmargs=-Xmx2g -XX:+UseParallelGCorg.gradle.parallel=trueorg.gradle.caching=truekotlin.code.style=officialcommon/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.
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.
package com.example.common
import kotlinx.serialization.Serializableimport java.util.UUID
@Serializabledata class Task( val id: String = UUID.randomUUID().toString(), val title: String, val description: String = "", val status: TaskStatus = TaskStatus.TODO, val priority: Priority = Priority.MEDIUM)
@Serializableenum class TaskStatus { TODO, IN_PROGRESS, DONE}
@Serializableenum 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.
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.
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 ontasks.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 filetasks.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 generateBuildInfotasks.jar { dependsOn("generateBuildInfo")}app/App.kt — consuming the library
Section titled “app/App.kt — consuming the library”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.
package com.example.app
import com.example.common.*import kotlinx.serialization.encodeToStringimport 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).
package com.example.app
import com.example.common.*import kotlin.test.Testimport kotlin.test.assertEqualsimport kotlin.test.assertNotNullimport kotlin.test.assertNullimport 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) }}How the modules fit together
Section titled “How the modules fit together”settings.gradle.ktsdeclares which modules exist (common,app).- The root
build.gradle.ktsusessubprojects { }to apply shared config to all of them. appdepends oncommonthroughimplementation(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 withdependsOn(...).
Run it
Section titled “Run it”-
Build every module:
Terminal window ./gradlew build -
Run the app (the leading
:app:targets that subproject):Terminal window ./gradlew :app:run -
Run all tests, or just the app’s:
Terminal window ./gradlew test./gradlew :app:test -
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