8 Configuration-Patterns

In einem Java-Projekt gibt es oft zahlreiche Abhängigkeiten. Treten dann Versionskonflikte auf, wird der Build langsamer, und es ist nicht immer klar, von welcher Abhängigkeit eine bestimmte Bibliothek stammt. Genau diese Probleme adressiert Gradle mit seinem Configuration-System. Die Kernidee ist einfach: Gradle trennt strikt zwischen drei Aufgaben – das Deklarieren von Abhängigkeiten, das Auflösen der Abhängigkeiten und das Bereitstellen der Artefakte. Diese klare Trennung macht Builds nicht nur 30–50 % schneller(Durch die Kombination mit dem Cache), sondern auch deutlich wartbarer. Projekte wie das Spring Framework oder Netflix haben so ihre Build-Zeiten deutlich reduziert.

8.1 Das Grundkonzept: Drei Rollen, klare Verantwortlichkeiten

Gradle Configurations sind vergleichbar mit spezialisierten Containern. Bislang war jeder “Container” ein Art Alleskönner - das konnte zu unübersichtlichem Verhalten führen. Mit dem Configuration Pattern hat jeder “Container” genau eine Aufgabe:

8.1.1 1. Bucket Configurations (Declarable)

Diese sind wie Einkaufslisten - sie sammeln nur, welche Dependencies Sie brauchen, lösen aber nichts auf. Das ist Ihre implementation oder api Configuration.

// So deklarieren Sie Dependencies - einfach und klar
dependencies {
    implementation("org.springframework:spring-core:6.0.11")  // Interne Abhängigkeit
    api("com.google.guava:guava:32.1.3-jre")                 // Wird an Nutzer weitergegeben
}

8.1.2 2. Resolvable Configurations

Diese sind die Consumer, die Ihre Einkaufsliste nehmen und tatsächlich die JARs herunterladen. Sie erstellen den Classpath für Compilation oder Runtime. Wichtig: Sie deklarieren niemals selbst Dependencies, sondern erweitern Bucket Configurations.

// Eine resolvable Configuration erstellen
val myClasspath = configurations.resolvable("myClasspath") {
    extendsFrom(configurations.implementation.get())  // Nimmt Dependencies von implementation
}

// Praktisches Beispiel: Alle großen JARs finden
tasks.register("findLargeJars") {
    doLast {
        configurations.compileClasspath.files
            .filter { it.length() > 10_000_000 }
            .forEach { println("Große JAR gefunden: ${it.name}") }
    }
}

8.1.3 3. Consumable Configurations

Diese stellen Artefakte für andere Projekte bereit - wie ein Laden, der Produkte verkauft. Wenn Sie eine Library bauen, exponieren diese Configurations Ihre JAR-Dateien.

// Configuration für API-Elemente, die andere Projekte nutzen können
configurations {
    apiElements {
        canBeConsumed = true
        canBeResolved = false
        outgoing {
            artifact(tasks.jar)  // Stellt die JAR-Datei bereit
        }
    }
}

8.2 Praktische Anwendung: Von der Theorie zur Praxis

8.2.1 Beispiel 1: Ein Multi-Modul-Projekt strukturieren

Nehmen wir an, Sie bauen eine Microservice-Anwendung mit gemeinsam genutzten Komponenten. Hier zeige ich Ihnen, wie Sie das sauber strukturieren:

// In Ihrem gemeinsamen Modul (shared-utils)
plugins {
    `java-library`  // Wichtig für die api Configuration
}

dependencies {
    // Diese Dependencies werden an Nutzer weitergegeben
    api("org.slf4j:slf4j-api:2.0.9")
    
    // Diese bleiben intern
    implementation("com.fasterxml.jackson.core:jackson-databind:2.15.3")
}

// In Ihrem Service-Modul
dependencies {
    implementation(project(":shared-utils"))
    // Sie bekommen automatisch slf4j-api, aber nicht jackson-databind
}

Der Vorteil: Ihre Service-Module bekommen nur die Dependencies, die sie wirklich brauchen. Das verhindert Versionskonflikte und hält den Classpath sauber.

8.2.2 Beispiel 2: Integration Tests mit eigenen Dependencies

