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.