Maven scopes vs. Gradle configurations

WARNING: Information presented in this blog entry is outdated. Use of the compile & runtime configuration is no longer possible since Gradle 7. Other changes might have occurred since this blog entry was posted. Caveat emptor.

Both Maven and Gradle are popular choices when it comes to building Java projects. These tools share common traits but there are some key differences that make you more productive depending on a particular scenario (no, I'm not referring to the XML DSL vs. Groovy/Kotlin DSL schism), case in point scopes vs. configurations.

Scopes and configurations are used by each tool to define dependencies and how they affect different classpaths, such as the compilation and runtime classpaths. Maven defines 6 scopes: compile, runtime, provided, system, test, and import. Gradle on the other hand defines the following configurations when the Java plugin is applied: annotationProcessor, compile, compileOnly, runtime, testAnnotationProcessor, testCompile, testCompileOnly, testRuntime. Actually Gradle defines a few more but these ought to be enough to get started.

Maven defines the behavior for each scope as following (copied verbatim from the dependency management page)

  • compile This is the default scope, used if none is specified. Compile dependencies are available in all classpaths of a project. Furthermore, those dependencies are propagated to dependent projects.
  • provided This is much like compile, but indicates you expect the JDK or a container to provide the dependency at runtime. For example, when building a web application for the Java Enterprise Edition, you would set the dependency on the Servlet API and related Java EE APIs to scope provided because the web container provides those classes. This scope is only available on the compilation and test classpath, and is not transitive.
  • runtime This scope indicates that the dependency is not required for compilation, but is for execution. It is in the runtime and test classpaths, but not the compile classpath.
  • test This scope indicates that the dependency is not required for normal use of the application, and is only available for the test compilation and execution phases. This scope is not transitive.
  • system This scope is similar to provided except that you have to provide the JAR which contains it explicitly. The artifact is always available and is not looked up in a repository.
  • import (only available in Maven 2.0.9 or later) This scope is only supported on a dependency of type pom in the section. It indicates the dependency to be replaced with the effective list of dependencies in the specified POM's section. Since they are replaced, dependencies with a scope of import do not actually participate in limiting the transitivity of a dependency.

At the bottom of the scope hierarchy we find compile, used to define dependencies needed for compilation. Next we've got provided which also defines dependencies available for compilation. Dependencies defined by compile and provided must be resolved against a repository and they are also visible for runtime execution; the difference strives that provided should not expose its dependencies to the final packaging as they are expected to be provided by the hosting environment (hence the name), such as an application server. The third one is system which behaves like provided except that it does not require a repository for resolution, rather you must define a path (preferably a relative path to the root of the project) that points to the artifact's location. Dependencies placed this scope are also visible at runtime but you must make sure they are reachable by their path. We move on to runtime, which as the name implies, are dependencies required during execution. Finally we find test, which builds on top of the previous 4 scopes, defining those dependencies required for both compilation and execution of tests.

Alright, did you notice the subtle difference between production scopes (the first 4 scopes) and test scope? There's a clear distinction between compilation and execution when it comes to production, but test puts everything in one basket. Why is this a problem? We'll see in just a moment. Say you have an application where SLF4J is used as the logging framework of choice. Log4j is used as the chosen Slf4j binding as it provides many useful features such as appenders; however to keep things simple during tests (and to be able to change logging levels at will during tests) the chosen Slf4j binding will be slf4j-simple. Armed with this knowledge the POM for this project could look like this

pom.xml

<project xmlns="http://maven.apache.org/POM/4.0.0"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/maven-v4_0_0.xsd">
    <modelVersion>4.0.0</modelVersion>
    <groupId>com.acme</groupId>
    <artifactId>sample</artifactId>
    <packaging>jar</packaging>
    <version>0.1.0-SNAPSHOT</version>

    <properties>
        <slf4j.version>1.7.25</slf4j.version>
        <log4j.version>1.2.17</log4j.version>
        <junit.version>4.12</junit.version>
    </properties>

    <build>
        <plugins>
            <plugin>
                <groupId>org.apache.maven.plugins</groupId>
                <artifactId>maven-dependency-plugin</artifactId>
                <version>3.0.1</version>
            </plugin>
        </plugins>
    </build>

    <dependencies>
        <dependency>
            <groupId>org.slf4j</groupId>
            <artifactId>slf4j-api</artifactId>
            <version>${slf4j.version}</version>
            <scope>compile</scope>
        </dependency>

        <dependency>
            <groupId>log4j</groupId>
            <artifactId>log4j</artifactId>
            <version>${log4j.version}</version>
            <scope>runtime</scope>
        </dependency>
        <dependency>
            <groupId>org.slf4j</groupId>
            <artifactId>slf4j-log4j12</artifactId>
            <version>${slf4j.version}</version>
            <scope>runtime</scope>
        </dependency>

        <dependency>
            <groupId>junit</groupId>
            <artifactId>junit</artifactId>
            <version>${junit.version}</version>
            <scope>test</scope>
        </dependency>

        <dependency>
            <groupId>org.slf4j</groupId>
            <artifactId>slf4j-simple</artifactId>
            <version>${slf4j.version}</version>
            <scope>test</scope>
        </dependency>
    </dependencies>
</project>

Let's see, slf4j-api is set to the compile scope because we need it for compilation, good. Then we have the log4j artifacts set to the runtime scope because we only need the Slf4j bindings during execution, that's good as well. Next we define the testing framework (JUnit 4 in this case) in the test scope, also good. Finally we define slf4j-simple in the test scope too as we need it during tests. When tests are run we get the following output

-------------------------------------------------------
 T E S T S
-------------------------------------------------------
Running com.acme.HelloWorldTest
SLF4J: Class path contains multiple SLF4J bindings.
SLF4J: Found binding in [jar:file:/Users/aalmiray/.m2/repository/org/slf4j/slf4j-log4j12/1.7.25/slf4j-log4j12-1.7.25.jar!/org/slf4j/impl/StaticLoggerBinder.class]
SLF4J: Found binding in [jar:file:/Users/aalmiray/.m2/repository/org/slf4j/slf4j-simple/1.7.25/slf4j-simple-1.7.25.jar!/org/slf4j/impl/StaticLoggerBinder.class]
SLF4J: See http://www.slf4j.org/codes.html#multiple_bindings for an explanation.
SLF4J: Actual binding is of type [org.slf4j.impl.Log4jLoggerFactory]
Tests run: 1, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.071 sec

Oops, we're not supposed to have more than one Sfl4j binding in the classpath! The dependency:tree goal can shed a bit more of light into this matter

[INFO] --- maven-dependency-plugin:3.0.1:tree (default-cli) @ sample ---
[INFO] com.acme:sample:jar:0.1.0-SNAPSHOT
[INFO] +- org.slf4j:slf4j-api:jar:1.7.25:compile
[INFO] +- log4j:log4j:jar:1.2.17:runtime
[INFO] +- org.slf4j:slf4j-log4j12:jar:1.7.25:runtime
[INFO] +- junit:junit:jar:4.12:test
[INFO] |  \- org.hamcrest:hamcrest-core:jar:1.3:test
[INFO] \- org.slf4j:slf4j-simple:jar:1.7.25:test

We get a report on artifacts and their scope, we can clearly see that log4j artifacts are set to runtime as we specified exactly that; the fact that test can reach into runtime and defines both compilation and execution classpaths is hurting us. What now? Well we could use profiles to try to fix this situation, by moving the log4j artifacts to a block protected by a profile which is not enabled by default, for example

pom.xml

<project xmlns="http://maven.apache.org/POM/4.0.0"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/maven-v4_0_0.xsd">
    <modelVersion>4.0.0</modelVersion>
    <groupId>com.acme</groupId>
    <artifactId>sample</artifactId>
    <packaging>jar</packaging>
    <version>0.1.0-SNAPSHOT</version>

    <properties>
        <slf4j.version>1.7.25</slf4j.version>
        <log4j.version>1.2.17</log4j.version>
        <junit.version>4.12</junit.version>
    </properties>

    <build>
        <plugins>
            <plugin>
                <groupId>org.apache.maven.plugins</groupId>
                <artifactId>maven-dependency-plugin</artifactId>
                <version>3.0.1</version>
            </plugin>
        </plugins>
    </build>

    <dependencies>
        <dependency>
            <groupId>org.slf4j</groupId>
            <artifactId>slf4j-api</artifactId>
            <version>${slf4j.version}</version>
            <scope>compile</scope>
        </dependency>

        <dependency>
            <groupId>junit</groupId>
            <artifactId>junit</artifactId>
            <version>${junit.version}</version>
            <scope>test</scope>
        </dependency>

        <dependency>
            <groupId>org.slf4j</groupId>
            <artifactId>slf4j-simple</artifactId>
            <version>${slf4j.version}</version>
            <scope>test</scope>
        </dependency>
    </dependencies>

    <profiles>
        <profile>
            <id>prod</id>
            <dependencies>
                <dependency>
                    <groupId>log4j</groupId>
                    <artifactId>log4j</artifactId>
                    <version>${log4j.version}</version>
                    <scope>runtime</scope>
                </dependency>
                <dependency>
                    <groupId>org.slf4j</groupId>
                    <artifactId>slf4j-log4j12</artifactId>
                    <version>${slf4j.version}</version>
                    <scope>runtime</scope>
                </dependency>
            </dependencies>
        </profile>
    </profiles>
</project>

After this change we get the following outputs by invoking depedency:tree with and without explicit usage of the prod profile

$ mvn dependency:tree

[INFO] --- maven-dependency-plugin:3.0.1:tree (default-cli) @ sample ---
[INFO] com.acme:sample:jar:0.1.0-SNAPSHOT
[INFO] +- org.slf4j:slf4j-api:jar:1.7.25:compile
[INFO] +- junit:junit:jar:4.12:test
[INFO] |  \- org.hamcrest:hamcrest-core:jar:1.3:test
[INFO] \- org.slf4j:slf4j-simple:jar:1.7.25:test

$ mvn dependency:tree -Pprod

[INFO] --- maven-dependency-plugin:3.0.1:tree (default-cli) @ sample ---
[INFO] com.acme:sample:jar:0.1.0-SNAPSHOT
[INFO] +- org.slf4j:slf4j-api:jar:1.7.25:compile
[INFO] +- junit:junit:jar:4.12:test
[INFO] |  \- org.hamcrest:hamcrest-core:jar:1.3:test
[INFO] +- org.slf4j:slf4j-simple:jar:1.7.25:test
[INFO] +- log4j:log4j:jar:1.2.17:runtime
[INFO] \- org.slf4j:slf4j-log4j12:jar:1.7.25:runtime

This looks like it might just work, however you now must remember to activate the prod profile whenever a production release must be made. Let's see how Gradle manages this situation.

In its most basic setup Gradle maps Maven's scopes to the following configurations:

Maven Gradle
compile compile
provided compileOnly, testCompileOnly (*)
system
runtime runtime
test testCompile, testRuntime

The provided scope is a tricky one and turned out to be a sore issue between the Gradle community and the maintainers of Gradle for years. The semantics of provided as Maven defines them are mixed from the POV of the Gradle maintainers, as this scope allows you to define dependencies that are needed for compilation only, even for those projects where an application container is not needed. What happened with this scope goes a bit like this: webapps were on the rise in early days of Maven; an option was needed to specify those dependencies provided by an application container in such a way that the final package (a WAR file) would not include said dependencies (for exaple the Servlet API JAR file). As Maven grew in popularity it was used to build all kinds of projects, some required the semantics of provided but were not web application projects. When annotation processor projects came to use (such as Lombok, AutoValue, Dagger, etc) it became clear that such components should form part of the compilation step but are not needed during execution, thus the only option for Maven users was to use the provided scope to define such dependencies.

Gradle refused to deliver a provided configuration for years; there were 3rd party plugins that delivered this feature until a time came when the compileOnly and testCompileOnly configurations came to be. These two configurations make it more explicit what they are supposed to be, and can be used to replicate the behavior of Maven's provided scope without mixed semantics. Coming back to annotation processors, for a while the compileOnly and testCompileOnly where the preferred choice for defining such dependencies, nowadays there are brand new configurations (annotationProcessor and testAnnotationProcessor) that take advantage of Gradle's build cache to provide better semantics and faster builds.

The test scope is also split into two: testCompile and testRuntime. This split grants you the capability to define dependencies with finer granularity, which is exactly the case we need. Thus our Gradle build file may look like this

build.gradle

apply plugin: 'java'

ext {
    log4jVersion = '1.2.17'
    slf4jVersion = '1.7.25'
    junitVersion = '4.12'
}

repositories {
    mavenCentral()
}

dependencies {
    compile     "org.slf4j:slf4j-api:$slf4jVersion"
    runtime     "org.slf4j:slf4j-log4j12:$slf4jVersion"
    runtime     "log4j:log4j:$log4jVersion"
    testCompile "junit:junit:$junitVersion"
    testRuntime "org.slf4j:slf4j-simple:$slf4jVersion"
}

There are a handful of ways for defining properties in a Gradle build (refer to Issue 001 of my newsletter for more) but decided to keep everything in one file thus the ext block was used here. Notice that the dependencies block defines all the required artifacts in the right scope as we previously discussed however tests still complain of multiple bindings as witnessed by the test reports

SLF4J: Class path contains multiple SLF4J bindings.
SLF4J: Found binding in [jar:file:/Users/aalmiray/.gradle/caches/modules-2/files-2.1/org.slf4j/slf4j-log4j12/1.7.25/110cefe2df103412849d72ef7a67e4e91e4266b4/slf4j-log4j12-1.7.25.jar!/org/slf4j/impl/StaticLoggerBinder.class]
SLF4J: Found binding in [jar:file:/Users/aalmiray/.gradle/caches/modules-2/files-2.1/org.slf4j/slf4j-simple/1.7.25/8dacf9514f0c707cbbcdd6fd699e8940d42fb54e/slf4j-simple-1.7.25.jar!/org/slf4j/impl/StaticLoggerBinder.class]
SLF4J: See http://www.slf4j.org/codes.html#multiple_bindings for an explanation.
SLF4J: Actual binding is of type [org.slf4j.impl.Log4jLoggerFactory]

Fortunately Gradle lets us update configurations in many ways, for our particular case we must find a way to exclude log4j from testRuntime and guess what, that's exactly what we can do by updating the build file with the following snippet

configurations {
    testRuntime.exclude group: 'log4j',     module: 'log4j'
    testRuntime.exclude group: 'org.slf4j', module: 'slf4j-log4j12'
}

Et voilá! No longer do we have multiple Slf4j bindings when running tests, nor do we have multiple Slf4j bindings when running the code in production as well. It's worth noting that custom Gradle configurations can be created by anyone (plugin and application authors alike) and are not limited to the default configurations provided by the Java plugin (or the other language plugins supported by Gradle); the behavior of Gradle configurations is much richer than the behavior of Maven scopes. We can assert that dependencies are correctly configured by invoking the following commands

$ gradle dependencies --configuration=runtime

runtime - Runtime dependencies for source set 'main' (deprecated, use 'runtimeOnly ' instead).
+--- org.slf4j:slf4j-api:1.7.25
+--- org.slf4j:slf4j-log4j12:1.7.25
|    +--- org.slf4j:slf4j-api:1.7.25
|    \--- log4j:log4j:1.2.17
\--- log4j:log4j:1.2.17

$ gradle dependencies --configuration=testRuntime

testRuntime - Runtime dependencies for source set 'test' (deprecated, use 'testRuntimeOnly ' instead).
+--- org.slf4j:slf4j-api:1.7.25
+--- junit:junit:4.12
|    \--- org.hamcrest:hamcrest-core:1.3
\--- org.slf4j:slf4j-simple:1.7.25
     \--- org.slf4j:slf4j-api:1.7.25

You may be wondering, what happened to the system scope mapping? Is it possible to have something like that in Gradle? Of course it is! Remember that system is a combination of provided plus a local path in such a way that the artifact is referenced directly from that path instead of being resolved by a repository. Gradle lets you define multiple types of repositories, one of them being flatDir, which can be used with any configuration you like, thus giving you more flexibility than the limited system scope found in Maven.

TL;DR: Gradle configurations deliver finer granularity for defining compilation & execution classpaths than Maven scopes do.

Liked it? Take a second to support aalmiray on Patreon!
Become a patron at Patreon!

Leave a Reply

Your email address will not be published. Required fields are marked *

This site uses Akismet to reduce spam. Learn how your comment data is processed.

ˆ Back To Top