How to Structure a Gradle Multi-Module Project

How to Structure a Gradle Multi-Module Project

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?

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 or api 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 via include
  • Confirm that the module directory contains a valid build.gradle or build.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 in build.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.

댓글 남기기

Table of Contents

Table of Contents