Ein häufiges Problem: Integration Tests brauchen zusätzliche Bibliotheken wie Testcontainers, die nicht im normalen Test-Classpath sein sollen.

// Schritt 1: Test Suite definieren
testing {
    suites {
        val integrationTest by registering(JvmTestSuite::class) {
            useJUnitJupiter()
            
            dependencies {
                // Diese Dependencies nur für Integration Tests
                implementation(project())
                implementation("org.testcontainers:postgresql:1.19.1")
                implementation("io.rest-assured:rest-assured:5.3.2")
            }
        }
    }
}

// Schritt 2: Integration Tests nach Unit Tests ausführen
tasks.named<Test>("integrationTest") {
    shouldRunAfter(tasks.test)
    systemProperty("test.type", "integration")
}

8.2.3 Beispiel 3: Dependencies für Code-Generierung

Viele Projekte generieren Code (z.B. mit JOOQ oder Protocol Buffers). Diese Generator-Tools sollen nicht im finalen Produkt landen:

// Eigene Configuration für Code-Generator
val codegen by configurations.creating {
    canBeResolved = true   // Kann aufgelöst werden
    canBeConsumed = false  // Wird nicht exponiert
}

dependencies {
    codegen("com.squareup:javapoet:1.13.0")
}

// Task für Code-Generierung
val generateCode by tasks.registering(JavaExec::class) {
    classpath = codegen  // Nutzt nur codegen Dependencies
    mainClass.set("com.example.Generator")
    
    // Generierte Dateien zum Source Set hinzufügen
    outputs.dir("$buildDir/generated/java")
}

sourceSets {
    main {
        java.srcDir(generateCode)
    }
}

8.3 Häufige Probleme und ihre Lösungen

8.3.1 Problem 1: “Configuration has been resolved”

Dieser Fehler tritt auf, wenn Sie versuchen, eine Configuration zu ändern, nachdem sie bereits aufgelöst wurde.

// ❌ FALSCH - Löst Configuration sofort auf
val files = configurations.runtimeClasspath.get().files
tasks.register<Copy>("copyLibs") {
    from(files)  // Configuration bereits aufgelöst!
    into("libs")
}

// ✅ RICHTIG - Lazy Resolution
tasks.register<Copy>("copyLibs") {
    from(configurations.runtimeClasspath)  // Resolution erst bei Task-Ausführung
    into("libs")
}

8.3.2 Problem 2: Performance-Probleme durch frühe Resolution

Ein häufiger Fehler ist, Configurations während der Konfigurationsphase aufzulösen. Das macht Builds langsam und verhindert Configuration Caching.

// ❌ FALSCH - Resolution in der Konfigurationsphase
println("Dependencies: ${configurations.compileClasspath.get().files}")

// ✅ RICHTIG - Resolution nur wenn nötig
tasks.register("showDependencies") {
    doLast {
        println("Dependencies: ${configurations.compileClasspath.get().files}")
    }
}

8.3.3 Problem 3: Versionskonflikte lösen

Wenn verschiedene Dependencies unterschiedliche Versionen derselben Bibliothek brauchen:

configurations.all {
    resolutionStrategy {
        // Force eine spezifische Version
        force("com.fasterxml.jackson.core:jackson-databind:2.15.3")
        
        // Oder fail bei Konflikten für explizite Kontrolle
        failOnVersionConflict()
        
        // Oder nutze Substitution
        dependencySubstitution {
            substitute(module("log4j:log4j"))
                .using(module("org.apache.logging.log4j:log4j-core:2.20.0"))
                .because("log4j v1 hat Sicherheitslücken")
        }
    }
}

8.4 Migration von alten Gradle-Versionen

Wenn Sie von Gradle 6.x oder älter migrieren, hier die wichtigsten Änderungen:

8.4.1 Schritt 1: Ersetzen Sie veraltete Configurations

// ALT (Gradle < 7.0)
dependencies {
    compile("...")           // → implementation oder api
    runtime("...")          // → runtimeOnly
    testCompile("...")      // → testImplementation
}

plugins {
    `java-library`  // Für api Configuration
}

dependencies {
    api("...")              // Dependencies, die weitergegeben werden
    implementation("...")   // Interne Dependencies
    runtimeOnly("...")     // Nur zur Laufzeit benötigt
    testImplementation("...") // Test Dependencies
}

