Comparing Mill vs Maven: Declarative Builds

The Maven build tool is famous for pioneering the idea of "Declarative Builds": that you should only need to specify what your project needs, and the build tool should be able to get everything downloaded and cached and working on your behalf. However, although that is an excellent ideal to strive towards, in practice there are many ways where using Maven can be annoyingly tedious.

This page discusses some of the key ways that the Mill build tool learns from Maven, and also how Mill tries to improve upon some of its rough edges. This lets Mill provide a user experience that improves upon what Maven can provide to developers, letting the simplicity of "Declarative Builds" really shine through.

Where Maven Falls Short

Consider a small Java program such as the one below:

HtmlScraper.java

import org.jsoup.Jsoup;
import java.util.*;

public class HtmlScraper {
  static List<String> fetchLinks(String title) throws Exception {
    var url = "https://en.wikipedia.org/wiki/" + title;
    var doc = Jsoup.connect(url).header("User-Agent", "My Scraper").get();
    var links = new ArrayList<String>();
    for (var a : doc.select("main p a")) {
      var href = a.attr("href");
      if (href.startsWith("/wiki/")) links.add(href.substring(6));
    }
    return links;
  }

  public static void main(String[] args) throws Exception {
    if (args.length < 2) throw new Exception("HtmlScraper.java <start> <depth>");
    var seen = new HashSet<>(Set.of(args[0]));
    var current = new HashSet<>(Set.of(args[0]));
    for (int i = 0; i < Integer.parseInt(args[1]); i++) {
      var next = new HashSet<String>();
      for (String article : current) {
        for (String link : fetchLinks(article)) {
          if (seen.add(link)) next.add(link);
        }
      }
      current = next;
    }
    for (String s : seen) System.out.println(s);
  }
}

This program is a single-file Java application that uses the Jsoup third-party library to scrape links from a Wikipedia page, and then uses that in turn to do a breadth-first search on the graph of Wikipedia articles and links. This is representative of the many small programs that get written in practice:

  • Students doing a homework assignment

  • Developers trying to automate a common workflow

  • Someone trying to reproduce and minimize a bug in a library so that it can be reported

This is a simple program that any programmer should be able to write without difficulty. In theory, there should be no reason for developers not to write small automation scripts and utilities in Java rather than Python or Bash: it’s an easy widely-used language with an excellent runtime and a rich ecosystem with high-quality libraries (such as Jsoup above). But in practice, setting up your single-file Java program to actually be run proves quite a hurdle. This article will discuss why.

Installing Java

To run your Java program, you first might need to install Java. This may seem obvious and straightforward, but in practice it can turn out to be surprisingly tricky. Consider this discussion on Installing Java in 2025:

+1 sdkman! is awesome. I’ve been using sdkman! for a decade to manage Java installations. Doesn’t seem to work on freebsd though, which I have to use freebsd’s pkg instead.

Avoid Oracle’s builds unless you need them for compliance reasons

If you are on Linux or mac, getting your sdk via your favorite package manager is fine for casual use. Just be aware that this might not necessarily work with all software projects you want to work on. If that matters to you, that’s what sdkman is for.

On macOS I wrote my own 9-line Zsh function that lists the JDKs available and sets JAVA_HOME.

On Windows, the only sane way to install Java seems to be scoop.sh or chocolatey

I just don’t see the point in installing a version manager specifically for the JDK.

I would say as well as using SDKMan, you should use Jenv shell plugin to easily manage your JDK versions across projects

Even among experienced developers, it is clear there is no consensus on how Java should be installed: it varies between JDK distributions, between operating systems, and between different users' personal preference. Although a new Java developer might not need to understand the breadth and depth of Java installation methods to get something working, there’s a non-trivial number of decisions that need to be made and a non-trivial number of ways things can go wrong.

Maybe you got things working on your Mac-OSX laptop, but can you ensure the project works when run on your colleague’s Windows-11 desktop machine, or your Linux backend servers? And can you keep things working as versions need to be upgraded, or you have multiple projects requiring differing versions of Java? Just "installing Java" turns out to be quite a tricky thing to get right!

Setting up your pom.xml

If our simple Java program had no third-party dependencies, then we could just run it from the command-line via:

> java HtmlScraper.java

