Let’s talk about what’s next

Whether you're working through a challenge or ready to move on something new, we're ready.

Looking to join the team?

Find your next challenge

Please enter a name

Please enter a company

Please enter an email

Please enter a valid email

Please enter a phone

Please enter a valid phone

Please tell us about your challenge or opportunity

Start a conversation

Thanks

Your message has been sent.
We will get back to you within 1–2 business days.

Something went wrong while sending. Please try again, or email us at hello@parser.com.

Insights

Using Gradle Version Catalogs to modernise Gradle Projects

Managing dependency versions in CI/CD pipelines can be challenging, especially when Docker image versions used in tests are hardcoded separately from the main build configuration.

30 Mar 2026
Dionisio Cortés Fernández
Engineering and architecture

By Dionisio Cortés Fernández, at Parser

Managing dependency versions in CI/CD pipelines can be challenging, especially when Docker image versions used in tests are hardcoded separately from the main build configuration. Over time, this can lead to inconsistencies and subtle bugs, even when automated tools such as Dependabot or Renovate are updating dependencies in the build files.

In this article, we demonstrate how Gradle Version Catalogs can be used to centralise dependency versions for both application and test environments in a Kotlin and Spring Boot project. We include practical examples showing how to pass version values from the Gradle catalog into Testcontainers. during test execution.

We also briefly review how dependency version management in Gradle has evolved, which helps explain why different project structures are still common today.

Introduction

While working on a new feature, we needed to mock a downstream service. During this process, we noticed that the Docker image versions of WireMock were hardcoded in the tests. It is not very intuitive to manage these versions alongside the others defined in the build.gradle.kts.

This can potentially lead to situations where the build file specifies WireMock 3.x while the actual test runtime still uses WireMock 2.x, which is surprisingly easy to miss.

To illustrate a better approach, we build a simple reactive application using Spring Boot 4 and Kotlin 2.2, and show how Gradle Version Catalogs help keep dependency versions aligned across the project.

1. The tools

As software developers, we sometimes adopt the latest tools simply because they are new. In this article, however, the goal is not to use something new for the sake of it, but to solve a specific problem.

  1. Gradle Version Catalogs (TOML)
  • The problem: Fragmented dependency management. Versions scattered across build.gradle, ext blocks, and hardcoded strings in tests.
  • The solution: A single TOML file that tools understand natively.
  1. The Infrastructure: Testcontainers & The Official WireMock Module
  • The problem: Brittle “works on my machine” local setups and managing mock servers manually.
  • The solution: Ephemeral, codifiable infrastructure that spins up and tears down with every test run.

In practice, whether a project uses Maven or Gradle matters less than choosing an approach that solves the problem clearly and reliably. At Parser, our focus is on helping our clients solve real engineering problems, so we prioritise approaches that improve maintainability and reduce friction in day-to-day development.

2. Centralising Versions with TOML

For many years, teams used the buildSrc directory in Gradle to manage constants and versions in Kotlin. While this approach provided type safety, it came at a cost: every version bump invalidated the entire build script cache. We can see different ways of handling Gradle projects (all work). If we are coming from Maven, a possible evolution might look like this:

2.1. Dependency Management Plugin Style

This is a specific way of handling versions in Gradle that is similar to how Maven works. It relies on the io.spring.dependency-management plugin to control dependency versions globally via a BOM.

plugins {
id("org.springframework.boot") version "4.0.2"
id("io.spring.dependency-management") version "1.1.7"
kotlin("jvm") version "2.2.21"
kotlin("plugin.spring") version "2.2.21"
}

group = "io.github.dionisioc"
version = "0.0.1-SNAPSHOT"

val wiremockVersion = "3.13.2"
val wiremockTcVersion = "1.0-alpha-15"

java {
toolchain {
languageVersion = JavaLanguageVersion.of(21)
}
}

repositories {
mavenCentral()
}