8.4.2 Schritt 2: Configuration Cache aktivieren

Fügen Sie zu Ihrer gradle.properties hinzu:

org.gradle.configuration-cache=true
org.gradle.configuration-cache.problems=warn

Dies kann Ihre Build-Zeit um 30-50% reduzieren, erfordert aber saubere Configuration-Patterns.

8.5 Best Practices für wartbare Builds

8.5.1 1. Nutzen Sie Platform/BOM für Versionsverwaltung

Anstatt Versionen überall zu wiederholen, nutzen Sie eine Platform:

dependencies {
    // Platform definiert Versionen zentral
    implementation(platform("org.springframework.boot:spring-boot-dependencies:3.1.5"))
    
    // Dependencies ohne Versionsangabe
    implementation("org.springframework.boot:spring-boot-starter-web")
    implementation("org.springframework.boot:spring-boot-starter-data-jpa")
}

8.5.2 2. Strukturieren Sie Test Configurations hierarchisch

configurations {
    // Basis für alle Tests
    val testBase by creating
    
    // Spezialisierte Test-Typen
    val unitTest by creating {
        extendsFrom(testBase)
    }
    
    val integrationTest by creating {
        extendsFrom(testBase)
    }
}

dependencies {
    testBase("org.junit.jupiter:junit-jupiter:5.9.3")
    integrationTest("org.testcontainers:testcontainers:1.19.1")
}

8.5.3 3. Verwenden Sie Factory-Methoden für neue Configurations

Gradle 8.1+ bietet spezielle Factory-Methoden, die automatisch die richtigen Flags setzen:

// Moderne, sichere Art Configurations zu erstellen
val myApi = configurations.dependencyScope("myApi")
val myClasspath = configurations.resolvable("myClasspath") {
    extendsFrom(myApi.get())
}
val myElements = configurations.consumable("myElements") {
    extendsFrom(myApi.get())
}

8.6 Debugging-Tools für Configuration-Probleme

Wenn etwas nicht funktioniert, helfen diese Kommandos:

# Zeigt alle Dependencies einer Configuration
./gradlew dependencies --configuration runtimeClasspath

# Erklärt, warum eine bestimmte Version gewählt wurde
./gradlew dependencyInsight --dependency jackson-databind

# Visualisiert den Build (kostenpflichtig, aber sehr hilfreich)
./gradlew build --scan

# Listet alle Configurations und ihre Eigenschaften
./gradlew configurations

8.7 Zusammenfassung: Die wichtigsten Punkte

Das neue Configuration-System in Gradle 8.x mag anfangs komplex wirken, aber die Grundidee ist einfach: Klare Trennung von Verantwortlichkeiten führt zu schnelleren, wartbareren Builds.

Merken Sie sich diese Kernregeln:

  1. Jede Configuration hat nur eine Rolle - entweder Dependencies deklarieren, auflösen oder Artefakte bereitstellen
  2. Nutzen Sie implementation statt compile - das verhindert Dependency-Verschmutzung
  3. Resolution nur zur Ausführungszeit - niemals während der Konfiguration
  4. Configuration Cache aktivieren - für 30-50% schnellere Builds
  5. Bei Problemen: Build Scans nutzen - sie visualisieren komplexe Dependency-Graphen

Mit diesen Patterns haben Projekte wie Spring Framework und Netflix ihre Build-Zeiten drastisch reduziert. Der initiale Aufwand für die Migration lohnt sich durch bessere Performance und weniger Build-Probleme in der Zukunft.

Der Schlüssel zum Erfolg liegt darin, die drei Rollen zu verstehen und konsequent zu trennen. Beginnen Sie mit kleinen Schritten - migrieren Sie zunächst von compile zu implementation, aktivieren Sie dann Configuration Cache, und optimieren Sie schrittweise Ihre Build-Logik. ## Übung

8.8 Resolvable und Consumable Configurations

8.8.1 Schritt 1: Projekt erstellen

# Neues Verzeichnis anlegen und hineinwechseln
mkdir gradle-configs-demo
cd gradle-configs-demo

# Gradle-Projekt initialisieren
gradle init --type basic --dsl kotlin --project-name configs-demo

# Unterverzeichnisse für die zwei Module erstellen
mkdir -p producer/src/main/java/demo
mkdir -p consumer/src/main/java/demo

