Gradle’s multi-project builds are the secret sauce for taming Java monorepos, allowing you to manage a single, massive codebase as a collection of smaller, interconnected projects.

Consider this a tiny, but illustrative, monorepo structure:

my-monorepo/
├── build.gradle       (root project build file)
├── settings.gradle    (defines included projects)
├── api/
│   ├── build.gradle   (api project build file)
│   └── src/main/java/...
├── impl/
│   ├── build.gradle   (impl project build file)
│   └── src/main/java/...
└── shared/
    ├── build.gradle   (shared project build file)
    └── src/main/java/...

Here’s how settings.gradle brings these pieces together:

// settings.gradle
rootProject.name = 'my-monorepo'
include ':api', ':impl', ':shared'

And the root build.gradle sets up common configurations:

// build.gradle (root)
subprojects {
    apply plugin: 'java'
    group 'com.example'
    version '1.0-SNAPSHOT'

    repositories {
        mavenCentral()
    }

    // Common Java settings
    java {
        sourceCompatibility = JavaVersion.VERSION_17
        targetCompatibility = JavaVersion.VERSION_17
    }

    // Common testing setup
    tasks.withType(Test) {
        useJUnitPlatform()
    }
}

Now, each subproject can define its own dependencies. For instance, impl needs api and shared:

// impl/build.gradle
dependencies {
    implementation project(':api')
    implementation project(':shared')
}

When you run a Gradle task, like ./gradlew build, Gradle understands these inter-project dependencies. It builds shared and api first, then impl, ensuring that all necessary artifacts are available. You can also target specific subprojects: ./gradlew :api:build or ./gradlew :impl:clean.

The real power emerges with tasks like ./gradlew dependencies which visualizes the entire dependency graph across your monorepo, or ./gradlew ide (for IntelliJ IDEA) which generates project files reflecting this structure. This allows your IDE to correctly resolve imports between modules, providing a seamless development experience.

The java-library plugin is often preferred for modules that will be consumed by other modules, as it enforces API boundaries and makes dependency management more robust.

// api/build.gradle
apply plugin: 'java-library'

java {
    registerFeature('main') {
        usingSourceSet(sourceSets.main)
    }
}

dependencies {
    // If api itself depends on shared
    implementation project(':shared')
}

The ability to apply common configurations across all subprojects via the subprojects block in the root build.gradle is crucial for maintaining consistency in build settings, Java versions, and dependency repositories. This prevents the "works on my machine" syndrome and ensures that every part of your monorepo adheres to the same standards.

When you create a new module, say utils, you simply add include ':utils' to settings.gradle and create its utils/build.gradle file. If utils needs to be used by other modules, you’d typically apply the java-library plugin to utils/build.gradle.

The Gradle wrapper (gradlew) is indispensable here. It ensures everyone on the team uses the same Gradle version, eliminating environment-related build discrepancies.

This dependency management extends to publishing. If shared were to be published to a local Maven repository for other projects to consume (even within the same monorepo, though less common), Gradle handles the artifact generation and metadata correctly for each subproject.

The gradle.properties file can be used to define properties that are accessible across all projects, like version numbers or JVM arguments.

# gradle.properties
myProjectVersion=1.0.0
org.gradle.jvmargs=-Xmx2048m

And then accessed in build.gradle files:

// build.gradle (root)
subprojects {
    // ...
    version = myProjectVersion // Accessing from gradle.properties
    // ...
}

This structured approach to managing dependencies between modules, combined with centralized configuration and consistent tooling via the wrapper, is what makes Gradle multi-project builds so effective for large, complex codebases.

The default configuration for java-library projects registers a compileClasspath and runtimeClasspath for the main API, which is what downstream projects will depend on. This separation is key to enforcing modularity.

The next hurdle you’ll likely encounter is optimizing build times in a large monorepo, which often involves exploring Gradle’s build cache and incremental build capabilities more deeply.

Want structured learning?

Take the full Java course →