However, the program does use a third-party dependency - Jsoup - and so running it is no longer so simple: we now need a build tool! The most common build tool in the Java ecosystem is Maven, and so you may want to set up a Maven pom.xml for the project.

CONSIDER: are you able to write out the pom.xml for this project without copy-paste cargo-culting something from Stackoverflow or similar?

In practice, even experienced developers are not able to write pom.xmls by hand. They’re simply too verbose with too much irrelevant boilerplate that you don’t actually care about. Here’s an example pom.xml for the above project:

pom.xml

<project xmlns="http://maven.apache.org/POM/4.0.0"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">
  <modelVersion>4.0.0</modelVersion>
  <groupId>example</groupId>
  <artifactId>html-scraper</artifactId>
  <version>1.0</version>
  <dependencies>
    <dependency>
      <groupId>org.jsoup</groupId>
      <artifactId>jsoup</artifactId>
      <version>1.17.2</version>
    </dependency>
  </dependencies>
  <build>
    <plugins>
      <plugin>
        <groupId>org.codehaus.mojo</groupId>
        <artifactId>exec-maven-plugin</artifactId>
        <version>3.2.0</version>
        <configuration>
          <mainClass>HtmlScraper</mainClass>
        </configuration>
      </plugin>
    </plugins>
  </build>
</project>

Although copy-pasting something from Stackoverflow (or from an existing project, from ChatGPT, from Gemini, etc.) does get things working in the short term, longer term the boilerplate does have costs that may not be immediately obvious:

  • When you need to update the pom.xml later, splicing together multiple copy-pasted blobs of XML can be a lot more error-prone than copy-pasting things the first time

  • Although modern AI assistants like ChatGPT or Gemini are able to modify the code on your behalf, they sometimes make mistakes or hallucinate. Will you be able to catch these errors in the reams of boilerplate?

  • When the build tool is misbehaving because something is misconfigured, and your AI assistant isn’t able to diagnose the problem (maybe they’re the one who made the mistake in the first place!), would you be able to debug it and figure out how to fix things?

But let’s assume we have our pom.xml written, we next need to install Maven to use it:

Installing Maven

Next we need to install Maven. From the Maven docs, there are a bunch of different commands needed for different operating systems or Linux distributions.

> brew install maven
> sdk install maven
> sudo port install maven
> sudo apt install maven
> sudo dnf install maven
> sudo yum install maven
> choco install maven
> scoop install maven

This itself isn’t a huge problem, but even after installing Maven on your machine, there are questions remaining:

  1. Did we install the right version of Maven? Not every project builds on every version of Maven, and the same command above may install a different version of Maven on different operating system versions. Figuring out what Maven version our project needs and figuring out the command-line syntax to make our specific package manage install it can be a challenge

  2. To solve the right-maven-version problem, many projects these days use ./mvnw bootstrap scripts as a best practice. Should you be using that instead? And if you do, you’d still find people using mvn rather than ./mvnw out of muscle memory and getting confusing errors

Similar to the challenges of Installing Java, installing Maven once isn’t hard, but installing Maven consistently on your Mac laptop, or your colleague’s Windows desktop, and on your Linux CI machines can be quite tricky! And trickier still is maintaining these installations as the different development environments inevitable evolve over time.

Running your Java program

Finally, now that we have our HtmlScraper.java written, java installed, our pom.xml set up, and mvn (or ./mvnw) installed, we need to run our Java application.

CONSIDER: can you remember the command to run this Java application from the command line?

It’s not

> mvn run Java 1

Neither is it

> mvn HtmlScraper.java Java 1

Or

> mvn run HtmlScraper.java Java 1

Rather, the correct invocation is:

> mvn exec:java -Dexec.args="Java 1"
Ptolemy
Bali
Greater_Sunda_Islands
Rama
Sulaiman_al-Tajir
Endemism
Gajah_Mada
Yingya_Shenglan
Mahabharata
Cirebon
Ethnic_groups_in_Indonesia
...

Maven experts should be able to write this out from memory without issues, but it is surprisingly involved for someone who just wants to run a single HtmlScraper.java file containing their code! Many experienced Java developers probably can’t do this without looking it up first.

Whither Declarative Builds?

