Softwareentwicklung

Das Dependency-Biest zähmen: Gradle Version Catalogs im Monorepo

Warum eine einzige libs.versions.toml den Versions-Drift in mehrmoduligen Gradle-Builds stoppt, wie du sie nutzt und wie Dependabot sie aktuell hält.

Marco Schäck
Marco Schäck
Process Development Lead
15. Nov. 20255 Min. Lesezeit
Das Dependency-Biest zähmen: Gradle Version Catalogs im Monorepo

Fünf Module. Fünf build.gradle.kts-Dateien. Und irgendwie drei verschiedene MockK-Versionen. So ein Setup ist mir schon in mehr als einem Gradle-Projekt mit mehreren Modulen begegnet. Jedes Modul deklariert seine eigenen Versionen. Also driften sie auseinander. Und jedes Upgrade wird zur Search-and-Replace-Session quer durchs ganze Repo.

Maven-Nutzer kennen dieses Problem kaum. Dort halten das Parent-POM und sein <dependencyManagement>-Block die Versionsnummern schon seit Maven 2 an einer Stelle, also seit rund zwanzig Jahren. Gradle hatte lange keinen solchen Standard. Also bauten die Teams entweder ihre eigenen Lösungen, oder sie lebten mit dem Drift.

Das änderte sich mit dem Version Catalog. Seit Gradle 7.4 Anfang 2022 ist er stabil, und Gradles eigener Best-Practices-Guide empfiehlt ihn. Trotzdem finde ich immer wieder Builds, die nie umgestellt haben, obwohl die Idee simpel ist und sich schnell auszahlt: Eine Datei benennt jede Abhängigkeit und Version genau einmal. Und ein Bot hält sie aktuell.

🔍 Was ist ein Version Catalog eigentlich?

Ein Version Catalog ist eine TOML-Datei unter gradle/libs.versions.toml. Sie hat vier Abschnitte:

[versions]
kotlin = "2.2.0"
spring-boot = "3.5.4"
mockk = "1.14.5"

[libraries]
spring-boot-bom = { module = "org.springframework.boot:spring-boot-dependencies", version.ref = "spring-boot" }
spring-boot-starter-web = { module = "org.springframework.boot:spring-boot-starter-web" }
jackson-kotlin = { module = "com.fasterxml.jackson.module:jackson-module-kotlin" }
mockk = { module = "io.mockk:mockk", version.ref = "mockk" }
kotest-assertions = { module = "io.kotest:kotest-assertions-core", version = "5.9.1" }

[bundles]
testing = ["mockk", "kotest-assertions"]

[plugins]
kotlin-jvm = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" }
spring-boot = { id = "org.springframework.boot", version.ref = "spring-boot" }

versions und libraries geben jeder Version und jedem Artefakt einen Namen. plugins tut dasselbe für Gradle-Plugins. Und bundles fasst Bibliotheken zusammen, die du immer gemeinsam nutzt, etwa einen Test-Stack.

Der Spring-Starter und Jackson tragen hier keine Version. Das ist Absicht. Ihre Versionen kommen aus dem Spring Boot BOM, das der Catalog ebenfalls auflistet.

🛠️ Wie nutzt du ihn?

Das Setup ist diese eine Datei. Gradle findet sie von allein und generiert daraus typsichere Accessors. So kann jedes Modul sie sofort nutzen:

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

dependencies {
    implementation(platform(libs.spring.boot.bom))
    implementation(libs.spring.boot.starter.web)
    implementation(libs.jackson.kotlin)
    testImplementation(libs.bundles.testing)
}

Das Ergebnis: keine Versions-Strings mehr. Koordinaten tippst du einmal, nicht pro Modul. Und deine IDE vervollständigt libs. für dich. Über eine Sache stolperst du am Anfang allerdings: Bindestriche in den TOML-Namen werden in Kotlin zu Punkten. Aus spring-boot-starter-web wird also libs.spring.boot.starter.web.

🏗️ Warum glänzt er im Monorepo?

Einen Catalog würde ich sogar in einem Single-Modul-Projekt empfehlen. Aber im Monorepo, wo ein Gradle-Build viele Module umfasst, zahlt er sich erst richtig aus.

Jedes Modul des Builds liest aus derselben Datei. So schrumpfen die drei MockK-Versionen aus dem Intro auf eine Zeile. Zwei Module können nicht mehr aus Versehen verschiedene Versionen deklarieren. Ein Kotlin-Update heißt: eine Zeile in einer Datei ändern. Genau das zeigt dann der Pull Request. Ein Reviewer sieht das ganze Upgrade auf einen Blick, statt sich durch fünf Build-Dateien zu wühlen.

Bundles zahlen sich genauso aus. Jedes Modul zieht den Test-Stack mit einem einzigen libs.bundles.testing herein. Nimm eine Bibliothek ins Bundle auf, und jedes Modul, das dieses Bundle nutzt, zieht mit, ohne dass du eine Build-Datei anfasst.

