Pom Pom Pom 🎶 - Have we reached the end of life?
Posted on 2025-06-12

Brief history of POM file proliferation

Maven build system uses .pom (POM) files for both defining how a library should be built and as metadata to consumers of the library such as a list of dependent libraries. The Maven build system and thus the POM files take a largely Java/JVM-centric view where the default artifact’s and dependencies’ format is a .jar (JAR).

Maven POM file popularity has lead to other build systems, such as Gradle, to build adapter layers for the publishing and the consumption of already published Java libraries. That means that Gradle is able to parse and interpret POM files on Maven artifact servers (such as MavenCentral) and use them regardless of the build system used to build those artifacts. Similarly, when publishing Java libraries, Gradle is able to generate POM files from Gradle’s own build.gradle.kts build definition files allowing other users to consume these libraries. POM files that were originally a Maven build system specific format turned into a very valuable compatibility layer / de facto standard for JVM build systems to inter-op with each other, thus allowing JVM ecosystem to flourish.

Sadly, POM format is not a perfect solution, and this post will share a few reasons why it might be the case.

Assumption about a single artifact

POM files can only describe a single artifact for a given Maven coordinate (for example com.example:example:1.0.0) and by default that artifact is a JAR (example-1.0.0.jar), unless specified otherwise via entry like <packaging>aar</packaging>. This is good enough for a large number of JVM use-cases, but there are some use-cases that this does not work with:

  • debug vs release versions
  • compile vs runtime versions
  • per-CPU architecture versions
  • JVM target versions (Java 8 vs Java 21)
  • JVM vs Android compatible

Given you can only have a single artifact, library publishers are forced to pick a default version, which does not always work, e.g. it might be hard to pick an artifact that works for every OS.

One workaround for this is using classifiers that allows you to specify com.example:example:1.0.0:android. This notation allows consumers to resolve example-1.0.0-android.jar instead the default example-1.0.0-android.jar. This works until you want to publish another library com.other:other that depends on this existing library. There is only a single <dependencies> block in the POM file, so when com.other:other adds an entry for com.example:example it has to pick specify <classifier>android</classifier> or it will default to the main artifact. That means someone using com.other:other can either have it working for JVM or Android, but not both.

Another approach might be to encode information in the artifact version. For example, you could have com.example:example:1.0.0-jvm and com.example:example:1.0.0-android. User can choose the right one and have it work. This even is able to avoid the issue with the classier approach as com.other:other:1.0.0-jvm can depend on com.example:example:1.0.0-jvm and com.other:other:1.0.0-android can depend on com.example:example:1.0.0-android. Sadly, this hits a big stumbling block when you have multiple versions being pulled in by different dependencies. By default, Maven will result to the highest version between the dependencies, e.g. if you have 1.0.0-android and 1.0.0-jvm it will actually pick 1.0.0-jvm as -j is alphabetically newer than -a. This requires consumers to do careful version management to get the correct versions.

Yet another way could be to split your artifacts to use multiple Maven coordinates com.example:example-jvm and com.example:example-android. Similar to the approach that uses versions, this requires every library that builds on top of this library to publish using multiple Maven coordinates as well: com.other:other-jvm:1.0.0 and com.other:other-android:1.0.0, but unlike that approach, it is a lot more obvious when the wrong artifact is selected as there is no automatic version resolution involved, as you can see if -jvm or -android artifacts are used.

All the approaches above require flattening the resolution of the dependencies, namely every library dependency that is added requires to match the splits of every other dependency. This results in a combinatorial explosion of all the options when you consider all the various use-cases listed above (e.g. CPU architecture and JVM vs Android).

To make matters worse, it also means whenever a low level library needs to introduce multiple artifacts, every library that builds on top of it needs to re-release to support it. This gets very complex as the depth of dependencies increases.

A Better Way?

Gradle recognized these POM file limitations and chose to introduce Gradle Module Metadata .module (MODULE) files to support richer artifact publishing and consumption. It introduces a concept of attributes that allows publishers to describe multiple variants of the artifact, for example "org.gradle.jvm.environment": "android" vs "org.gradle.jvm.environment": "standard-jvm" and then the consumers provide these attributes to resolve the correct artifact, while user only needs to specify com.example:example:1.0.0 and it just works. Gradle puts these MODULE files alongside the POM files to support non-Gradle consumers, but for cases where the default is hard to pick, we get back to the issue described earlier.

Android Gradle Plugin (AGP) and Kotlin Gradle Plugin (KGP) (especially for Kotlin multiplatform) uses MODULE files to express the variants of the artifacts produced using these plugins and users that use Gradle have it just work. Note, I do want to acknowledge that when resolution fails, the debugging of attribute selection is a miserable experience.

What about non-Gradle build systems?

This gets us to the crux of the problem. POM parsing and resolution is relatively simple to implement and thus many build systems (e.g. Bazel) have built adapters to fetch artifacts for a Maven coordinate. Sadly, MODULE file parsing and resolution requires either executing Gradle or painstakingly re-implementing and keeping up-to-date with a specification. The resolution logic is scatted between Gradle core and third-party plugins such as AGP and KGP, where these plugins keep changing their attribute set and compatibility rules.

Bazel, for example, seems to be headed in a direction of using Gradle in an isolated process, but it is not clear if this will merge and what the performance characteristics of this system will be.

In the meantime, POM user experience keeps getting worse due to the issues describe in this post, as well as a variety of bugs in Gradle and KGP. Have we reached the end of life for POMs?