8.8.2 Schritt 2: Multi-Projekt konfigurieren

settings.gradle.kts:

rootProject.name = "configs-demo"
include("producer", "consumer")

8.8.3 Schritt 3: Producer-Modul (stellt etwas bereit)

producer/build.gradle.kts:

plugins {
    java
}

// WICHTIG: Repositories definieren!
repositories {
    mavenCentral()
}

java {
    toolchain {
        languageVersion.set(JavaLanguageVersion.of(17))
    }
}

// CONSUMABLE Configuration - kann von anderen genutzt werden
val myTools by configurations.creating {
    isCanBeConsumed = true
    isCanBeResolved = false
}

// Ein paar Tools bereitstellen
dependencies {
    myTools("com.google.guava:guava:32.1.3-jre")
    myTools("org.apache.commons:commons-lang3:3.14.0")
}

8.8.4 Schritt 4: Consumer-Modul (nutzt etwas)

consumer/build.gradle.kts:

plugins {
    java
}

// WICHTIG: Repositories definieren!
repositories {
    mavenCentral()
}

java {
    toolchain {
        languageVersion.set(JavaLanguageVersion.of(17))
    }
}

// RESOLVABLE Configuration - kann Dependencies auflösen
val externalTools by configurations.creating {
    isCanBeResolved = true
    isCanBeConsumed = false
}

// Tools vom Producer-Modul holen
dependencies {
    externalTools(project(":producer", configuration = "myTools"))
}

// Task: Zeige alle Tools (Configuration Cache kompatibel!)
tasks.register("showTools") {
    // Input deklarieren für Configuration Cache
    inputs.files(externalTools)
    
    doLast {
        println("\n📦 Tools in der Configuration:")
        // Auf die Configuration über inputs zugreifen
        inputs.files.files.forEach { file ->
            println("  - ${file.name} (${file.length() / 1024} KB)")
        }
    }
}

// Task: Kopiere Tools in build-Ordner
tasks.register<Copy>("copyTools") {
    from(externalTools)
    into(layout.buildDirectory.dir("tools"))
    
    doLast {
        println("\n${outputs.files.asFileTree.files.size} Dateien nach build/tools kopiert")
    }
}

8.8.5 Schritt 5: Ausprobieren

# Projekt bauen
./gradlew build

# Tools anzeigen (funktioniert jetzt!)
./gradlew :consumer:showTools

# Ausgabe:
# 📦 Tools in der Configuration:
#   - guava-32.1.3-jre.jar (2931 KB)
#   - commons-lang3-3.14.0.jar (651 KB)
#   - failureaccess-1.0.1.jar (4 KB)
#   - listenablefuture-9999.0-empty-to-avoid-conflict-with-guava.jar (2 KB)
#   - jsr305-3.0.2.jar (19 KB)
#   - checker-qual-3.37.0.jar (231 KB)
#   - error_prone_annotations-2.21.1.jar (15 KB)
#   - j2objc-annotations-2.8.jar (8 KB)

# Tools kopieren
./gradlew :consumer:copyTools

# Prüfen was kopiert wurde
ls consumer/build/tools/

8.8.6 Schritt 6: Dependencies prüfen

# Dependencies vom Consumer anzeigen
./gradlew :consumer:dependencies --configuration externalTools

# Ausgabe:
# externalTools
# \--- project :producer (myTools)
#      +--- com.google.guava:guava:32.1.3-jre
#      |    +--- com.google.guava:failureaccess:1.0.1
#      |    +--- com.google.guava:listenablefuture:9999.0-empty-to-avoid-conflict-with-guava
#      |    +--- com.google.code.findbugs:jsr305:3.0.2
#      |    +--- org.checkerframework:checker-qual:3.37.0
#      |    +--- com.google.errorprone:error_prone_annotations:2.21.1
#      |    \--- com.google.j2objc:j2objc-annotations:2.8
#      \--- org.apache.commons:commons-lang3:3.14.0

8.8.7 🎯 Das Wichtigste auf einen Blick

8.8.7.1 Producer (stellt bereit)

val myTools by configurations.creating {
    isCanBeConsumed = true
    isCanBeResolved = false
}

Kann von anderen Modulen genutzt werden

