= Comparing Mill vs Maven: Declarative Builds

The xref:comparisons/maven.adoc[Maven] build tool pioneered 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`

```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 https://jsoup.org/[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 https://news.ycombinator.com/item?id=46037261[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:

```console
> 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.

**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.xml``s 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`
```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 obvious up front:

- 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 too! 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

From the https://maven.apache.org/install.html[Maven docs], there
are a bunch of different commands needed for different operating systems or Linux distributions.

```console
> 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
https://maven.apache.org/tools/wrapper/maven-wrapper-plugin/usage.html[./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 due to version mismatches

Similar to the challenges of xref:#_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 various 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.

**Can you remember the command to run this Java application from the command line?**

It's not

```console
> mvn run Java 1
```

Neither is it

```console
> mvn HtmlScraper.java Java 1
```

Or

```console
> mvn run HtmlScraper.java Java 1
```

Rather, the correct invocation is:

```console
> 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?

Maven is one of the first "declarative" build tools, and it gets a lot of things right:
automatic dependency resolution, downloading, caching, parallelization, and so on.
But it also 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 or Javascript instead.

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, perform CPU intensive work, or make use of specialized Java libraries.
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 Build Configuration

Mill's `build.mill.yaml` xref:javalib/intro.adoc#_declarative_configuration[declarative configuration
files] are much simpler than the Maven ``pom.xml``s 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`
```yaml
extends: JavaModule
mvnDeps:
- org.jsoup:jsoup:1.7.2
```

In this file,

- `extends` tells us what class of modules this is, e.g.
`JavaModule`, `KotlinModule`, `ScalaModule`,
- The other keys such as `mvnDeps` tell us configuration on that module we want to override

Mill's declarative `build.mill.yaml` config files are not limited to the trivial examples
like the one above, but support the full range of features that any build tool does. This can
be seen in the more heavily-configured example below:

`build.mill.yaml`
```yaml
extends: [mill.javalib.JavaModule] # fully-qualified class name
mvnDeps:
- org.thymeleaf:thymeleaf:3.1.1.RELEASE
- org.slf4j:slf4j-nop:2.0.7

# Add a custom maven repository URL
repositories: [https://oss.sonatype.org/content/repositories/releases]

# Set an explicit main class
mainClass: foo.Foo2

# Pin a specific JVM version, supports >=11
jvmVersion: 11
# Optionally specify the distribution: temurin, zulu, corretto, graalvm-community, etc.
# jvmVersion: temurin:11

# Add an additional source folder and resource folder, in addition to the default one.
# Note the !append tag to append to the existing value rather than over-writing it
sources: !append [custom-src/]
resources: !append [custom-resources/]

# Configure java compiler and runtime options and env vars
javacOptions: [-deprecation]
forkArgs: [-Dmy.custom.property=my-prop-value]
forkEnv: { MY_CUSTOM_ENV: my-env-value }
```

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 xref:#_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:

- {mill-doc-url}/api/latest/mill/javalib/JavaModule.html#mvnDeps-0[mill.javalib.JavaModule]

If you want to configure the `jvmVersion`, `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.


=== Mill Bootstrap Scripts

Mill is primarily installed via a `./mill`
xref:cli/installation-ide.adoc#_bootstrap_scripts[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 `{default-mill-jvm-version}`,
or you can explicitly configure it in your `build.mill.yaml` file via:

```yaml
jvmVersion: 24
jvmVersion: zulu:24
jvmVersion: temurin:24
```

You can easily try out different Java versions by changing the `jvmVersion` config key,
and any Mill project can set `jvmVersion` 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 ``jvmVersion``s,
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 `jvmVersion` the project needs and then handles any downloading and caching of those JVMs
automatically for you.

The `./mill` bootstrap script is thus able to fix both the challenges
xref:#_installing_java[Installing Java] as well as xref:#_installing_maven[Installing Maven].
You don't need to install Maven, you don't need to install https://sdkman.io/[SdkMan] or
https://www.jenv.be/[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.


=== Running Programs with Mill

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

```console
> ./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:

```console
> 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:

```java
//| 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:

```console
> ./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
- You can configure the script however you like: `jvmVersion`, `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 https://www.gnu.org/software/bash/[Bash]
or https://ant.apache.org/[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 `jvmVersion` 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 xref:mill::javalib/intro.adoc[give Mill a try]!