dependencies {
implementation("org.springframework.boot:spring-boot-starter-webflux")
implementation("org.jetbrains.kotlin:kotlin-reflect")
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-reactor")
implementation("com.fasterxml.jackson.module:jackson-module-kotlin")

testImplementation("org.springframework.boot:spring-boot-starter-test")
testImplementation("org.springframework.boot:spring-boot-starter-webflux-test")testImplementation("org.testcontainers:testcontainers-junit-jupiter")
testImplementation("org.wiremock:wiremock-standalone:$wiremockVersion")

testImplementation("org.wiremock.integrations.testcontainers:wiremock-testcontainers-module:$wiremockTcVersion")
}

kotlin {
compilerOptions {
freeCompilerArgs.addAll("-Xjsr305=strict", "-Xannotation-default-target=param-property")
}
}

tasks.withType<Test> {
useJUnitPlatform()
systemProperty("TEST_WIREMOCK_VERSION", wiremockVersion)
}./gradlew clean buildNone
Java HotSpot(TM) 64-Bit Server VM warning: Sharing is only supported for boot loader classes because bootstrap classpath has been appended
2026-03-08T19:22:33.227+01:00 INFO 16515 --- [wiremock-gradle-example] [ionShutdownHook] o.s.boot.reactor.netty.GracefulShutdown : Commencing graceful shutdown. Waiting for active requests to complete
2026-03-08T19:22:33.229+01:00 INFO 16515 --- [wiremock-gradle-example] [ netty-shutdown] o.s.boot.reactor.netty.GracefulShutdown : Graceful shutdown complete

BUILD SUCCESSFUL in 13s
9 actionable tasks: 9 executed
Consider enabling configuration cache to speed up this build: https://docs.gradle.org/9.3.1/userguide/configuration_cache_enabling.html

2.2. buildSrc (Kotlin DSL)

We can go for a more Gradle friendly style (buildSrc (Kotlin DSL))

We will use the following folder structure:


project-root/
├── buildSrc/
│ ├── build.gradle.kts
│ └── src/
│ └── main/
│ └── kotlin/
│ └── Dependencies.kt

├── src/
│ ├── main/
│ │ ├── kotlin/
│ │ │ └── io/github/dionisioc/
│ │ │ └── Application.kt
│ │ └── resources/
│ │ └── application.yml
│ └── test/
│ ├── kotlin/
│ │ └── io/github/dionisioc/
│ │ └── ReactiveIntegrationTest.kt
│ └── resources/
│ └── wiremock/

├── build.gradle.kts
└── settings.gradle.kts

The buildSrc/build.gradle.kts fil

plugins {
`kotlin-dsl`
}

repositories {
mavenCentral()
}

Dependencies.kt

object Versions {
const val kotlin = "2.2.21"
const val springBoot = "4.0.2"
const val dependencyManagement = "1.1.7"

const val wiremock = "3.13.2"
const val wiremockTc = "1.0-alpha-13"
}

object Libs {
const val springWebflux = "org.springframework.boot:spring-boot-starter-webflux"
const val springTest = "org.springframework.boot:spring-boot-starter-test"
const val springWebfluxTest = "org.springframework.boot:spring-boot-starter-webflux-test"
const val testcontainers = "org.testcontainers:testcontainers-junit-jupiter"

const val kotlinReflect = "org.jetbrains.kotlin:kotlin-reflect"
const val coroutinesReactor = "org.jetbrains.kotlinx:kotlinx-coroutines-reactor"
const val jacksonKotlin = "com.fasterxml.jackson.module:jackson-module-kotlin"

const val wiremockStandalone = "org.wiremock:wiremock-standalone:${Versions.wiremock}"
const val wiremockTcModule = "org.wiremock.integrations.testcontainers:wiremock-testcontainers-module:${Versions.wiremockTc}"
}

build.gradle.kts

plugins {
id("org.springframework.boot") version Versions.springBoot
id("io.spring.dependency-management") version Versions.dependencyManagement
kotlin("jvm") version Versions.kotlin
kotlin("plugin.spring") version Versions.kotlin
}

