Real codebases have many modules; structuring them well keeps builds fast and dependencies honest.
A multi-project build has one settings.gradle.kts that includes several subprojects, each with its own build.gradle.kts in its folder:
rootProject.name = "shop"
include("core", "api", "app")Subprojects are addressed by path: :core, :services:billing for nested folders. Run a task in one project with ./gradlew :api:test; running ./gradlew test from the root runs it in every project that has it.
Projects depend on each other with implementation(project(":core")). Gradle then builds core first and uses its jar (with compile avoidance when only internals changed). Keep the graph acyclic.
Sharing configuration: the old subprojects { } / allprojects { } blocks in the root script are discouraged because they cross project boundaries and break with isolated projects and the configuration cache. The recommended way is a convention plugin: a small precompiled script plugin in buildSrc/src/main/kotlin/acme.java-conventions.gradle.kts (or an included build under build-logic/) that applies java, sets the toolchain, test settings and common dependencies. Each subproject then just writes plugins { id("acme.java-conventions") }.
A gradle.properties at the root holds shared properties such as version and JVM settings. ./gradlew projects prints the project tree. Composite builds (includeBuild("../lib")) let you substitute a published dependency with a local source checkout.
Example
// settings.gradle.kts
rootProject.name = "shop"
include("core", "api", "app")
// buildSrc/build.gradle.kts
// plugins { `kotlin-dsl` }
// repositories { mavenCentral() }
// buildSrc/src/main/kotlin/acme.java-conventions.gradle.kts
plugins { java }
repositories { mavenCentral() }
java { toolchain { languageVersion = JavaLanguageVersion.of(21) } }
dependencies {
testImplementation("org.junit.jupiter:junit-jupiter:5.11.4")
testRuntimeOnly("org.junit.platform:junit-platform-launcher")
}
tasks.test { useJUnitPlatform() }
// api/build.gradle.kts
plugins { id("acme.java-conventions"); `java-library` }
dependencies { api(project(":core")) }
// app/build.gradle.kts
plugins { id("acme.java-conventions"); application }
dependencies { implementation(project(":api")) }
application { mainClass = "com.acme.App" }
// $ ./gradlew :app:run $ ./gradlew projectsRemember
- include(...) in settings defines subprojects, addressed as :name
- implementation(project(":core")) depends on a sibling
- Share setup with convention plugins in buildSrc or build-logic, not subprojects { }
- ./gradlew :api:test runs a task in one project
Watch out for
- Configuring subprojects from the root with subprojects { }, which breaks modern features like the configuration cache.