01 December 2021

Tags: gradle maven composition inheritance

Introduction

In general, when I read comments about Maven vs Gradle, I realize that people focus on the cosmetics (XML vs Groovy/Kotlin) when it’s from my point of view the least interesting aspect of the comparison. In this article, I want to focus on one particular aspect which differentiates the 2 build tools: the famous composition over inheritance paradigm. In different aspects (POM files, lifecycle), Apache Maven is using inheritance, while Gradle is using composition. It is a particularly important difference which completely changes the way we think about building software.

Inheritance in Maven builds

A typical Maven project is built with a pom.xml file, which declares everything the module needs: - the dependencies - the build plugins and their configuration

Very quickly, it turns out that there are common things that you want to share between modules:

  • they would use the same compiler options

  • they would use the same plugins and configuration

  • they would apply a number of common dependencies

  • etc.

Let’s imagine that we have a project which consists of 3 modules: - a library module, pure Java - an application module which uses the library and the Micronaut Framework - and a documentation module which provides a user manual for the application using Asciidoctor.

The idiomatic way to solve the problem of sharing the configuration of the library and application modules (which are both Java) in Maven is to define a so-called "parent POM" which declares all of these common things, for example:

<project xmlns="https://maven.apache.org/POM/4.0.0" xmlns:xsi="https://www.w3.org/2001/XMLSchema-instance"
    xsi:schemaLocation="https://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>

    <groupId>org.example</groupId>
    <artifactId>example-parent</artifactId>
    <version>1.0.0-SNAPSHOT</version>
    <packaging>pom</packaging>

    <name>Common Config</name>

    <properties>
        <java.version>1.8</java.version>
        <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
        <junit.jupiter.version>5.8.1</junit.jupiter.version>
    </properties>

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

    <build>
        <plugins>
            <plugin>
                <groupId>org.apache.maven.plugins</groupId>
                <artifactId>maven-surefire-plugin</artifactId>
                <version>3.0.0-M5</version>
            </plugin>

            <plugin>
                <groupId>org.apache.maven.plugins</groupId>
                <artifactId>maven-compiler-plugin</artifactId>
                <version>3.8.1</version>
                <configuration>
                    <source>${java.version}</source>
                    <target>1.8</target>
                </configuration>
            </plugin>
        </plugins>
    </build>
</project>

To simplify things, we could call this a "convention": by convention, all modules which will use this parent POM will apply all those plugins and dependencies (note, there are subtleties if you use <pluginManagement> or <dependencyManagement>).

A "child POM" like our application pom only has to declare the parent to "inherit" from it:

<project xmlns="https://maven.apache.org/POM/4.0.0" xmlns:xsi="https://www.w3.org/2001/XMLSchema-instance"
    xsi:schemaLocation="https://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>

    <parent>
        <groupId>org.example</groupId>
        <artifactId>example-parent</artifactId>
        <version>1.0.0-SNAPSHOT</version>
    </parent>

    <artifactId>application</artifactId>

    <dependencies>
        <dependency>
            <groupId>org.example</groupId>
            <artifactId>library</artifactId>
            <version>1.0.0-SNAPSHOT</version>
        </dependency>
    </dependencies>

</project>

This model works really well when all modules have a lot in common. The inheritance model also makes it simple to override things (child values override parent values). In the example above, we don’t have to specify the groupId and version of our module because it will be inherited from the parent.

However, this model comes with a number of drawbacks:

  • as soon as you have different modules which share different set of dependencies, or use different sets of plugins, you have to create different parents and have an inheritance model between parents. Unfortunately this is the case here, since only our library and application modules have something in common. It won’t be a surprise for many that you have to exclude dependencies just because they came through parent poms…​

  • you can only have a single parent, meaning that you cannot inherit from a framework parent POM and from your own conventions.

  • it’s not great for performance, because you end up configuring a lot of things which will never be necessary for your particular "child" module.

  • overriding values is sometimes much more complicated and you have to start relying on obscure syntaxes like combine.children="append" (see this excellent blog post for details).

Those limitations are quickly reached when you are using a framework like Micronaut or Spring Boot. Because those frameworks are built with developer productivity in mind, they come with their own "parent POMs" which makes the lives of developers easier by avoiding copy and paste of hundreds of lines of XML. They also need to provide this parent POM because they would come with their own Maven plugin which works around the limitations of the lifecycle model.

But then, we have a problem: on one side, you have this "parent POM" which is provided by the framework, and on the other side, you have your own "parent POM" which is providing, say, the company-specific conventions (like checkstyle configuration, coordinates of Maven repositories for publication, etc.).

In order to be able to use both conventions, you have to create a new parent POM, and you have no choice but writing your company convention parent POM inheriting from the framework POM: obviously you can’t change the framework POM itself! This is problematic, because it means that for every release of the framework, you have to update your company convention parent POM. This is also problematic for another aspect: not all the modules of your multi-project build are "Spring Boot" or "Micronaut" applications. Some of them may be simple Java libraries which are used by your app, but do not require the framework. As a consequence, you have to create multiple parents, and duplicate the configuration in each of those POM files.

