Multi-Module Spring Boot Project with Gradle Version Catalog
Structure a multi-module Spring Boot project with Gradle — shared dependencies via version catalogs (libs.versions.toml), module boundaries, and build optimization.
As your Spring Boot project grows, a single module becomes unwieldy — slow builds, tangled dependencies, unclear boundaries. Splitting into modules forces clean separation and speeds up incremental builds.
This post covers practical multi-module setup with Gradle Kotlin DSL and version catalogs.
When to go multi-module
Good reasons:
- Build times exceed 2 minutes and you want incremental builds
- Teams work on different features independently
- You want to enforce boundaries (API module can’t access DB module internals)
- Shared libraries used across multiple services
Bad reasons:
- “It’s best practice” (for a 10-file project, it’s overhead)
- Premature separation before boundaries are clear
Project structure
my-service/
├── build.gradle.kts # root build
├── settings.gradle.kts # module declarations
├── gradle/
│ └── libs.versions.toml # version catalog
├── app/ # Spring Boot application
│ ├── build.gradle.kts
│ └── src/main/kotlin/
├── domain/ # Business logic, no framework deps
│ ├── build.gradle.kts
│ └── src/main/kotlin/
├── infrastructure/ # DB, messaging, external APIs
│ ├── build.gradle.kts
│ └── src/main/kotlin/
└── common/ # Shared utilities
├── build.gradle.kts
└── src/main/kotlin/
Dependency direction:
app → domain, infrastructure
infrastructure → domain, common
domain → common
common → (nothing)
Version catalog (libs.versions.toml)
The version catalog centralizes all dependency versions in one file:
gradle/libs.versions.toml:
[versions]
kotlin = "2.1.0"
spring-boot = "4.0.3"
spring-dependency-management = "1.1.7"
postgresql = "42.7.4"
flyway = "10.22.0"
jackson = "2.18.2"
kotest = "5.9.1"
mockk = "1.14.0"
testcontainers = "1.20.4"
[libraries]
spring-boot-starter-web = { module = "org.springframework.boot:spring-boot-starter-web" }
spring-boot-starter-data-jpa = { module = "org.springframework.boot:spring-boot-starter-data-jpa" }
spring-boot-starter-validation = { module = "org.springframework.boot:spring-boot-starter-validation" }
spring-boot-starter-actuator = { module = "org.springframework.boot:spring-boot-starter-actuator" }
spring-boot-starter-test = { module = "org.springframework.boot:spring-boot-starter-test" }
spring-boot-starter-security = { module = "org.springframework.boot:spring-boot-starter-security" }
postgresql = { module = "org.postgresql:postgresql", version.ref = "postgresql" }
flyway-core = { module = "org.flywaydb:flyway-core", version.ref = "flyway" }
flyway-postgresql = { module = "org.flywaydb:flyway-database-postgresql", version.ref = "flyway" }
jackson-kotlin = { module = "com.fasterxml.jackson.module:jackson-module-kotlin", version.ref = "jackson" }
kotlin-reflect = { module = "org.jetbrains.kotlin:kotlin-reflect" }
kotest-runner = { module = "io.kotest:kotest-runner-junit5", version.ref = "kotest" }
kotest-assertions = { module = "io.kotest:kotest-assertions-core", version.ref = "kotest" }
mockk = { module = "io.mockk:mockk", version.ref = "mockk" }
testcontainers-postgresql = { module = "org.testcontainers:postgresql", version.ref = "testcontainers" }
testcontainers-junit = { module = "org.testcontainers:junit-jupiter", version.ref = "testcontainers" }
[bundles]
spring-web = ["spring-boot-starter-web", "jackson-kotlin", "kotlin-reflect"]
spring-data = ["spring-boot-starter-data-jpa", "postgresql", "flyway-core", "flyway-postgresql"]
testing = ["kotest-runner", "kotest-assertions", "mockk"]
testcontainers = ["testcontainers-postgresql", "testcontainers-junit"]
[plugins]
kotlin-jvm = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" }
kotlin-spring = { id = "org.jetbrains.kotlin.plugin.spring", version.ref = "kotlin" }
kotlin-jpa = { id = "org.jetbrains.kotlin.plugin.jpa", version.ref = "kotlin" }
spring-boot = { id = "org.springframework.boot", version.ref = "spring-boot" }
spring-dependency-management = { id = "io.spring.dependency-management", version.ref = "spring-dependency-management" }
Why version catalogs?
- Single source of truth for versions — change once, apply everywhere
- IDE autocomplete —
libs.spring.boot.starter.webis typed, not a magic string - Bundles — group related dependencies (
spring-web,testing) - Shared across modules — all modules use the same versions
settings.gradle.kts
rootProject.name = "my-service"
include("app", "domain", "infrastructure", "common")
Root build.gradle.kts
plugins {
alias(libs.plugins.kotlin.jvm) apply false
alias(libs.plugins.kotlin.spring) apply false
alias(libs.plugins.spring.boot) apply false
alias(libs.plugins.spring.dependency.management) apply false
}
subprojects {
group = "com.example.myservice"
version = "0.0.1-SNAPSHOT"
repositories {
mavenCentral()
}
tasks.withType<Test> {
useJUnitPlatform()
}
}
apply false declares plugins without applying them — each module opts in.
Module build files
common/build.gradle.kts
plugins {
alias(libs.plugins.kotlin.jvm)
}
dependencies {
// No Spring dependencies — pure Kotlin
testImplementation(libs.bundles.testing)
}
domain/build.gradle.kts
plugins {
alias(libs.plugins.kotlin.jvm)
}
dependencies {
implementation(project(":common"))
// No Spring, no JPA — pure business logic
testImplementation(libs.bundles.testing)
}
infrastructure/build.gradle.kts
plugins {
alias(libs.plugins.kotlin.jvm)
alias(libs.plugins.kotlin.spring)
alias(libs.plugins.kotlin.jpa)
alias(libs.plugins.spring.dependency.management)
}
dependencies {
implementation(project(":domain"))
implementation(project(":common"))
implementation(libs.bundles.spring.data)
implementation(libs.spring.boot.starter.web)
testImplementation(libs.spring.boot.starter.test)
testImplementation(libs.bundles.testing)
testImplementation(libs.bundles.testcontainers)
}
app/build.gradle.kts
plugins {
alias(libs.plugins.kotlin.jvm)
alias(libs.plugins.kotlin.spring)
alias(libs.plugins.spring.boot)
alias(libs.plugins.spring.dependency.management)
}
dependencies {
implementation(project(":domain"))
implementation(project(":infrastructure"))
implementation(project(":common"))
implementation(libs.bundles.spring.web)
implementation(libs.spring.boot.starter.validation)
implementation(libs.spring.boot.starter.actuator)
implementation(libs.spring.boot.starter.security)
testImplementation(libs.spring.boot.starter.test)
testImplementation(libs.bundles.testing)
}
Only the app module applies the spring-boot plugin (for bootJar, bootRun).
What goes where
| Module | Contains | Depends on |
|---|---|---|
| common | Shared utilities, value classes, extensions | Nothing |
| domain | Entities, repository interfaces, use cases, domain events | common |
| infrastructure | JPA entities, repository implementations, API clients, Kafka | domain, common |
| app | Controllers, configuration, Spring Boot main class | All modules |
The key rule
The domain module has zero framework dependencies. No Spring, no JPA annotations, no Kafka. Business logic is pure Kotlin.
The infrastructure module implements the interfaces defined in domain:
// domain/src/.../repository/UserRepository.kt
interface UserRepository {
suspend fun findById(id: UserId): User?
suspend fun save(user: User): User
}
// infrastructure/src/.../repository/JpaUserRepository.kt
@Repository
class JpaUserRepository(
private val jpaRepo: SpringDataUserRepository
) : UserRepository {
override suspend fun findById(id: UserId): User? {
return jpaRepo.findByIdOrNull(id.value)?.toDomain()
}
override suspend fun save(user: User): User {
return jpaRepo.save(user.toEntity()).toDomain()
}
}
Building
# Build everything
./gradlew build
# Build only the app (and its dependencies)
./gradlew :app:build
# Run the app
./gradlew :app:bootRun
# Run tests for a specific module
./gradlew :domain:test
Gradle only rebuilds changed modules and their dependents. If you change common, all modules rebuild. If you change domain, only domain, infrastructure, and app rebuild. If you change app, only app rebuilds.
Build cache
Enable Gradle build cache for faster builds:
gradle.properties:
org.gradle.caching=true
org.gradle.parallel=true
org.gradle.configuration-cache=true
With build cache + parallel execution, incremental builds of a 4-module project typically take 2-5 seconds instead of 30+.
Common mistakes
1. Circular dependencies
domain → infrastructure → domain ← CIRCULAR
If domain needs something from infrastructure, the boundary is wrong. Extract the shared interface to domain or common.
2. Too many modules
5 modules for a 20-file project adds build complexity with no benefit. Start with one module. Split when you feel the pain.
3. Spring Boot plugin on library modules
The spring-boot plugin creates a fat jar. Only the app module should have it. Library modules should produce regular jars.
4. Not using bundles
// Tedious
implementation(libs.kotest.runner)
implementation(libs.kotest.assertions)
implementation(libs.mockk)
// Better — use a bundle
testImplementation(libs.bundles.testing)
Group related dependencies into bundles in the version catalog.
Summary
Multi-module Spring Boot with version catalogs:
- Version catalog (
libs.versions.toml) — single source of truth for all dependency versions - Module per concern — app, domain, infrastructure, common
- Domain module is pure Kotlin — no framework dependencies
- Only app module gets the Spring Boot plugin — it produces the bootJar
- Build cache + parallel — for fast incremental builds
Start with a single module. Split when build times or team coordination demand it. The version catalog is worth adopting even in single-module projects.