8.8.8 Consumer (nutzt)

val externalTools by configurations.creating {
    isCanBeResolved = true
    isCanBeConsumed = false
}

Kann Dependencies auflösen und Dateien herunterladen

8.8.8.1 Merksatz


8.8.9 Was leistet der Consumer?

Der Consumer erfüllt in dem Beispiel genau die Rolle, die das Kapitel didaktisch verdeutlichen soll:

Kurz: Der Producer „stellt zur Verfügung“, der Consumer „holt ab und verarbeitet“. Damit wird der fundamentale Unterschied zwischen consumable und resolvable Configurations klar:

8.8.10 Was leistet der Producer?

Der Producer ist im Beispiel der Gegenpart zum Consumer. Seine Aufgabe ist:

Kurz gesagt: Der Producer „schnürt ein Paket“ (seine consumable Configuration) und bietet es anderen Modulen an. Er ist der Lieferant von Abhängigkeiten, ohne selbst deren Auflösung oder Verarbeitung vorzunehmen.

8.9 Noch einfachere Variante (ohne Java)

Falls es noch einfacher sein soll - hier das absolute Minimum:

producer/build.gradle.kts:

repositories {
    mavenCentral()
}

// CONSUMABLE Configuration
val myTools by configurations.creating {
    isCanBeConsumed = true
    isCanBeResolved = false
}

dependencies {
    myTools("org.apache.commons:commons-lang3:3.14.0")
}

consumer/build.gradle.kts:

repositories {
    mavenCentral()
}

// RESOLVABLE Configuration  
val externalTools by configurations.creating {
    isCanBeResolved = true
    isCanBeConsumed = false
}

dependencies {
    externalTools(project(":producer", configuration = "myTools"))
}

// Einfache Task ohne Configuration Cache Probleme
tasks.register("listTools") {
    inputs.files(externalTools)
    
    doLast {
        println("Tools:")
        inputs.files.files.forEach { 
            println("- ${it.name}")
        }
    }
}

Mit:

./gradlew :consumer:listTools

8.9.1 Aufgabe: Übung um ein weiteres Submodul erweitern

Erstellen Sie ein drittes Modul analyzer, das selbst eine resolvable Configuration definiert. Dieses Modul soll die vom consumer kopierten Artefakte weiterverarbeiten. Ziel ist es, die Abhängigkeiten nicht nur herunterzuladen, sondern in einer eigenen Task auszuwerten.

  1. Legen Sie ein neues Submodul analyzer an und fügen Sie es in settings.gradle.kts hinzu.
  2. Definieren Sie dort eine resolvable Configuration analyzedTools, die sich die Dateien aus dem consumer holt.
  3. Implementieren Sie eine Task analyzeTools, die alle JARs ausgibt und deren Größe summiert.
  4. Führen Sie anschließend den Task :analyzer:analyzeTools aus und überprüfen Sie die Ausgabe.

8.9.2 Lösungsvorschlag (für Daniel)

Ich erstelle für Sie eine Musterlösung für die Aufgabe mit dem analyzer Modul:

8.10 Musterlösung: Analyzer-Modul

8.10.1 Schritt 1: settings.gradle.kts erweitern

settings.gradle.kts:

rootProject.name = "configs-demo"
include("producer", "consumer", "analyzer")

8.10.2 Schritt 2: Consumer-Modul anpassen (consumable Configuration hinzufügen)

consumer/build.gradle.kts (erweiterte Version):

plugins {
    java
}

repositories {
    mavenCentral()
}

java {
    toolchain {
        languageVersion.set(JavaLanguageVersion.of(17))
    }
}

// RESOLVABLE Configuration - kann Dependencies auflösen
val externalTools by configurations.creating {
    isCanBeResolved = true
    isCanBeConsumed = false
}

// NEU: CONSUMABLE Configuration für andere Module
val processedTools by configurations.creating {
    isCanBeConsumed = true
    isCanBeResolved = false
}

// Tools vom Producer-Modul holen
dependencies {
    externalTools(project(":producer", configuration = "myTools"))
    // Die gleichen Tools auch für andere Module bereitstellen
    processedTools(project(":producer", configuration = "myTools"))
}