group = "io.github.dionisioc"
version = "0.0.1-SNAPSHOT"

java {
toolchain {
languageVersion = JavaLanguageVersion.of(21)
}
}

repositories {
mavenCentral()
}

dependencies {
implementation(Libs.springWebflux)
implementation(Libs.kotlinReflect)
implementation(Libs.coroutinesReactor)
implementation(Libs.jacksonKotlin)

testImplementation(Libs.springTest)
testImplementation(Libs.springWebfluxTest)
testImplementation(Libs.testcontainers)

testImplementation(Libs.wiremockStandalone)
testImplementation(Libs.wiremockTcModule)
}

kotlin {
compilerOptions {
freeCompilerArgs.addAll("-Xjsr305=strict", "-Xannotation-default-target=param-property")
}
}

tasks.withType<Test> {
useJUnitPlatform()

systemProperty("TEST_WIREMOCK_VERSION", Versions.wiremock)
}



./gradlew clean buildJava HotSpot(TM) 64-Bit Server VM warning: Sharing is only supported for boot loader classes because bootstrap classpath has been appended
2026-03-08T19:18:55.724+01:00 INFO 14683 --- [wiremock-gradle-example] [ionShutdownHook] o.s.boot.reactor.netty.GracefulShutdown : Commencing graceful shutdown. Waiting for active requests to complete
2026-03-08T19:18:55.725+01:00 INFO 14683 --- [wiremock-gradle-example] [ netty-shutdown] o.s.boot.reactor.netty.GracefulShutdown : Graceful shutdown complete

BUILD SUCCESSFUL in 12s
12 actionable tasks: 9 executed, 3 up-to-date
Consider enabling configuration cache to speed up this build: https://docs.gradle.org/9.3.1/userguide/configuration_cache_enabling.html
Consider enabling configuration cache to speed up this build: https://docs.gradle.org/9.3.1/userguide/configuration_cache_enabling.html

This approach works well for organisation and maintainability, especially as the project grows beyond a single file. It cleanly separates versions from project building. It’s worth remembering that the build system is part of our software. Just as we use separation of concerns in application code, we should apply the same principle to build logic. In this setup, buildSrc contains what we want to build, and build.gradle.kts defines how we build it.

2.3. Version Catalogs (TOML)

We are now using the final and recommended way for declaring versions in Gradle, introduced in version 7.4 (February 2022), following a preview in version 7.0 (April 2021)

project-root/
├── gradle/
│ └── libs.versions.toml
├── build.gradle.kts
└── settings.gradle.kts[versions]
kotlin = "2.2.21"
springBoot = "4.0.2"
dependencyManagement = "1.1.7"

wiremock = "3.13.2"
wiremockTestcontainers = "1.0-alpha-15"

[libraries]
spring-webflux = { module = "org.springframework.boot:spring-boot-starter-webflux" }
spring-test = { module = "org.springframework.boot:spring-boot-starter-test" }
spring-webflux-test = { module = "org.springframework.boot:spring-boot-starter-webflux-test" }
testcontainers-junit = { module = "org.testcontainers:testcontainers-junit-jupiter" }

kotlin-reflect = { module = "org.jetbrains.kotlin:kotlin-reflect" }
kotlinx-coroutines-reactor = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-reactor" }
jackson-kotlin = { module = "com.fasterxml.jackson.module:jackson-module-kotlin" }

wiremock-standalone = { module = "org.wiremock:wiremock-standalone", version.ref = "wiremock" }
wiremock-testcontainers = { module = "org.wiremock.integrations.testcontainers:wiremock-testcontainers-module", version.ref = "wiremockTestcontainers" }

