
As modern software systems grow more complex and interconnected, the need for a scalable and maintainable project structure becomes paramount. One of the most effective strategies to tackle this challenge is by adopting a Gradle multi-module project architecture. Whether you’re building a monolithic application or evolving into microservices, structuring your codebase into well-defined modules can significantly improve build performance, code reusability, and team collaboration.
This post provides a comprehensive, step-by-step guide to building and managing Gradle multi-module projects. From understanding core concepts to configuring builds, managing dependencies, and optimizing CI/CD pipelines — you’ll find everything you need to design a robust modular architecture for your Java or Kotlin project.
📑 Table of Contents
- 1. Why Use a Multi-Module Structure?
- 2. What Is a Gradle Multi-Module Project?
- 3. Basic Structure of a Multi-Module Gradle Project
- 4. Practical Example: Creating a Multi-Module Project
- 5. Managing Dependencies Between Modules
- 6. Sharing Common Dependencies and Build Settings
- 7. Build Optimization Strategies
- 8. Testing Strategies in Multi-Module Projects
- 9. Integrating Multi-Modules with CI/CD
- 10. Common Issues and How to Fix Them
- 11. Conclusion: Leveling Up with Multi-Module Projects
1. Why Use a Multi-Module Structure?
In a growing codebase, organizing all source files into a single module quickly becomes inefficient and difficult to manage. As your application evolves, so does the complexity of its build process, testing strategy, and team collaboration workflow. This is where the multi-module approach truly shines.
Gradle’s support for multi-module projects allows developers to separate functionality into dedicated components, known as modules. Each module can be built, tested, and deployed independently, while still interacting with others through clearly defined dependencies. This architecture promotes encapsulation, reduces build times, and enables parallel development by different teams.
Imagine having a common
module for shared utilities, a service
module for business logic, and a web
module for exposing APIs. If a change occurs only in the service logic, there’s no need to rebuild the entire application — only the affected module is rebuilt, saving both time and computing resources.
In this guide, you’ll explore how to design and configure a Gradle multi-module project from the ground up. Whether you’re a beginner or an intermediate developer, this post aims to provide clear, structured insights that are immediately applicable to real-world Java or Kotlin projects.
2. What Is a Gradle Multi-Module Project?
A Gradle multi-module project is a project architecture where a single root project manages multiple subprojects (or modules), each representing a specific feature, domain, or responsibility. Instead of organizing your entire codebase within a monolithic structure, you divide it into smaller, self-contained units that can be built and tested independently.
Each module can include its own source code, dependencies, and build logic while still participating in the larger application ecosystem through explicit dependencies. Gradle handles this relationship transparently, allowing you to share code efficiently and optimize build performance.
2.1 Common Module Structure
Here’s a typical folder structure of a Gradle multi-module project:
my-multi-project/
├── build.gradle
├── settings.gradle
├── common/
│ └── build.gradle
├── service/
│ └── build.gradle
└── web/
└── build.gradle
The settings.gradle
file defines the modules that should be included in the build. Each module contains its own build.gradle
file and source directories. The root build.gradle
file is responsible for global configuration like dependency repositories, plugin versions, and shared settings.
2.2 Single vs Multi-Module Comparison
Aspect | Single Module | Multi-Module |
---|---|---|
Structure | All code in one module | Code divided by responsibility |
Build Efficiency | Slower for large codebases | Faster with incremental builds |
Reusability | Hard to share code | Modules can be reused independently |
Testing | Tests are tightly coupled | Isolated and modularized tests |
In short, while a single module structure might be easier to start with, it quickly becomes unmanageable as your application grows. Multi-module architecture, on the other hand, brings modularity, faster builds, and maintainability — essential for modern software development practices.
Now that you understand the basics, let’s move on to configuring the actual folder structure and Gradle files required to build a multi-module project.
3. Basic Structure of a Multi-Module Gradle Project
Before diving into configuration and code, it’s essential to understand how a typical Gradle multi-module project is structured. The directory layout, as well as the relationship between the root and subprojects, plays a critical role in how Gradle builds and resolves dependencies across modules.
3.1 Directory Layout
Here’s a simplified yet realistic folder structure for a multi-module Gradle project:
my-multi-project/
├── build.gradle
├── settings.gradle
├── common/
│ ├── build.gradle
│ └── src/main/java/...
├── service/
│ ├── build.gradle
│ └── src/main/java/...
└── web/
├── build.gradle
└── src/main/java/...
The root directory contains global configuration files such as settings.gradle
and a root-level build.gradle
. Each subdirectory (e.g., common
, service
, web
) is an individual Gradle module with its own build file and source folders.
3.2 settings.gradle
The settings.gradle
file defines which modules should be included in the overall project. This is where Gradle recognizes submodules and makes them part of the build lifecycle.
rootProject.name = 'my-multi-project'
include 'common', 'service', 'web'
Alternatively, if you’re using Kotlin DSL, the file will be named settings.gradle.kts
and look like this:
rootProject.name = "my-multi-project"
include("common", "service", "web")
3.3 Root build.gradle
The root build.gradle
is typically used to define configuration that applies to all modules. This includes the project group, version, repositories, and optionally, dependency or plugin configurations.
buildscript {
repositories {
mavenCentral()
}
}
allprojects {
group = 'com.example'
version = '1.0.0'
repositories {
mavenCentral()
}
}
3.4 Kotlin DSL Considerations
If you’re working with Kotlin DSL (.gradle.kts
), the syntax is slightly different but offers benefits like type safety and better IDE support. Here’s how the root build file looks in Kotlin DSL:
plugins {
kotlin("jvm") version "1.8.21" apply false
}
allprojects {
group = "com.example"
version = "1.0.0"
repositories {
mavenCentral()
}
}
Regardless of whether you use Groovy or Kotlin DSL, keeping your root build file clean and modular helps in scaling and maintaining the project long term.
In the next section, we’ll create a working multi-module project step by step, including writing sample build.gradle
files for each module and building the project with Gradle.
4. Practical Example: Creating a Multi-Module Project
Now that we’ve covered the foundational concepts and structure of a Gradle multi-module project, let’s put theory into practice. In this section, we’ll walk through the process of creating a simple multi-module project with three modules:
- common — shared utilities and data classes
- service — core business logic
- web — REST API layer or web interface
This structure is widely used in real-world enterprise applications, and it allows for clear separation of concerns, faster builds, and independent module development.
4.1 Create the project directory
First, create the root directory and subdirectories for each module:
mkdir my-multi-project
cd my-multi-project
mkdir common service web
4.2 Define modules in settings.gradle
In the root directory, create a file named settings.gradle
and include the module paths:
rootProject.name = 'my-multi-project'
include 'common', 'service', 'web'
4.3 Set up the root build.gradle
Next, define global settings and repositories in the root build.gradle
:
buildscript {
repositories {
mavenCentral()
}
}
allprojects {
group = 'com.example'
version = '1.0.0'
repositories {
mavenCentral()
}
}
4.4 Configure module-specific build.gradle files
Each module needs its own build.gradle
file. Here’s how to define them:
common/build.gradle
apply plugin: 'java'
dependencies {
// Add any common dependencies here
}
service/build.gradle
apply plugin: 'java'
dependencies {
implementation project(':common')
}
web/build.gradle
apply plugin: 'java'
dependencies {
implementation project(':service')
implementation project(':common')
}
By defining dependencies explicitly using project()
, Gradle ensures each module is built in the correct order and that only the necessary modules are compiled together.
4.5 Create source directories
Each module should follow the standard Java directory layout. You can create them as follows:
mkdir -p common/src/main/java
mkdir -p service/src/main/java
mkdir -p web/src/main/java
You can now begin writing your application logic within the appropriate module folders.
4.6 Build and verify
To build the entire project, run the following command from the root directory:
./gradlew clean build
If everything is configured correctly, Gradle will compile each module, resolve their dependencies, and generate build artifacts for each.
This simple example lays the foundation for more advanced configurations, such as integrating Spring Boot, applying Kotlin DSL, or publishing modules independently. In the next section, we’ll explore how to manage dependencies across modules in a more scalable way.
5. Managing Dependencies Between Modules
One of the most powerful features of a multi-module Gradle project is the ability to explicitly define how modules depend on each other. This promotes clear architecture, better separation of concerns, and more efficient builds. However, managing these dependencies correctly is key to avoiding pitfalls like circular references or bloated classpaths.
5.1 Using project()
to Reference Modules
In Gradle, you can reference another module in the same project using the project()
method inside a module’s dependencies
block. For example, if the service
module needs to use classes from the common
module:
dependencies {
implementation project(':common')
}
Similarly, if the web
module depends on both service
and common
, its build file would include:
dependencies {
implementation project(':service')
implementation project(':common')
}
This ensures Gradle builds modules in the correct order and establishes proper compile-time visibility.
5.2 API vs. Implementation: What’s the Difference?
Gradle’s api
and implementation
keywords serve different purposes:
Attribute | implementation | api |
---|---|---|
Visibility | Internal to the current module | Exposed to dependent modules |
Performance | Faster due to limited exposure | Slightly slower; broader exposure |
Recommended Usage | Default for most cases | When re-exporting libraries |
Use api
only when another module needs to access the transitive dependencies of a module. Otherwise, stick with implementation
to keep your dependency graph lean and build performance optimal.
5.3 Avoiding Circular Dependencies
A common mistake in multi-module setups is creating circular dependencies — for example, common → service → web → common
. Gradle will throw an error in such cases because it cannot resolve an endless chain of dependencies.
To prevent circular dependencies:
- Ensure one-way dependency flow (e.g.,
common → service → web
) - Use interfaces or abstraction layers to decouple modules
- Consider extracting shared logic into a separate
core
orapi
module
5.4 Inspecting the Dependency Tree
To visualize dependencies and identify potential issues, use Gradle’s dependencies
task:
./gradlew :web:dependencies
This will show a tree of all direct and transitive dependencies for the specified module, helping you detect version conflicts or redundant inclusions.
By managing dependencies thoughtfully and structuring modules with clear responsibilities, your project becomes easier to scale, test, and maintain — especially as it grows in size and complexity.
6. Sharing Common Dependencies and Build Settings
In large-scale multi-module projects, it’s common for multiple modules to share the same dependencies, compiler options, plugin versions, or testing frameworks. Duplicating these configurations across modules not only leads to clutter but also increases the risk of inconsistencies. Gradle offers several mechanisms to centralize and share configuration across modules.
6.1 Using subprojects
for Shared Configuration
The subprojects
block in the root build.gradle
file allows you to apply common settings to all modules automatically. This includes plugins, Java versions, and commonly used dependencies.
subprojects {
apply plugin: 'java'
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
dependencies {
implementation 'org.projectlombok:lombok:1.18.30'
testImplementation 'org.junit.jupiter:junit-jupiter:5.10.0'
}
test {
useJUnitPlatform()
}
}
This ensures that all submodules use the same Java version, unit testing framework, and shared libraries without requiring manual repetition in every build.gradle
file.
6.2 Defining Constants in buildSrc
For more maintainability, you can create a buildSrc
directory in your project. It works like an internal Gradle plugin and allows you to define constants for dependency versions or plugin coordinates.
Create the file buildSrc/src/main/java/Dependencies.java
like so:
public class Dependencies {
public static final String JUNIT = "org.junit.jupiter:junit-jupiter:5.10.0";
public static final String LOMBOK = "org.projectlombok:lombok:1.18.30";
}
You can then use these constants in each module’s build script:
dependencies {
implementation Dependencies.LOMBOK
testImplementation Dependencies.JUNIT
}
This approach ensures that all modules use the same versions and allows for quick updates from a single location.
6.3 Using Gradle Version Catalogs
Introduced in Gradle 7.0+, Version Catalogs provide a declarative way to manage dependency versions using a libs.versions.toml
file. This method separates version definitions from logic and works well with Kotlin DSL projects.
Create a gradle/libs.versions.toml
file:
[versions]
junit = "5.10.0"
lombok = "1.18.30"
[libraries]
junit = { module = "org.junit.jupiter:junit-jupiter", version.ref = "junit" }
lombok = { module = "org.projectlombok:lombok", version.ref = "lombok" }
Then, in your module’s build file:
dependencies {
implementation(libs.lombok)
testImplementation(libs.junit)
}
This is a modern and scalable way to manage dependencies across multiple modules and is fully supported by IntelliJ and Kotlin DSL.
6.4 Extracting Common Build Logic
If your project contains complex logic shared across multiple modules, you can extract that logic into separate Gradle files and include them using apply from
:
Example: create a file gradle/common.gradle
with shared configuration:
// common.gradle
apply plugin: 'java'
dependencies {
implementation 'org.slf4j:slf4j-api:2.0.9'
}
Then apply it in your module’s build.gradle
:
apply from: "$rootDir/gradle/common.gradle"
Sharing configuration is not just about avoiding duplication — it’s about ensuring consistency across modules, reducing maintenance overhead, and enabling better scalability as your project grows.
7. Build Optimization Strategies
As your multi-module project grows in size and complexity, build performance becomes a critical factor. Slow builds not only hamper developer productivity but also slow down your CI/CD pipelines. Fortunately, Gradle provides several powerful features to optimize build times, especially in modular architectures.
7.1 Enable Parallel Builds
Gradle can execute independent modules in parallel if they don’t depend on each other. This can significantly reduce total build time for large projects.
To enable parallel execution, add the following to your gradle.properties
file:
org.gradle.parallel=true
Or use the command line:
./gradlew build --parallel
Note: Make sure your modules are properly isolated and don’t share mutable state (e.g., writing to shared files).
7.2 Use the Configuration Cache
Gradle’s Configuration Cache dramatically improves build times by reusing the result of the configuration phase between builds.
Run builds with this option enabled:
./gradlew build --configuration-cache
To check compatibility and debug issues, use:
./gradlew help --configuration-cache
Make sure all custom tasks and plugins are compatible with the configuration cache for it to be effective.
7.3 Leverage the Build Cache
The Gradle Build Cache allows outputs of tasks to be reused across builds, both locally and remotely. This is extremely beneficial in CI environments where many builds share similar inputs.
Enable it in gradle.properties
:
org.gradle.caching=true
Remote build cache servers like Gradle Enterprise or custom S3-based setups can provide even faster results across developer machines and CI jobs.
7.4 Optimize Dependency Resolution
Gradle checks for dependency updates on every build unless you configure caching policies. You can reduce network latency by adjusting caching strategies for dynamic and changing modules.
Example in settings.gradle
or build.gradle
:
configurations.all {
resolutionStrategy {
cacheChangingModulesFor 0, 'seconds'
cacheDynamicVersionsFor 10, 'minutes'
}
}
This ensures Gradle doesn’t re-fetch dependencies unnecessarily while still allowing for updates within a reasonable window.
7.5 Limit Unnecessary Tasks
If you’re only working on a specific module, there’s no need to build the entire project. You can target builds more precisely:
./gradlew :service:build
This minimizes computation and keeps your feedback loop fast.
7.6 Parallelize Tests
JUnit tests can be a major bottleneck in large projects. You can configure Gradle to run them in parallel using multiple forks:
test {
maxParallelForks = Runtime.runtime.availableProcessors().intdiv(2) ?: 1
}
This helps reduce overall test execution time, especially for large suites.
By combining these strategies — parallel builds, configuration and build caching, targeted tasks, and dependency tuning — you can significantly improve your build performance and scale your project more effectively.
8. Testing Strategies in Multi-Module Projects
Testing is a critical part of any development workflow, and in a multi-module architecture, it becomes even more important to design your test structure thoughtfully. Each module should be independently testable and should only test its own responsibilities. This modular approach enables faster feedback cycles, easier debugging, and better test maintenance.
8.1 Module-Specific Test Execution
One of the key benefits of multi-module projects is the ability to test modules individually. You don’t need to run the entire test suite when working on a single module. For example, to test only the service
module:
./gradlew :service:test
This saves time and isolates failures to the module where the changes were made.
8.2 Unit Tests vs Integration Tests
It’s important to separate unit tests (fast, isolated) from integration tests (slower, broader). Here’s a quick comparison:
Aspect | Unit Test | Integration Test |
---|---|---|
Scope | Single class or method | Multiple components/modules |
Dependencies | Mocked or isolated | Real databases, services, etc. |
Speed | Very fast | Slower |
To keep things organized, you can define a separate source set for integration tests within each module.
8.3 Adding a Separate Source Set for Integration Tests
In your module’s build.gradle
, define an additional source set:
sourceSets {
integrationTest {
java {
compileClasspath += main.output + test.output
runtimeClasspath += main.output + test.output
srcDir file('src/integrationTest/java')
}
resources.srcDir file('src/integrationTest/resources')
}
}
configurations {
integrationTestImplementation.extendsFrom testImplementation
integrationTestRuntimeOnly.extendsFrom testRuntimeOnly
}
task integrationTest(type: Test) {
description = 'Runs integration tests.'
group = 'verification'
testClassesDirs = sourceSets.integrationTest.output.classesDirs
classpath = sourceSets.integrationTest.runtimeClasspath
shouldRunAfter test
}
Run integration tests separately using:
./gradlew integrationTest
8.4 Dedicated Integration Test Module
Another approach is to isolate all integration tests in a separate module, such as integration-test
. This helps maintain strict separation from production code and enables CI pipelines to run integration tests independently of unit tests.
8.5 Aggregating Code Coverage Reports
When using tools like JaCoCo, each module produces its own coverage report. To get a full view of test coverage across all modules, you can aggregate these reports in the root project using a custom task:
task jacocoRootReport(type: JacocoReport) {
dependsOn = subprojects.test
subprojects.each { subproject ->
executionData fileTree(dir: "${subproject.buildDir}/jacoco", include: "*.exec")
sourceDirectories.from files("${subproject.projectDir}/src/main/java")
classDirectories.from files("${subproject.buildDir}/classes/java/main")
}
reports {
xml.required = true
html.required = true
}
}
This provides a unified coverage report and helps identify which modules or classes need more test coverage.
With proper test separation and clear test responsibilities across modules, you can maintain high confidence in your codebase while keeping your test runs fast, meaningful, and easy to manage.
9. Integrating Multi-Modules with CI/CD
Modern development relies heavily on Continuous Integration and Continuous Deployment (CI/CD). When working with a multi-module Gradle project, your CI/CD pipeline must be designed to accommodate multiple, independently built modules — especially when only parts of the system change. Efficient integration helps speed up feedback loops, avoid unnecessary builds, and streamline deployments.
9.1 Key Considerations for Multi-Module CI/CD
Before implementing CI/CD for a multi-module project, you should consider the following:
- Detect which modules have changed in a commit or merge request
- Run tests and builds only for affected modules
- Generate per-module reports and logs
- Optionally, publish only changed modules as artifacts
9.2 GitLab CI Example: Module-Specific Builds
GitLab CI allows you to trigger jobs only when specific files or directories have changed. This is extremely useful in multi-module environments.
Here’s how to define a job that builds only the common
module if its code changes:
build-common:
stage: build
script:
- ./gradlew :common:build
rules:
- changes:
- common/**/*
You can create similar jobs for other modules like web
or service
, and chain them conditionally using needs
.
9.3 GitHub Actions Example: Detecting Path Changes
In GitHub Actions, you can use the paths
field to trigger workflows only when specific modules are modified:
on:
push:
paths:
- 'web/**'
- 'common/**'
jobs:
build-web:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Build Web Module
run: ./gradlew :web:build
This keeps your CI workflows lean and focused on what actually changed.
9.4 Generating Combined Test Coverage Reports
To collect test coverage across all modules and generate a single report, define a task like jacocoRootReport
in your root project. This was described earlier in the testing section but is often integrated into the CI pipeline like so:
./gradlew jacocoRootReport
The generated HTML and XML reports can then be archived as build artifacts or uploaded to code quality tools like SonarQube.
9.5 Publishing Modules Separately
If each module is a reusable library or service, you may want to publish them individually. You can use the Gradle publishing
plugin to deploy modules to Maven repositories:
publishing {
publications {
mavenJava(MavenPublication) {
from components.java
}
}
repositories {
maven {
url = uri("https://your.repository.url/releases")
}
}
}
In your CI pipeline, you can publish only the changed modules after successful tests and builds.
With these techniques, you can scale your build and deployment pipelines to support hundreds of modules efficiently — without wasting time on full builds or monolithic workflows.
10. Common Issues and How to Fix Them
While Gradle multi-module projects offer great flexibility and structure, they can also introduce complexity that leads to common pitfalls — especially for teams new to modular architectures. Here are the most frequently encountered problems and how to resolve them.
10.1 Module Not Recognized
Symptom: A module does not appear in the project or fails during build.
Solution:
- Ensure the module is included in
settings.gradle
viainclude
- Confirm that the module directory contains a valid
build.gradle
orbuild.gradle.kts
- Run
./gradlew clean build
and re-sync your IDE (e.g., IntelliJ)
10.2 Dependency Conflicts
Symptom: Compilation or runtime errors due to mismatched versions of libraries.
Solution:
- Use
./gradlew dependencies
to inspect the dependency tree - Resolve conflicts with
resolutionStrategy
inbuild.gradle
:
configurations.all {
resolutionStrategy {
force 'com.google.guava:guava:32.1.2-jre'
}
}
10.3 Circular Dependencies
Symptom: Build fails with a circular dependency error.
Solution:
- Ensure a one-way dependency flow (e.g.,
common → service → web
) - Use abstractions or interfaces to decouple components
- Refactor shared logic into a new base module if necessary
10.4 Test Failures due to Missing Classes
Symptom: NoClassDefFoundError
or other classpath-related test errors.
Solution:
- Check that the required dependencies are present under
testImplementation
- Ensure proper source set configuration if using custom test folders
10.5 IntelliJ Gradle Sync Issues
Symptom: IntelliJ fails to resolve modules or shows broken imports.
Solution:
- Invalidate caches:
File → Invalidate Caches / Restart
- Re-import the Gradle project from the root directory
- Ensure Gradle wrapper is used instead of system Gradle installation
Understanding these issues and their resolutions will help you build confidence as your project scales. The key is consistency in structure, discipline in dependency management, and clear documentation for team members.
11. Conclusion: Leveling Up with Multi-Module Projects
A well-structured Gradle multi-module project transforms the way teams build, test, and scale software. By dividing responsibilities across clearly defined modules, you unlock benefits such as faster builds, improved reusability, and more effective collaboration among developers.
Throughout this guide, you’ve learned how to:
- Understand the purpose and structure of multi-module projects
- Set up and configure modules with real-world examples
- Manage dependencies clearly and safely
- Share common build logic and dependency versions
- Optimize builds and testing for performance and maintainability
- Integrate modules into modern CI/CD workflows
Whether you’re building a large enterprise platform or a modular microservices backend, mastering Gradle’s multi-module capabilities gives you the tools to scale with confidence.
Good architecture isn’t just about clean code — it’s about building systems that evolve gracefully over time. And multi-module design is a key part of that evolution.
Now it’s your turn — start modularizing your project, experiment with the techniques from this guide, and take your software architecture to the next level.