🤖 Wer hält den Catalog aktuell?

Eine zentrale Datei hilft nur, wenn sie jemand pflegt. Und dieser Jemand kann ein Bot sein. Dependabot, GitHubs eingebauter Update-Dienst, prüft deine Abhängigkeiten auf neuere Releases und öffnet dafür Pull Requests. Alles, was er braucht, ist eine kleine Konfigurationsdatei:

# .github/dependabot.yml
version: 2
updates:
  - package-ecosystem: "gradle"
    directory: "/"
    schedule:
      interval: "weekly"
    groups:
      minor-and-patch:
        patterns: ["*"]
        update-types: ["minor", "patch"]

Ab da liest Dependabot einmal pro Woche die libs.versions.toml. Er findet die Datei allerdings nur unter dem Standardpfad gradle/, also lass sie dort liegen. Bringt MockK ein neues Release, bekommst du einen Pull Request, der genau eine Zeile im Catalog ändert. Deine CI baut jedes Modul damit neu, und eine grüne Pipeline beweist, dass alle Module weiterhin durchlaufen.

Aber ein Bot öffnet nur Pull Requests. Reviewen und mergen muss sie immer noch jemand, und bei Dutzenden Abhängigkeiten wird das schnell unübersichtlich. Der groups-Key oben definiert eine einzige Sammelgruppe: patterns: ["*"] greift jede Abhängigkeit, und update-types beschränkt sie auf Minor- und Patch-Bumps. Zusammen landen sie in einem einzigen Pull Request. So prüfst du einen Pull Request pro Woche statt fünfzehn. Nur Major-Versionen kommen weiter einzeln, und genau da willst du ohnehin genauer hinschauen.

Der eigentliche Gewinn steckt im Aufbau des Catalogs. Artefakte, die zusammengehören, teilen sich eine version.ref. Kommt also ein neues Spring-Boot-Release, ändert der Bot eine einzige Zeile, und BOM und Plugin ziehen beide mit. Kein halbes Upgrade, bei dem ein Teil dem Rest vorauseilt. Das funktioniert, weil Spring das BOM und das Plugin in derselben Version veröffentlicht. Sollten Artefakte unter einem Key mal nach unterschiedlichen Zeitplänen veröffentlicht werden, überspringt Dependabot das Update, statt es aufzuspalten.

⚖️ Worauf solltest du achten?

Ein Catalog benennt Versionen nur. Er erzwingt sie nicht. Auf transitive Abhängigkeiten hat er keinen Einfluss. Deshalb importiert der dependencies-Block von oben das Spring Boot BOM weiterhin über platform(). Die beiden ergänzen sich, sie ersetzen sich nicht.

Darüber hinaus halten zwei kleine Gewohnheiten die Datei aufgeräumt. Benenne Aliase nach dem Artefakt: jackson-kotlin findest du leicht, json nicht. Und gib jede Version über version.ref an. Dann teilen sich zusammengehörige Artefakte einen Eintrag, anders als die inline gesetzte kotest-Version oben.

Ein Haken bleibt: Convention-Plugins in buildSrc bekommen die libs-Accessors nicht geschenkt. Dort musst du den Catalog von Hand einbinden, was etwas zusätzlichen Aufwand kostet. Die Gradle-Docs zeigen, wie.

🤝 Fazit

Ein Version Catalog ist eine kleine Sache: eine TOML-Datei, die jede Abhängigkeit, Version und jedes Plugin einmal benennt und die zusammengehörigen gleich bündelt. Aber im Monorepo verhindert diese kleine Sache, dass deklarierte Versionen auseinanderdriften. Und sie macht aus jedem simplen Upgrade, das keine Code-Änderung braucht, einen Ein-Zeilen-Diff.

Alles allein erledigt er nicht. Er benennt Versionen nur, also kombiniere ihn mit einem BOM, um sie zu erzwingen. Und kombiniere ihn mit Dependabot oder Renovate, damit diese Ein-Zeilen-Diffs von allein hereinkommen.

Wie verwaltest du Versionen heute über deine Module hinweg? Wenn die Antwort Search-and-Replace enthält, überführ diese Woche mal die Abhängigkeiten eines Moduls in einen Catalog. Ich bin gespannt, wie es läuft.

Teilen:
Marco Schäck

Marco Schäck

Process Development Lead bei Miragon

Fandest du den Artikel interessant?

Abonniere unseren Newsletter und erhalte neue Beiträge direkt ins Postfach.

Innerhalb eines Tages weißt du, wo du stehst.

Kein Verkaufsgespräch, sondern eine ehrliche Einschätzung, ob und wie Automatisierung dir hilft.

Erstgespräch vereinbaren

Kein Commitment. Kein Pitch-Deck. Nur Klarheit.