[plugins]
kotlin-jvm = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" }
kotlin-spring = { id = "org.jetbrains.kotlin.plugin.spring", version.ref = "kotlin" }
spring-boot = { id = "org.springframework.boot", version.ref = "springBoot" }
spring-dependency-management = { id = "io.spring.dependency-management", version.ref = "dependencyManagement" }

build.gradle.kts

plugins {
alias(libs.plugins.spring.boot)
alias(libs.plugins.spring.dependency.management)
alias(libs.plugins.kotlin.jvm)
alias(libs.plugins.kotlin.spring)
}

group = "io.github.dionisioc"
version = "0.0.1-SNAPSHOT"

java {
toolchain {
languageVersion = JavaLanguageVersion.of(21)
}
}

repositories {
mavenCentral()
}

dependencies {
implementation(libs.spring.webflux)
implementation(libs.kotlin.reflect)
implementation(libs.kotlinx.coroutines.reactor)
implementation(libs.jackson.kotlin)

testImplementation(libs.spring.test)
testImplementation(libs.spring.webflux.test)
testImplementation(libs.testcontainers.junit)

testImplementation(libs.wiremock.standalone)
testImplementation(libs.wiremock.testcontainers)
}

kotlin {
compilerOptions {
freeCompilerArgs.addAll("-Xjsr305=strict", "-Xannotation-default-target=param-property")
}
}

tasks.withType<Test> {
useJUnitPlatform()
systemProperty("TEST_WIREMOCK_VERSION", libs.versions.wiremock.get())
}






./gradlew clean buildJava HotSpot(TM) 64-Bit Server VM warning: Sharing is only supported for boot loader classes because bootstrap classpath has been appended2026-03-08T19:28:42.895+01:00 INFO 18345 --- [wiremock-gradle-example] [ionShutdownHook] o.s.boot.reactor.netty.GracefulShutdown : Commencing graceful shutdown. Waiting for active requests to complete
2026-03-08T19:28:42.897+01:00 INFO 18345 --- [wiremock-gradle-example] [ netty-shutdown] o.s.boot.reactor.netty.GracefulShutdown : Graceful shutdown complete

BUILD SUCCESSFUL in 12s
9 actionable tasks: 9 executed
Consider enabling configuration cache to speed up this build: https://docs.gradle.org/9.3.1/userguide/configuration_cache_enabling.html

3. Gradle to Test Runtime

How do we get `wiremock = “3.13.2”` from gradle/libs.versions.toml into our Kotlin test runtime?

We inject it as a Java System Property during the test task.

+-----------------------+
| libs.versions.toml |
+-----------------------+
|
| Reads version
|
v
+-----------------------+
| build.gradle.kts |
+-----------------------+
|
| systemProperty
|
v
+-----------------------+
| JVM Test Runtime |
+-----------------------+
|
| System.getProperty
|
v
+-----------------------+
| Testcontainers |
+-----------------------+
|
| Docker Pull
|
v
+---------------------------+
| WireMock Image:Version |
+---------------------------+tasks.withType<Test> {
useJUnitPlatform()
systemProperty("TEST_WIREMOCK_VERSION", libs.versions.wiremock.get())
}

4. Wiremock configuration: Files and Mappings

When running standalone, WireMock expects a specific folder structure to separate Logic (Mappings) from Data (Files).

  • mappings/: The Map (“If a request comes to X, do Y”).
  • __files/: The Data (“Here is the JSON object to return”).

The Map (mappings/external-user.json):

{
"request": { "method": "GET", "url": "/external/users/101" },
"response": {
"status": 200,
"headers": { "Content-Type": "application/json" },
"bodyFileName": "user-response.json"
}
}

The Data (__files/user-response.json):

{ "id": "101", "username": "john_doe", "role": "ADVOCATE" }

When the WireMock Docker container starts, it expects these inside /home/wiremock. We map them using Testcontainers:

val wiremock = WireMockContainer("wiremock/wiremock:$WIREMOCK_VERSION")
.withClasspathResourceMapping("wiremock", "/home/wiremock", BindMode.READ_ONLY)

