As projects grow, it’s common to split them into smaller, focused modules that are built, tested, and released together. Gradle supports this through multi-project builds, allowing you to organize related codebases under a single build while keeping each module logically isolated.

Multi-Project Layout

A multi-project build consists of a root project and one or more subprojects, all defined in a single settings.gradle(.kts) file. This structure supports modularization, parallel execution, and code reuse.

structuring builds 6

A typical multi-project structure looks like this:

my-project/
├── settings.gradle.kts     (1)
├── build.gradle.kts            (2)
├── app/                    (3)
│   └── build.gradle.kts        (4)
├── core/                   (3)
│   └── build.gradle.kts        (5)
└── util/                   (3)
    └── build.gradle.kts        (6)
my-project/
├── settings.gradle         (1)
├── build.gradle        (2)
├── app/                    (3)
│   └── build.gradle    (4)
├── core/                   (3)
│   └── build.gradle    (5)
└── util/                   (3)
    └── build.gradle    (6)
1 Declares subprojects
2 Root project build logic (optional)
3 Subproject
4 App module
5 Shared core logic
6 Utility code

Each subproject can define its own build logic, dependencies, and plugins.

In settings.gradle(.kts), you include subprojects by name using include(). The include() method takes project paths as arguments:

rootProject.name = "my-project"
include("app", "core", "util")
rootProject.name = 'my-project'
include('app', 'core', 'util')

By default, a project path corresponds to the relative physical location of the project directory. For example, the path services:api maps to the directory ./services/api, relative to the root project.

You can find more examples and detailed usage in the DSL reference for Settings.include(String…​).

Project Descriptors

To further describe the project architecture to Gradle, the settings file provides project descriptors.

You can modify these descriptors in the settings file at any time.

To access a descriptor, you can:

settings.gradle.kts
include("project-a")
println(rootProject.name)
println(project(":project-a").name)
settings.gradle
include('project-a')
println rootProject.name
println project(':project-a').name

Using this descriptor, you can change the name, project directory, and build file of a project:

settings.gradle.kts
rootProject.name = "main"
include("project-a")
project(":project-a").projectDir = file("custom/my-project-a")
project(":project-a").buildFileName = "project-a.gradle.kts"
settings.gradle
rootProject.name = 'main'
include('project-a')
project(':project-a').projectDir = file('custom/my-project-a')
project(':project-a').buildFileName = 'project-a.gradle'

Consult the ProjectDescriptor class in the API documentation for more information.

Modifying a subproject path

Let’s take a hypothetical project with the following structure:

.
├── settings.gradle.kts
├── app/
│   ├── build.gradle.kts
│   └── src/
└── subs/               (1)
    ├── build.gradle.kts
    └── web             (1)
        └── my-web-module   (2)
.
├── settings.gradle
├── app/
│   ├── build.gradle
│   └── src/
└── subs/               (1)
    ├── build.gradle
    └── web             (1)
        └── my-web-module   (2)
1 Gradle may see this as a subproject
2 Actual intended subproject

If your settings.gradle(.kts) looks like this:

include(':subs:web:my-web-module')

Gradle sees a subproject with a logical project name of :subs:web:my-web-module and two, possibly unintentional, other subprojects logically named:

  1. :subs

  2. :subs:web

This can lead to phantom build directories, especially when using allprojects{} or subproject{}.

To avoid this, you can use:

include(':my-web-module')
project(':my-web-module').projectDir = "subs/web/my-web-module"

So that you only end up with a single subproject named :my-web-module.

So, while the physical project layout is the same, the logical results are different.

Naming recommendations

As your project grows, naming and consistency get increasingly more important.

To keep your builds maintainable, we recommend the following:

  1. Keep default project names for subprojects: It is possible to configure custom project names in the settings file. However, it’s an unnecessary extra effort for the developers to track which projects belong to what folders.

  2. Use lower case hyphenation for all project names: All letters are lowercase, and words are separated with a dash (-) character.

  3. Define the root project name in the settings file: The rootProject.name effectively assigns a name to the build, used in reports like Build Scans. If the root project name is not set, the name will be the container directory name, which can be unstable (i.e., you can check out your project in any directory). The name will be generated randomly if the root project name is not set and checked out to a file system’s root (e.g., / or C:\).

Declaring dependencies between Subprojects

What if one subproject depends on another? What if one subproject depends on the artifact produced by another?

structuring builds 7

This is a common use case in multi-project builds. Gradle supports this scenario with project dependencies.

Depending on another Project

Consider a multi-project build with the following layout:

.
├── api
│   ├── src
│   │   └──...
│   └── build.gradle.kts
├── services
│   └── person-service
│       ├── src
│       │   └──...
│       └── build.gradle.kts
├── shared
│   ├── src
│   │   └──...
│   └── build.gradle.kts
└── settings.gradle.kts
.
├── api
│   ├── src
│   │   └──...
│   └── build.gradle
├── services
│   └── person-service
│       ├── src
│       │   └──...
│       └── build.gradle
├── shared
│   ├── src
│   │   └──...
│   └── build.gradle
└── settings.gradle

In this example:

  1. person-service depends on both api and shared

  2. api depends on shared

You declare these relationships using the project path, which uses colons (:) to indicate nesting. For example:

  • :shared refers to the shared subproject

  • services:person-service refers to a nested subproject

settings.gradle.kts
rootProject.name = "dependencies-java"
include("api", "shared", "services:person-service")
shared/build.gradle.kts
plugins {
    id("java")
}

repositories {
    mavenCentral()
}

dependencies {
    testImplementation("junit:junit:4.13")
}
api/build.gradle.kts
plugins {
    id("java")
}

repositories {
    mavenCentral()
}

dependencies {
    testImplementation("junit:junit:4.13")
    implementation(project(":shared"))
}
services/person-service/build.gradle.kts
plugins {
    id("java")
}

repositories {
    mavenCentral()
}

dependencies {
    testImplementation("junit:junit:4.13")
    implementation(project(":shared"))
    implementation(project(":api"))
}
settings.gradle
rootProject.name = 'basic-dependencies'
include 'api', 'shared', 'services:person-service'
shared/build.gradle
plugins {
    id 'java'
}

repositories {
    mavenCentral()
}

dependencies {
    testImplementation "junit:junit:4.13"
}
api/build.gradle
plugins {
    id 'java'
}

repositories {
    mavenCentral()
}

dependencies {
    testImplementation "junit:junit:4.13"
    implementation project(':shared')
}
services/person-service/build.gradle
plugins {
    id 'java'
}

repositories {
    mavenCentral()
}

dependencies {
    testImplementation "junit:junit:4.13"
    implementation project(':shared')
    implementation project(':api')
}

For more details on project paths, consult the DSL documentation for Settings.include(String…​).

A project dependency affects both the build order and classpath:

  • The required project will be built first.

  • Its compiled classes and transitive dependencies are added to the consuming project’s classpath.

For example, running ./gradlew :api:compileJava will first build shared, then api.

Depending on Artifacts produced by another Project

Sometimes, you only need the output of a specific task from another project—not the entire project itself.

While you can create task-to-task dependencies between projects, Gradle discourages this because it creates tight coupling between tasks.

Instead, use outgoing artifacts to expose a task’s output and model it as a dependency. Gradle’s variant-aware dependency management allows one project to consume artifacts from another in a structured, on-demand way.