Although Maven is the first "declarative" build tool, in practice it has a lot of friction that has nothing to do with its declarative nature, which gets in the way of the smooth user experience a declarative build tool should be able to provide. From installing Java and Maven to setting up the verbose pom.xml and the unusual command-line invocation syntax, none of this is rocket science. But it adds enough friction to our HtmlScraper.java that people prefer to write their small programs in Python, Javascript.

This is not for any fault of Java the language: while Java is a bit more verbose than Python or Bash, it is fine for writing this particular HtmlScraper.java program. Java may even be preferable when the time comes to add parallelism and concurrency and other features that may be harder to implement in Python or Bash. The problem here is really that Java build tools: HtmlScraper.java is simple enough anyone could write it, but making sure HtmlScraper.java runs consistently on any machine you put it on can be surprisingly difficult.

Mill’s Simpler Declarative Builds

Because all of the issues above are with Java’s tooling rather than the language itself, the Mill build tool is able to streamline these things considerably.

Mill Bootstrap Scripts

Mill is primarily installed via a ./mill bootstrap script, similar to ./mvnw or ./gradlew scripts common with those build tools. Although it may look similar to what Maven or Gradle provide, Mill’s approach has several advantages that may not be obvious at a glance:

  1. ./mill is the standard way to install Mill. Any Mill project you check out should be runnable via ./mill without needing to first figure out how the project expects you to set up your machine.

  2. The ./mill bootstrap script downloads and caches not just Mill itself, but also the Java runtime needed to run your project. In general, Mill projects do not need Java to be pre-installed at all, and can build and run on any clean Windows, Mac, or Linux machine without prior setup

Mill does not rely on a pre-installed Java runtime by default, and instead manages its own JVM by downloaded and caching it on-demand. This defaults to zulu:21, or you can explicitly configure it in your build.mill.yaml file via:

jvmId: 24
jvmId: zulu:24
jvmId: temurin:24

You can easily try out different Java versions by changing the jvmId config key, and any Mill project can set jvmId to specify a consistent JVM version that is used regardless of where the project is being built. You can also have different projects or even different modules within the same project using different jvmIds, and Mill will download/cache/use them without you needing to worry about collisions.

With Mill, the Java version you use is managed declaratively: just like how Maven lets you declaratively configure the libraries that a project needs such as org.jsoup:jsoup:1.17.2, Mill lets you declaratively configure the jvmId the project needs and then handles any downloading and caching of those JVMs automatically for you.

Thus the ./mill bootstrap script is able to fix both the challenges Installing Java as well as Installing Maven. You don’t need to install Maven, you don’t need to install SdkMan or JEnv, you don’t even need to install Mill! Any Mill project you check out should be buildable and runnable using the ./mill bootstrap script without needing any prior installation, so you can immediately begin working on your project rather than fiddling with setup commands.

Mill Build Configuration

Mill’s build.mill.yaml declarative configuration files are much simpler than the Maven pom.xmls you may be used to writing. For example, for the HtmlScraper.java file shown above, the minimal configuration file looks like this:

build.mill.yaml

extends: JavaModule
mvnDeps:
- org.jsoup:jsoup:1.7.2

In this file,

  • extends tells us what class of modules this is, e.g. mill.javalib.JavaModule, mill.kotlinlib.KotlinModule, mill.scalalib.ScalaModule,

  • The other keys such as mvnDeps tell us configuration on that module we want to override

Mill follows Mavens principle of Convention over Configuration: although there are a lot of possible configuration options (mvnDeps, moduleDeps, repositories, javacOptions, etc.) the build.mill.yaml file only needs to contain the keys the user needs to customize. This greatly simplifies the common case where the user is mostly fine with the default configuration and avoids much of the complexity of Setting up your pom.xml.

In general, Mill’s build.mill.yaml config files are concise enough you don’t need to copy-paste reams of boilerplate to set them up, or skim through reams of boilerplate to read and understand and maintain them. They contain only the configuration that you care about, with every key listed and documented in the Mill API documentation for that particular module:

If you want to configure the jvmId, repositories, javacOptions, or any of the other normal build tool settings you can, but if you don’t need to configure them Mill makes it super easy to get started with reasonable defaults for everything.

Running Programs with Mill

When the time comes to running your program, with Mill it is simply:

> ./mill run Java 1
Ptolemy
Bali
Greater_Sunda_Islands
Rama
Sulaiman_al-Tajir
Endemism
Gajah_Mada
Yingya_Shenglan
Mahabharata
Cirebon
Ethnic_groups_in_Indonesia
...

