7 Dependency Constraints

7.1 Konzept und Motivation von Dependency Constraints

Dependency Constraints sind ein Mechanismus zur zentralen Versionskontrolle ohne direkte Abhängigkeitsdeklaration. Sie definieren Versionsbedingungen, die erfüllt werden müssen, wenn eine Abhängigkeit in den Dependency Graph aufgenommen wird. Im Gegensatz zu direkten Dependencies fügen Constraints selbst keine Abhängigkeiten hinzu, sondern beschränken nur deren Versionen, falls diese anderweitig eingebunden werden.

Diese Trennung von Abhängigkeitsdeklaration und Versionsverwaltung löst mehrere Probleme in komplexen Projekten. Transitive Abhängigkeiten können kontrolliert werden, ohne sie zu direkten Abhängigkeiten zu machen. Sicherheitsupdates lassen sich zentral erzwingen, ohne jeden Konsumenten anzupassen. Konflikte zwischen verschiedenen Versionsanforderungen werden explizit und nachvollziehbar aufgelöst.

Ein praktisches Beispiel verdeutlicht den Nutzen. Ein Projekt verwendet Spring Boot, das transitiv eine verwundbare Version von Jackson einbindet. Statt Jackson als direkte Abhängigkeit zu deklarieren oder Spring Boot zu patchen, definiert ein Constraint die minimale sichere Jackson-Version. Gradle upgraded automatisch auf diese Version, sobald Jackson transitiv eingebunden wird.

Die Syntax für Dependency Constraints ähnelt der normalen Dependency-Deklaration:

dependencies {
    constraints {
        implementation("com.fasterxml.jackson.core:jackson-databind:2.15.2") {
            because("CVE-2023-1234 requires at least version 2.15.2")
        }
    }
}

Die because-Klausel dokumentiert den Grund für den Constraint. Diese Information erscheint in Dependency Reports und Build Scans, was die Nachvollziehbarkeit von Versionsentscheidungen verbessert.

7.2 Platform- und BOM-Integration

Dependency Constraints bilden die Grundlage für Gradle Platforms und BOMs. Eine Platform ist ein spezieller Projekttyp, der ausschließlich Constraints definiert und keine eigene Implementation enthält. Konsumenten der Platform erben alle Constraints, müssen aber weiterhin explizit deklarieren, welche Abhängigkeiten sie verwenden.

Die Erstellung einer unternehmensweiten Platform standardisiert Versionen über Projektgrenzen hinweg:

plugins {
    `java-platform`
}

dependencies {
    constraints {
        api("org.slf4j:slf4j-api:2.0.9")
        api("ch.qos.logback:logback-classic:1.4.11")
        api("org.junit.jupiter:junit-jupiter:5.10.0")
        runtime("org.postgresql:postgresql:42.6.0")
    }
}

Die Verwendung von api macht Constraints für Konsumenten sichtbar, während runtime nur zur Laufzeit wirkt. Konsumenten binden die Platform mit der platform()-Funktion ein und erhalten automatisch alle Versionsvorgaben.

Maven BOMs werden automatisch als Gradle Platforms interpretiert. Die dependencyManagement-Sektion eines Maven POM wird zu Dependency Constraints konvertiert. Dies ermöglicht nahtlose Integration mit Maven-basierten Projekten und die Nutzung existierender BOMs wie Spring Boot Dependencies oder Apache Camel BOM.

Enforced Platforms erzwingen ihre Constraints strikt. Die normale Platform-Einbindung erlaubt Überschreibung durch explizite Versionen. Mit enforcedPlatform() werden Platform-Constraints zu harten Requirements:

dependencies {
    implementation(enforcedPlatform("com.company:corporate-platform:1.0"))
    implementation("org.slf4j:slf4j-api") // Version von Platform erzwungen
}

7.3 Rich Version Declarations

Dependency Constraints unterstützen Rich Version Declarations für präzise Versionsanforderungen. Diese gehen über einfache Versionsnummern hinaus und ermöglichen komplexe Versionsbedingungen. Die require-Direktive spezifiziert die gewünschte Version, kann aber durch höhere Versionen überschrieben werden.

Strictly-Versionen verhindern jegliches Upgrade:

constraints {
    implementation("com.google.guava:guava") {
        version {
            strictly("31.0-jre")
        }
    }
}

Diese Konfiguration verhindert, dass transitive Abhängigkeiten eine höhere Guava-Version einbringen. Der Build schlägt fehl, wenn eine inkompatible Versionsanforderung besteht.

Rejected Versions schließen spezifische Versionen aus:

constraints {
    implementation("commons-codec:commons-codec") {
        version {
            require("1.15")
            reject("1.14", "1.13")
        }
        because("Versions 1.13 and 1.14 have known security vulnerabilities")
    }
}

Gradle wählt die nächsthöhere verfügbare Version, wenn eine abgelehnte Version angefordert wird. Dies ist besonders nützlich für Sicherheits-Patches, bei denen bestimmte Versionen vermieden werden müssen.

Preferred Versions beeinflussen die Versionsauswahl ohne harte Anforderungen:

constraints {
    implementation("org.apache.commons:commons-lang3") {
        version {
            prefer("3.12.0")
            require("[3.0, 4.0)")
        }
    }
}

Die bevorzugte Version wird gewählt, wenn keine anderen Faktoren dagegen sprechen. Die require-Range definiert akzeptable Grenzen.

7.4 Constraint-Vererbung in Multi-Modul-Projekten

In Multi-Modul-Projekten werden Constraints häufig zentral definiert und vererbt. Das Root-Projekt kann Constraints für alle Subprojekte setzen, ohne deren Build-Skripte zu modifizieren. Dies reduziert Duplikation und vereinfacht Versions-Updates.

Die Konfiguration im Root-Projekt verwendet allprojects oder subprojects:

allprojects {
    dependencies {
        constraints {
            implementation("io.netty:netty-all:4.1.97.Final")
            implementation("com.google.code.gson:gson:2.10.1")
        }
    }
}

Subprojekte können geerbte Constraints überschreiben, wenn spezielle Anforderungen bestehen. Ein Legacy-Modul könnte eine ältere Version benötigen:

dependencies {
    constraints {
        implementation("com.google.code.gson:gson:2.8.9") {
            because("Legacy code not compatible with Gson 2.10+")
        }
    }
}

Die Constraint-Resolution berücksichtigt alle Quellen und wählt die restriktivste kompatible Version. Bei Konflikten zwischen Constraints schlägt der Build fehl und fordert explizite Auflösung.

Module können eigene Constraint-Sets als Configurations exportieren:

val sharedConstraints by configurations.creating {
    canBeConsumed = true
    canBeResolved = false
}

dependencies {
    constraints {
        sharedConstraints("org.apache.logging.log4j:log4j-core:2.20.0")
    }
}

Andere Module importieren diese Constraints durch Dependency auf die Configuration. Dies ermöglicht flexible Constraint-Gruppierung nach technischen oder organisatorischen Kriterien.

7.5 Konfliktauflösung mit Constraints

Dependency Constraints bieten präzise Kontrolle über Konfliktauflösung. Wenn mehrere Constraints oder Dependencies unterschiedliche Versionen fordern, muss Gradle eine Auswahl treffen. Das Default-Verhalten wählt die höchste Version, die alle Bedingungen erfüllt. Constraints modifizieren dieses Verhalten durch zusätzliche Bedingungen.

Force-Direktiven in Constraints überschreiben normale Versionsauflösung:

constraints {
    implementation("org.springframework:spring-core:5.3.29") {
        version {
            strictly("5.3.29")
        }
        because("Corporate policy requires Spring 5.3.x branch")
    }
}

Diese strikte Version verhindert automatische Updates durch transitive Dependencies. Selbst wenn Spring Boot 6.0 Spring Core 6.0 benötigt, bleibt die Version bei 5.3.29. Der Build schlägt fehl, wenn die Versionen inkompatibel sind.

Resolution Strategy Rules ergänzen Constraints für dynamische Konfliktauflösung:

configurations.all {
    resolutionStrategy {
        eachDependency {
            if (requested.group == "org.slf4j") {
                useVersion("2.0.9")
                because("Standardize SLF4J version across all modules")
            }
        }
    }
}

Diese Regel wirkt zusätzlich zu Constraints und kann für Gruppen-weite Standardisierung verwendet werden.

7.6 Best Practices und Patterns

Die Organisation von Constraints folgt bewährten Mustern. Sicherheitsrelevante Constraints gehören in eine zentrale Security-Platform, die unternehmenweit geteilt wird. Technologie-Stack-Constraints gruppieren sich nach Frameworks wie Spring, Jakarta EE oder Micronaut. Test-Framework-Constraints bilden eine eigene Kategorie für einheitliche Testumgebungen.

Versionierung von Constraint-Platforms sollte Semantic Versioning folgen. Major-Updates signalisieren Breaking Changes in Constraints, Minor-Updates fügen neue Constraints hinzu, Patch-Updates aktualisieren bestehende Versionen. Diese Systematik ermöglicht kontrollierte Updates in konsumierenden Projekten.

Die Dokumentation von Constraints ist essentiell. Jeder Constraint sollte eine because-Klausel mit Begründung enthalten. Sicherheits-Constraints referenzieren CVE-Nummern, Kompatibilitäts-Constraints nennen betroffene Systeme, Policy-Constraints verweisen auf Unternehmensrichtlinien.

Regelmäßige Constraint-Reviews identifizieren veraltete oder unnötige Einschränkungen. Automatisierte Tools prüfen auf neue Versionen und Sicherheitslücken. Die Integration in CI/CD-Pipelines validiert Constraint-Kompatibilität vor Produktions-Deployments. Constraint-Violations triggern Alerts und blockieren kritische Deployments.

Die Balance zwischen Flexibilität und Kontrolle bestimmt die Constraint-Strategie. Zu strikte Constraints behindern Innovation und Updates. Zu laxe Constraints führen zu Versionschaos und Sicherheitsproblemen. Die optimale Strategie variiert je nach Projekttyp, Team-Größe und Risikotoleranz.