// Existierende Tasks bleiben unverändert
tasks.register("showTools") {
    inputs.files(externalTools)
    
    doLast {
        println("\n📦 Tools in der Configuration:")
        inputs.files.files.forEach { file ->
            println("  - ${file.name} (${file.length() / 1024} KB)")
        }
    }
}

tasks.register<Copy>("copyTools") {
    from(externalTools)
    into(layout.buildDirectory.dir("tools"))
    
    doLast {
        println("\n${outputs.files.asFileTree.files.size} Dateien nach build/tools kopiert")
    }
}

8.10.3 Schritt 3: Analyzer-Modul implementieren

analyzer/build.gradle.kts:

plugins {
    java
}

repositories {
    mavenCentral()
}

java {
    toolchain {
        languageVersion.set(JavaLanguageVersion.of(17))
    }
}

// RESOLVABLE Configuration - holt sich die Tools vom Consumer
val analyzedTools by configurations.creating {
    isCanBeResolved = true
    isCanBeConsumed = false
}

// Dependencies vom Consumer-Modul holen
dependencies {
    analyzedTools(project(":consumer", configuration = "processedTools"))
}

// Task: Analysiere alle Tools
tasks.register("analyzeTools") {
    // Input für Configuration Cache
    inputs.files(analyzedTools)
    
    doLast {
        println("\n" + "=".repeat(60))
        println("TOOL ANALYSE REPORT")
        println("=".repeat(60))
        
        val files = inputs.files.files.filter { it.name.endsWith(".jar") }
        var totalSize = 0L
        var maxSize = 0L
        var maxFile = ""
        
        println("\nGefundene JAR-Dateien:")
        println("-".repeat(60))
        
        files.sortedBy { it.name }.forEach { file ->
            val sizeInKb = file.length() / 1024
            val sizeInMb = file.length() / (1024.0 * 1024.0)
            totalSize += file.length()
            
            if (file.length() > maxSize) {
                maxSize = file.length()
                maxFile = file.name
            }
            
            println(String.format("%-50s %8d KB (%5.2f MB)", 
                file.name, 
                sizeInKb, 
                sizeInMb))
        }
        
        println("\n" + "=".repeat(60))
        println("ZUSAMMENFASSUNG:")
        println("-".repeat(60))
        println("Anzahl JARs:        ${files.size}")
        println("Gesamtgröße:        ${totalSize / 1024} KB (${String.format("%.2f", totalSize / (1024.0 * 1024.0))} MB)")
        println("Durchschnittsgröße: ${if (files.isNotEmpty()) (totalSize / files.size) / 1024 else 0} KB")
        println("Größte Datei:       $maxFile (${maxSize / 1024} KB)")
        println("=".repeat(60))
    }
}

// Erweiterte Analyse-Task mit mehr Details
tasks.register("analyzeToolsDetailed") {
    inputs.files(analyzedTools)
    
    doLast {
        println("\nDETAILLIERTE ANALYSE")
        println("=".repeat(60))
        
        val jarFiles = inputs.files.files.filter { it.name.endsWith(".jar") }
        val groupedByPrefix = jarFiles.groupBy { 
            it.name.substringBefore("-").replace("_", "-")
        }
        
        println("\nNach Präfix gruppiert:")
        groupedByPrefix.forEach { (prefix, files) ->
            val totalSize = files.sumOf { it.length() }
            println("\n  $prefix:")
            files.forEach { file ->
                println("    - ${file.name} (${file.length() / 1024} KB)")
            }
            println("    Gesamt: ${totalSize / 1024} KB")
        }
        
        // Dependencies-Baum
        println("\nDependency-Struktur:")
        println("  guava")
        println("  ├── failureaccess")
        println("  ├── listenablefuture")
        println("  ├── jsr305")
        println("  ├── checker-qual")
        println("  ├── error_prone_annotations")
        println("  └── j2objc-annotations")
        println("  commons-lang3 (standalone)")
    }
}

8.10.4 Schritt 4: Ausführen und Testen

# Alle Module bauen
./gradlew build

# Analyzer-Task ausführen
./gradlew :analyzer:analyzeTools

# Detaillierte Analyse ausführen
./gradlew :analyzer:analyzeToolsDetailed

# Dependency-Graph anzeigen
./gradlew :analyzer:dependencies --configuration analyzedTools