This command-line syntax follows closely the syntax used in other runtimes:

> python foo.py <arg2> <arg2>
> bash foo.sh <arg2> <arg2>

mvn's command line syntax to run your Java code isn’t a blocker, but it is yet another small hurdle on top of the many other small hurdles. In contrast, Mill tries to follow the common command-line conventions used by most other programming languages and CLI tools, so that anyone who picks up a Mill project coming from other languages or ecosystems will feel right at home.

Java Single-File Scripts in Mill

Apart from Mill modules defined via a build.mill.yaml, Mill also allows you to write single-file "scripts" with their build configuration defined in a build header comment. For example, the HtmlScraper.java file above can be annotated with a //| build header comment as shown below:

//| mvnDeps:
//| - org.jsoup:jsoup:1.7.2
import org.jsoup.Jsoup;
import java.util.*;
public class HtmlScraper {
  static List<String> fetchLinks(String title) throws IOException {
    var url = "https://en.wikipedia.org/wiki/" + title;
    var doc = Jsoup.connect(url).header("User-Agent", "My Scraper").get();
    var links = new ArrayList<String>();
    for (var a : doc.select("main p a")) {
      var href = a.attr("href");
      if (href.startsWith("/wiki/")) links.add(href.substring(6));
    }
    return links;
  }
  public static void main(String[] args) throws Exception {
    if (args.length < 2) throw new Exception("HtmlScraper.java <start> <depth>");
    var seen = new HashSet<>(Set.of(args[0]));
    var current = new HashSet<>(Set.of(args[0]));
    for (int i = 0; i < Integer.parseInt(args[1]); i++) {
      var next = new HashSet<String>();
      for (String article : current) {
        for (String link : fetchLinks(article)) {
          if (seen.add(link)) next.add(link);
        }
      }
      current = next;
    }
    for (String s : seen) System.out.println(s);
  }
}

Mill single-file script modules can be run from the command line just like any Python or Bash script, via:

> ./mill HtmlScraper.java Java 1
Ptolemy
Bali
Greater_Sunda_Islands
Rama
Sulaiman_al-Tajir
Endemism
Gajah_Mada
Yingya_Shenglan
Mahabharata
Cirebon
Ethnic_groups_in_Indonesia
...

Mill scripts further simplifies the usage of Mill for small programs. Like Maven, Mill single-file scripts are fully declarative:

  • Any build configuration is set in the //| build header comment to specify what the file needs

  • Any downloading of dependencies or compilation of the .java file is scheduled, parallelized and cached automatically

  • You have full power to configure the script however you like: jvmId, repositories, javacOptions, etc.

But unlike Maven, Mill is able to streamline the process of taking your .java file and running it, such that the underlying simplicity of its declarative configuration really shines through in the user experience.

While Mill’s build.mill.yaml are already pretty easy to use, for small programs Mill’s single-file scripts with a //| build header are even easier to start with. And when the program does grow in size and complexity, more advanced use cases such as multi-module projects or custom build logic are well supported.

Conclusion: Mill vs Maven

Maven invented the idea of "declarative builds", which was a big step forward in a build-tool landscape dominated by tools like Bash or Ant. However, despite being declarative, Maven’s rough edges make getting started with Java troublesome and error-prone: from installing a Java runtime (the "right" way), to installing Maven (the "right" way), to cargo-cult copy-pasting a pom.xml and associated commands to run your program from the command-line.

None of these issues are blockers, but each of them is a hurdle enough to make someone re-consider whether they want to write their code in Java. Some may persevere and get things working, others will wrap all this messiness in their own collection of bash scripts and automation, and still others will end up deciding to write their small program in Python or Bash instead of Java.

Mill tries to learn from all the best-practices of Maven, but polishes the rough edges to provide a much smoother experience working with declarative builds. From the zero-setup installation process and declarative jvmId management, to the concise build.mill.yaml format and familiar command-line syntax, to support for single-file programs. Mill lets the key ideas of declarative shine through to provide an experience writing and running your Java programs that is every bit as smooth as running a Python or Bash script from the command line.

If you have ever worked with a Maven build you found it clunky to configure and clunky to use, do give Mill a try!