This inheritance problem surfaces in different places in Maven. Another one is, as I mentioned, the "lifecycle" which works in phases. Basically, in Maven everything is executed linearly: if you want to do install, then you have to execute everything which is before that phase, which includes, for example, test. This may sound reasonable, but this model completely falls apart: this is no surprise that every single plugin has to implement their own -DskipTest variant, in order to avoid doing work which shouldn’t be done. I had users@maven.apache.org:2021-9">an interesting use case when implementing the GraalVM native Maven plugin, which requires to configure the surefire plugin to pass extra arguments. Long story short: this isn’t possible with Maven. Consequence: the only workaround is the multiplication of Maven profiles, which a user has to understand, maintain, and remember.

Composition in Gradle builds

Gradle builds use a very different model: composition. In a nutshell, in a Gradle project you don’t explain how to build, but what you build: that is, you would say "this is a library", or "this is a CLI application" or "this is a documentation module". Because a library exposes an API and an application doesn’t, those are different things, so their conventions, and capabilities, are different.

The way you "say" this is in a Gradle build is by applying plugins.

A typical Java library would apply the java-library plugin, while an application would apply the application plugin and a documentation project would apply, say, the asciidoctor plugin. What do a Java library project and a documentation project have in common? Barely nothing. A Java Library has Java sources, a number of dependencies, code quality plugins applied, etc. The documentation module, on its side, is a set of markdown or asciidoc files, and resources. The layout of the projects is different, the conventions are different, and the set of plugins are different. Java projects may share the same conventions for source layout, but they are obviously different for the docs. In addition, there’s no reason to let the user declare "implementation" dependencies on the documentation project: it doesn’t make sense so it should be an error to do so.

On the other hand all those modules may share a number of things:

  • they are all published to a Maven repository

  • they need to use the same Java toolchain

  • they need to comply to security policies of your company

The way Gradle solves this problem is by composing plugins:

  • a plugin can "apply" another plugin

  • each plugin is guaranteed to be applied only once, even if several plugins use it

  • a plugin can "react" to the application of other plugins, allowing fine-grained customizations

So in the example above, the application use case can be easily solved: first, you’d have your own "convention plugin" which defines your company conventions (e.g apply the checkstyle plugin with a number of rules). Then, you’d have the Micronaut application plugin which is already written for you. Finally, your application module would simply apply both plugins:

plugins {
   id 'com.mycompany.conventions' version '1.0.0'
   id 'io.micronaut.application' version '3.0.0'
}

micronaut {
    version '3.2.0'
}

What becomes more interesting is that you can (and you actually should) create your own "component types" which apply a number of plugins. In the example above, we could replace the use of the 2 plugins with a single one:

plugins {
   id 'com.mycompany.micronaut-application' version '3.0.0'
}

Note how we moved the configuration of the micronaut version to our convention plugin. I’m not going to explain how to write a custom Gradle plugin in this blog post, but the code of this plugin would very much look like this:

plugins {
    id 'com.mycompany.conventions' version '1.0.0'
    id 'io.micronaut.application' version '3.0.0'
}

micronaut {
    version '3.2.0'
}

Does it look familiar? Yes it does, this is exactly what we had in the beginning: composition is slowly happening! I encourage you to take a look at this documentation for further details about writing your own convention plugins.

Interestingly, as I said, Gradle plugins are allowed to react to the presence of other plugins. This makes it particularly neat for defining dynamically more tasks depending on the context. For example, a plugin can do:

pluginManager.withPlugin('io.micronaut.application') {
    // configure the Micronaut application plugin
}
pluginManager.withPlugin('io.micronaut.library') {
    // configure the Micronaut library plugin
}
pluginManager.withPlugin('io.spring.boot') {
    // configure the Spring Boot plugin
}

Which is very resilient to the fact that the plugins may be applied in any order and that they can combine with each other to provide higher level constructs. It also makes it possible to give choice to users regarding their preferences: you provide a single convention plugin which is aware of what to do if the user prefers to use Spring Boot over Micronaut.

In the end, com.mycompany.micronaut-application is defined as a combination of the io.micronaut.application, your.company.conventions plugins. Instead of declaring how to build your company application, you simply described what it is.

This is only touching the surface of the Gradle world here, but when I read that Gradle is "just Ant on steroids", nothing could be more wrong. Gradle in this case is much superior, because it focuses on convention over configuration, while providing better constructs than Maven does for it.

But let’s come back to our multi-project example: each of the modules would apply a different convention plugin (which is also why it’s important that the allprojects pattern dies):

  • library would apply the com.mycompany.library plugin

  • application would apply the com.mycompany.application plugin

  • docs would apply the com.mycompany.docs plugin

The com.mycompany.library plugin would, for example, apply the java-library and com.mycompany.java-conventions plugin. The com.mycompany.application plugin would, for example, apply the io.micronaut.application and com.mycompany.java-conventions plugin (knowing that the io.micronaut.application plugin applied the application plugin and more, such as the GraalVM plugin) The com.mycompany.docs plugin would, for example, apply the org.asciidoctor.jvm.convert plugin and the com.mycompany.docs plugin.

You’ll notice how those actually combine together, making it easier to maintain and upgrade builds: should you change the company conventions, all you have to do is release a new version of the convention plugin.

Conclusion

In this quickie, I have explained a major difference in how Maven and Gradle envision build configuration. While both of them are designed with convention over configuration in mind, the inheritance model of Maven makes it difficult to build conventions on top of each other without duplication. On the other hand, Gradle uses a composition model which makes it possible to design your own conventions while being aware of other plugins being applied by the user: Gradle builds are more lenient and more maintainable.

As a complement, you might be interested in: