Multi-module Gradle projects are the default for any non-trivial Java codebase. Getting the structure right saves years of build-config pain. This article covers a layout that scales, the conventions that keep it sane, and the mistakes that cost teams weeks.
Why multi-module
Single-module projects hit limits fast:
- Build time grows with codebase (everything recompiles)
- No physical enforcement of architectural boundaries
- Hard to publish parts as libraries
- Test isolation blurs
Multi-module gives:
- Parallel compilation per module
- Physical dependency rules (compile errors if you import wrong)
- Partial rebuilds (only changed modules + dependents)
- Per-module tests, per-module versioning
Cost: more setup, more configuration files, some added complexity.
A typical layout
my-service/
├── settings.gradle.kts
├── build.gradle.kts
├── buildSrc/ (or gradle/build-logic/)
│ └── src/main/kotlin/
│ └── build-conventions.gradle.kts
├── api/ (public types, DTOs, interfaces)
├── domain/ (business logic, domain models)
├── persistence/ (JPA entities, repositories)
├── web/ (REST controllers, DTO mapping)
├── messaging/ (Kafka consumers, publishers)
├── app/ (main application class, wiring)
└── integration-test/ (end-to-end tests)Dependency rules:
apidepends on nothingdomaindepends onapipersistence,web,messagingdepend ondomainappdepends on everythingintegration-testdepends onapp
Arrow flows up. domain never imports persistence. api never imports anything project-internal. Enforced by Gradle’s dependency graph; violations fail compilation.
settings.gradle.kts
rootProject.name = "my-service"
include(
"api",
"domain",
"persistence",
"web",
"messaging",
"app",
"integration-test"
)Clean. One place listing all modules.
Shared build logic with conventions
buildSrc/src/main/kotlin/java-conventions.gradle.kts:
plugins {
id("java-library")
id("jacoco")
}
java {
toolchain {
languageVersion.set(JavaLanguageVersion.of(21))
}
}
tasks.withType<Test> {
useJUnitPlatform()
testLogging {
events("failed", "skipped")
exceptionFormat = TestExceptionFormat.FULL
}
}
dependencies {
testImplementation("org.junit.jupiter:junit-jupiter")
testImplementation("org.assertj:assertj-core")
testImplementation("org.mockito:mockito-junit-jupiter")
}Each module’s build.gradle.kts applies the conventions:
plugins {
id("java-conventions")
}
dependencies {
api(project(":api"))
implementation("org.springframework.boot:spring-boot-starter")
}Conventions make per-module builds short. One place to change Java version, one place to add default test dependencies.
The api vs implementation distinction
api("org.slf4j:slf4j-api") — consumers see this too. Use for types that appear in public method signatures.
implementation("ch.qos.logback:logback-classic") — only this module sees it. Use for internal dependencies.
Getting this right:
- Reduces compile-time classpath for dependents
- Speeds builds substantially
- Prevents leaky dependencies
Default to implementation. Escalate to api only when a type is returned from or passed to a public method.
Version catalogs
Central version management in gradle/libs.versions.toml:
[versions]
spring-boot = "3.4.0"
junit = "5.10.2"
jackson = "2.17.0"
[libraries]
spring-boot-starter-web = { module = "org.springframework.boot:spring-boot-starter-web", version.ref = "spring-boot" }
spring-boot-starter-test = { module = "org.springframework.boot:spring-boot-starter-test", version.ref = "spring-boot" }
jackson-databind = { module = "com.fasterxml.jackson.core:jackson-databind", version.ref = "jackson" }
[bundles]
jackson = ["jackson-databind", "jackson-datatype-jsr310"]Usage:
dependencies {
implementation(libs.spring.boot.starter.web)
testImplementation(libs.spring.boot.starter.test)
implementation(libs.bundles.jackson)
}One place to bump versions. IDE autocomplete for dependencies. Huge quality-of-life improvement over hardcoded strings.
Spring Boot in a multi-module layout
Only the app module applies org.springframework.boot. Others are plain java-library modules.
// app/build.gradle.kts
plugins {
id("java-conventions")
id("org.springframework.boot")
id("io.spring.dependency-management")
}
dependencies {
implementation(project(":domain"))
implementation(project(":persistence"))
implementation(project(":web"))
implementation(project(":messaging"))
implementation("org.springframework.boot:spring-boot-starter-web")
}The Boot plugin creates the executable JAR from this module. Domain modules stay library-shaped.
Testing across modules
Unit tests live in each module. Integration tests can either:
- Live in a dedicated
integration-testmodule (cleaner isolation) - Live in the
appmodule undersrc/integrationTest/with source set config (less setup)
For teams starting out: keep integration tests in app. Extract to dedicated module once they grow.
Use Testcontainers for real DB/Kafka instances. Same cluster-of-containers across all integration tests; reuse containers to speed up.
Common mistakes
Too many modules. 30 modules for a service that could live in 7. Cross-module refactoring becomes painful. Start simple; split only when boundaries stabilize.
Circular dependencies. Module A depends on B; B depends on A. Almost always a sign that one should be two or they should be one. Fix at design time.
api used everywhere. Every dependency declared as api. Compile classpath becomes huge; builds slow. Review and demote where appropriate.
Build logic duplicated. Each module’s build file has 50 lines of boilerplate. Extract to conventions in buildSrc.
Tests in random places. Unit tests in app module. Mock-heavy tests in persistence. Consolidate — tests live where the code they test lives.
Performance
For large multi-module projects:
- Configuration cache —
org.gradle.configuration-cache=true. Massive speedup on re-runs. - Parallel execution —
org.gradle.parallel=true. Default in modern Gradle. - Caching — Gradle’s build cache catches unchanged module outputs. Huge on CI.
- Toolchain auto-provisioning — Gradle downloads and manages JDK per project.
Properly configured multi-module Gradle on 100k LOC rebuilds in seconds after a local change.
Closing note
Gradle multi-module setup rewards investment. The first week of setup and conventions pays off every day afterward — faster builds, enforced boundaries, easy version management. The traps are real but well-known: too many modules, circular deps, duplicated build logic. Avoid those and the structure scales comfortably to hundreds of thousands of lines of code without the build becoming an obstacle.