5. The Application Under Test

The Application Class:

import org.springframework.boot.autoconfigure.SpringBootApplication
import org.springframework.boot.runApplication
import org.springframework.context.annotation.Bean
import org.springframework.web.reactive.function.client.WebClient

@SpringBootApplication
class WiremockGradleExampleApplication {
@Bean
fun webClientBuilder(): WebClient.Builder = WebClient.builder()
}

fun main(args: Array<String>) {
runApplication<WiremockGradleExampleApplication>(*args)
}

The Client:

import org.springframework.stereotype.Component
import org.springframework.web.reactive.function.client.WebClient
import org.springframework.web.reactive.function.client.awaitBody

@Component
class ExternalUserClient(builder: WebClient.Builder) {
private val client = builder.build()

suspend fun fetchUser(baseUrl: String, id: String): UserResponse {
return client.get()
.uri("$baseUrl/external/users/{id}", id)
.retrieve()
.awaitBody<UserResponse>()
}
}

data class UserResponse(val id: String, val username: String, val role: String)




The Controller (API Endpoint):

import org.springframework.beans.factory.annotation.Value
import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.PathVariable
import org.springframework.web.bind.annotation.RestController

@RestController
class UserController(
private val client: ExternalUserClient,
@Value("\${external.api.base-url}") private val externalUrl: String
) {
@GetMapping("/api/users/{id}")
suspend fun getUser(@PathVariable id: String): UserDto {
val user = client.fetchUser(externalUrl, id)
return UserDto(user.id, user.username.uppercase())
}
}

data class UserDto(val id: String, val displayName: String)

6. The Integration Test Strategy

The final piece pulls the version from the System Property we set in Gradle.

import com.github.tomakehurst.wiremock.client.WireMock
import com.github.tomakehurst.wiremock.client.WireMock.*
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.boot.test.context.SpringBootTest
import org.springframework.boot.webtestclient.autoconfigure.AutoConfigureWebTestClient
import org.springframework.test.context.DynamicPropertyRegistry
import org.springframework.test.context.DynamicPropertySource
import org.springframework.test.web.reactive.server.WebTestClient
import org.testcontainers.containers.BindMode
import org.testcontainers.junit.jupiter.Container
import org.testcontainers.junit.jupiter.Testcontainers
import org.wiremock.integrations.testcontainers.WireMockContainer

@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@AutoConfigureWebTestClient
@Testcontainers
class ReactiveIntegrationTest {

@Autowired lateinit var webTestClient: WebTestClient
companion object {
private val WIREMOCK_VERSION = System.getProperty("TEST_WIREMOCK_VERSION")

@Container
val wiremock = WireMockContainer("wiremock/wiremock:$WIREMOCK_VERSION")
.withClasspathResourceMapping("wiremock", "/home/wiremock", BindMode.READ_ONLY)

@JvmStatic
@DynamicPropertySource
fun properties(registry: DynamicPropertyRegistry) {
registry.add("external.api.base-url") { wiremock.baseUrl }
}
}

@BeforeEach
fun setup() {
WireMock.configureFor(wiremock.host, wiremock.getMappedPort(8080))
WireMock.resetAllRequests()
}

@Test
fun `should fetch and transform user data non-blocking`() {
webTestClient.get()
.uri("/api/users/101")
.exchange()
.expectStatus().isOk
.expectBody()
.jsonPath("$.displayName").isEqualTo("JOHN_DOE")

verify(getRequestedFor(urlEqualTo("/external/users/101")))
}
}

Conclusion

By using Gradle Version Catalogs, we move from fragile, disjointed configurations to a unified place where dependency versions are defined and maintained. This approach give us some advantages:

  1. Velocity: Automated tools update your entire stack,from JARs to Docker images, a single TOML file.
  2. Performance: Switching to Version Catalogs improves Gradle configuration times.
  3. Maintainability: Test data is package-scoped and versions are no longer hidden in code.

References: