PCSalt
YouTube GitHub
Back to Spring Boot
Spring Boot · 2 min read

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 autocompletelibs.spring.boot.starter.web is 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

ModuleContainsDepends on
commonShared utilities, value classes, extensionsNothing
domainEntities, repository interfaces, use cases, domain eventscommon
infrastructureJPA entities, repository implementations, API clients, Kafkadomain, common
appControllers, configuration, Spring Boot main classAll 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:

  1. Version catalog (libs.versions.toml) — single source of truth for all dependency versions
  2. Module per concern — app, domain, infrastructure, common
  3. Domain module is pure Kotlin — no framework dependencies
  4. Only app module gets the Spring Boot plugin — it produces the bootJar
  5. 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.