Skip to content

Instantly share code, notes, and snippets.

@lukebemish
Last active August 16, 2024 00:55
Show Gist options
  • Save lukebemish/fd84abd0a98be9b0ec89116203cee547 to your computer and use it in GitHub Desktop.
Save lukebemish/fd84abd0a98be9b0ec89116203cee547 to your computer and use it in GitHub Desktop.

Luke's Big List of Gradle Do's and Don't's

Gradle has a lot of subtle assumptions or requirments it makes. Some of them are documented, but some are only implied through how gradle executes, and many are not obvious to someone unfamiliar with gradle. Here, I've made my best attempt to document some of these requirements/suggestions, provide some reasoning, and provide sources where they exist. In general, I've split stuff up into two categories: things that developers using gradle for their projects should know, and things that those authoring gradle plugins should know. Note that those authoring gradle plugins should also be aware of the first category.

Table of contents

Best practices and suggestions for Gradle-usng developers

General tips

Be declarative if at all possible

Gradle -- in part due to how much it's changed since early versions -- often has many different ways to do the same thing. Generally speaking, the newer approaches are more declarative and less imperative than the older approaches, and these approaches tend to better support modern gradle features in general, or just be less annoying to fuss around with. To consider some examples, see how to add to the classpaths of a source set or using capabilities instead of classifiers.

Dependencies and publishing

When handling dependencies, prefer configurations to raw file collections

Consider the following scenario: you have an api source set and a main source set, and you'd like everything from the classpath of the api source set to also be on the classpath of the main source set. A common way of doing this (that should be avoided!) that I've quite commonly seen goes as follows:

sourceSets {
    api {}
    main {
        runtimeClasspath += sourceSets.api.runtimeClasspath
        compileClasspath += sourceSets.api.compileClasspath
    }
}

To see why this is not a good idea, consider the following case: the main source set has a dependency implementation 'a:a:1.0.0', and the api souce set has a dependency implementation 'b:b:1.2.0'. Now, it turns out that a also depends on b, but it publishes a transitive dependency on version 1.0.0. With the code above, both versions 1.0.0 and 1.2.0 of b will end up on the classpath -- and if there's further transitive dependencies this gets messy even faster! A better approach to this is to let gradle handle it all at the level of dependency resolution:

configurations {
    compileClasspath.extendsFrom apiCompileClasspath
    runtimeClasspath.extendsFrom apiRuntimeClasspath
}

And the compile classpath will inherit the dependencies used by the api compile classpath, instead of just inherting the resolved files.

Make use of feature variants

Let's expand on that last example of a separate api source set a bit more: generally, if you're using such a setup it's because you're publishing a separate api jar. You might create a jar with a different classifier, include its contents in both the main jar and the normal jar, and then publish both by manually adding the artifact to the publication. A complete build script implementing this that way -- which is not the best option for a number of reasons -- looks as follows:

sourceSets {
    api {}
}

configurations {
    compileClasspath.extendsFrom apiCompileClasspath
    runtimeClasspath.extendsFrom apiRuntimeClasspath
}

tasks.register('apiJar', Jar) {
    from sourceSets.api.output
    archiveClassifier = 'api'
}

jar {
    from sourceSets.api.output
}

publishing {
    publications {
        mavenJava(MavenPublication) {
            from components.java
            artifact tasks.apiJar
        }
    }
}

At first glance, this seems like a good way to go about this. However, several problems arise quickly:

  • someone depending on the main jar or someone depending on the api jar will get the exact same transitive dependencies -- which is not optimal; there may, for instance, be compile-only dependencies of the main jar that you wish to expose as compileOnlyApi for use by other projects internally, but which should not be exposed to consumers of the api
  • the creation of the apiJar task, its addition to the publication, and the inheritance of source sets is all done imperatively -- this leaves it rather fragile, and somewhat confusing to read. Looking at the build script at a glance, we might immediately tell that it's making and publishing another jar -- but it's not obvious that what its doing is isolating certain portions of our project into their own chunk, which the main component of the project extends from.
  • Build scripts are meant to declare the structure of a project, as opposed to declaring a series of steps. With the approach above, someone writing this has to have some understanding of how the Jar task type works, how gradle sets up task dependencies, etc -- all of which are more like implementation details of declaring the desired structure.

Ideally, there'd be a way of telling gradle about the structure we want, and having it figure out all the tasks needed, linking it up to publication, and all the rest without having to manually specify it. And indeed there is! It's called library features. An example of the previous buildscript, adapted to use feature variants, might look like:

sourceSets {
    api {}
}

java.registerFeature('api') {
    usingSourceSet(sourceSets.api)
}

configurations {
    compileClasspath.extendsFrom apiCompileClasspath
    runtimeClasspath.extendsFrom apiRuntimeClasspath
    apiElements.extendsFrom apiApiElements
    runtimeElements.extendsFrom apiRuntimeElements
}

jar {
    from sourceSets.api.output
}

publishing {
    publications {
        mavenJava(MavenPublication) {
            from components.java
        }
    }
}

Registering the feature automatically sets up a number of components -- notably, the jar task for the feature, and publishing. In fact, you can tell it to set up javadoc or sources jars for a feature in a similar way as you would for the main feature! Additionally, the api jar now can have its own transitive dependencies when published -- and we can express that the main artifact's transitive dependencies should extend from the api jar's with the apiElements.extendsFrom apiApiElements and runtimeElements.extendsFrom apiRuntimeElements lines. When depending on this jar, instead of specifying the classifier, you specify the capability:

implementation('org.example:example:1.0.0') {
    capabilities {
        requireCapability('org.example:example-api')
    }
}

Now, there's still one or two things left to be desired with this setup -- namely, having to explicitly define that the various main configurations depend on the equivalent api configurations, and that the main jar should contain the sources from the api jar. However, if we're working with some sort of plugin to bundle dependencies, such as the shadow plugin, or the jar-in-jar features of plugins like loom or neogradle, this can all be expressed quite nicely:

sourceSets {
    api {}
}

java.registerFeature('api') {
    usingSourceSet(sourceSets.api)
}

dependencies {
    include(implementation(project(':')) {
        capabilities {
            requireCapability('org.example:example-api')
        }
    })
}

publishing {
    publications {
        mavenJava(MavenPublication) {
            from components.java
        }
    }
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment