Building Java with Mill

Mill supports three main ways of defining Java projects:

This page walks through a series of Mill builds of increasing complexity to show you the key features and usage of the Mill build tool. The other pages of this section of the docs go into more depth into individual Mill features: testing, publishing, and so on. Every example can be downloaded to try it yourself.

The API reference for Mill’s Java toolchain can be found at mill.javalib

Simple Config-Based Modules

build.mill.yaml (download, browse)
extends: [mill.javalib.JavaModule]
mvnDeps:
- "net.sourceforge.argparse4j:argparse4j:0.9.0"
- "org.thymeleaf:thymeleaf:3.1.1.RELEASE"
test/package.mill.yaml (download, browse)
extends: [build.JavaTests, mill.javalib.TestModule.Junit4]
mvnDeps:
- "com.google.guava:guava:33.3.0-jre"

This is a basic Mill build for a single JavaModule, with two third-party dependencies and a test suite using the JUnit framework. This example project uses two third-party dependencies - ArgParse4J for CLI argument parsing, Thymeleaf for HTML rendering - and uses them to wrap a given input string in HTML templates with proper escaping.

Basic Folder Layout

The source code for this module lives in the src/ folder. Output for this module (compiled files, resolved dependency lists, ...​) lives in out/. A typical filesystem layout is shown below:

build.mill.yaml
src/
    foo/Foo.java
resources/
    ...
test/
    package.mill.yaml
    src/
        foo/FooTest.java
out/
    compile.json
    compile.dest/
    ...
    test/
        compile.json
        compile.dest/
        ...
The default Mill source folder layout src/ differs from that of Maven/Gradle’s src/main/java/. If you wish to use the Maven source folder layout, e.g. for migrating an existing codebase, you should use Maven-Compatible Modules

Typical Usage

Typical usage from the command line is shown below:

> ./mill resolve _ # List what tasks are available to run
assembly
...
compile
...
run
...
> ./mill inspect compile # Show documentation and inputs of a task
compile(JavaModule.scala:...)
    Compiles the current module to generate compiled classfiles/bytecode.
Inputs:
    upstreamCompileOutput
    allSourceFiles
    compileClasspath
> ./mill compile # compile sources into classfiles
...
compiling 1 Java source to...
> ./mill test
...
Test foo.FooTest.testSimple finished...
Test foo.FooTest.testEscaping finished...
> ./mill run --text hello
<h1>hello</h1>
> ./mill assembly # bundle classfiles and libraries into a jar for deployment

> ./mill show assembly # show the output of the assembly task
".../out/assembly.dest/out.jar"

> java -jar ./out/assembly.dest/out.jar --text hello
<h1>hello</h1>

> ./out/assembly.dest/out.jar --text hello # mac/linux
<h1>hello</h1>

> # Note that on windows you need to rename `out.jar` to `out.bat` to run it without `java -jar`
> cp ./out/assembly.dest/out.jar out.bat # windows

> ./out.bat --text hello # windows
<h1>hello</h1>

The output of every Mill task is stored in the out/ folder under a name corresponding to the task that created it. e.g. The assembly task puts its metadata output in out/assembly.json, and its output files in out/assembly.dest. You can also use show to print out the metadata output for a particular task, or inspect to print metadata about the task itself (docs, source location, etc.)

Additional Mill tasks you would likely need include:

> ./mill resolve __ # recursively list all tasks and modules that are available

> ./mill runBackground # run the main method in the background

> ./mill clean <task>  # delete the cached output of a task, terminate any runBackground

> ./mill launcher      # prepares a out/launcher.dest/run you can run later

> ./mill jar           # bundle the classfiles into a jar suitable for publishing

> ./mill -w compile    # watch input files and re-compile whenever a file changes

You can start a JShell REPL attached to your foo module via:

> ./mill -i jshell

You can also start a standalone JShell console via ./mill --jshell. This is useful if you just need a JShell console to experiment with at the command line without being attached to a particular project or module.

> ./mill --jshell

The most common tasks that Mill can run are cached tasks, such as compile, and un-cached commands such as run. Cached tasks do not re-evaluate unless one of their inputs changes, whereas commands re-run every time. See the documentation for Tasks for details on the different task types.

This example uses Mill’s config-based YAML syntax, which is a good fit for simple builds where you are just setting some configuration keys. This is discussed more in the documentation for:

Multi-Module Projects

This example contains a simple Mill build with two modules, foo and bar, defined by their respective foo/package.mill.yaml and bar/package.mill.yaml config files, and on which you can run tasks on such as foo.run or bar.run. There is also a bar.test module defined by bar/test/package.mill.yaml.

foo/package.mill.yaml (download, browse)
extends: [mill.javalib.JavaModule]
moduleDeps: [bar]
mvnDeps:
- net.sourceforge.argparse4j:argparse4j:0.9.0
bar/package.mill.yaml (download, browse)
extends: [mill.javalib.JavaModule]
mvnDeps:
- org.thymeleaf:thymeleaf:3.1.1.RELEASE
bar/test/package.mill.yaml (download, browse)
extends: [build.bar.JavaTests, mill.javalib.TestModule.Junit4]
mvnDeps:
- com.google.guava:guava:33.3.0-jre

Inter-module dependencies are defined by the moduleDeps key, and modules can also be nested within each other, as bar.test is nested within bar.

Multi-Module Folder Layout

The above builds expect the following project layout:

build.mill
foo/
    src/
        foo/Foo.java
bar/
    src/
        bar/Bar.java
    test/
        src/
            bar/BarTests.java
out/
    foo/
        compile.json
        compile.dest/
        ...
    bar/
        compile.json
        compile.dest/
        ...
        test/
            compile.json
            compile.dest/
            ...

Typically, source, output files, and task names in Mill follow the module hierarchy, so e.g. input to the foo module lives in foo/src/ and compiled output files live in out/foo/compile.dest, and that module is run via foo.run.

You can use mill resolve to list out what tasks you can run, e.g. mill resolve __.run below which lists out all the run tasks:

> ./mill resolve __.run
foo.run
bar.run

> ./mill foo.run --text "hello-world"
<h1>hello-world</h1>

> ./mill bar.test
...
...bar.BarTests...simple...
...bar.BarTests...escaping...

In general, for multi-module builds, every task is prefixed by its module name: foo.run, bar.assembly, bar.test.compile, etc. Mill will ensure that the specified tasks and their upstream dependencies are executed in the right order, and re-executed as necessary when source code in each module changes.

Task Query Syntax

You can use wildcards and brace-expansion to select multiple tasks at once or to shorten the path to deeply nested tasks. If you provide optional task arguments and your wildcard or brace-expansion is resolved to multiple tasks, the arguments will be applied to each of the tasks.

Table 1. Wildcards and brace-expansion

Wildcard

Function

_

matches a single segment of the task path

__

matches arbitrary segments of the task path

{a,b}

is equal to specifying two tasks a and b

You can use the + symbol to add another task with optional arguments. If you need to feed a + as argument to your task, you can mask it by preceding it with a backslash (\).

> ./mill bar._.compile # Runs `compile` for all direct sub-modules of `foo`

> ./mill bar.__.test # Runs `test` for all transitive sub-modules of `foo`

> ./mill {foo,bar}.compile # Runs `compile` for `foo` and `bar`

> ./mill __.compile + bar.__.test # Runs all `compile` tasks and all tests under `foo`.

For more details on the query syntax, check out the query syntax documentation

Maven Compatible Modules

Mill’s default folder layout of foo/src/ and foo/test/src differs from that of Maven or Gradle’s foo/src/main/java/ and foo/src/test/java/. If you are migrating an existing codebase, you can use Mill’s MavenModule and MavenTests as shown below to preserve filesystem compatibility with an existing Maven or Gradle build:

build.mill.yaml (download, browse)
extends: [mill.javalib.MavenModule]
mvnDeps:
- "net.sourceforge.argparse4j:argparse4j:0.9.0"
- "org.thymeleaf:thymeleaf:3.1.1.RELEASE"

object test:
  extends: [MavenTests, mill.javalib.TestModule.Junit4]
  mvnDeps:
  - "com.google.guava:guava:33.3.0-jre"

object integration:
  extends: [MavenTests, mill.javalib.TestModule.Junit4]
  mvnDeps:
  - "com.google.guava:guava:33.3.0-jre"

MavenModule is a variant of JavaModule that uses the more verbose folder layout of Maven, sbt, and other tools:

  • foo/src/main/java/

  • foo/src/test/java/

Rather than Mill’s

  • foo/src/

  • foo/test/src/

This is especially useful if you are migrating to Mill, as during the migration a particular module may be built using both Maven/Gradle and Mill at the same time. That means that during migration, you can leave all your source files in place while setting up your Mill build, and do not need to invasively move them around to match the Mill default module layout.

Although the source layout of these compatibility modules is different from the default JavaModule, the command-line usage is the same:

> ./mill compile
compiling 1 Java source...

> ./mill test.compile
compiling 1 Java source...

> ./mill test.testForked
...foo.FooTests.hello ...

> ./mill test
...foo.FooTests.hello ...

> ./mill integration
...foo.FooIntegrationTests.hello ...

For more details on migrating from other build tools, see Migrating to Mill

Single-File Scripts

Mill allows you to run single-file Java programs easily from the command-line, even those that contain third-party dependencies, specific JVM versions, or other such build configuration. Unlike Java programs built with other tools, Mill scripts run via a ./mill bootstrap script run reproducibly without needing any prior installation or setup. This can be useful for several purposes:

  • A replacement for Bash scripts: Mill instead lets you write small Java scripts or programs in Java with full access to third-party libraries that run in a reproducible way across diverse dev/test/prod environments

  • Self-contained examples or issue reproductions, as you can include both the code and dependencies necessary in a self-contained file that can be run using ./mill without manual setup or installation

For example the Java program below can be run directly using Mill, which will automatically download and cache the specified third-party dependencies as necessary:

Foo.java (download, browse)
//| jvmId: 11.0.28
//| mvnDeps:
//| - "net.sourceforge.argparse4j:argparse4j:0.9.0"
//| - "org.thymeleaf:thymeleaf:3.1.1.RELEASE"

import net.sourceforge.argparse4j.ArgumentParsers;
import net.sourceforge.argparse4j.inf.ArgumentParser;
import net.sourceforge.argparse4j.inf.Namespace;
import org.thymeleaf.TemplateEngine;
import org.thymeleaf.context.Context;

public class Foo {
  public static String generateHtml(String text) {
    Context context = new Context();
    context.setVariable("text", text);
    return new TemplateEngine().process("<h1 th:text=\"${text}\"></h1>", context);
  }

  public static void main(String[] args) throws Exception {
    ArgumentParser parser = ArgumentParsers.newFor("template")
        .build()
        .defaultHelp(true)
        .description("Inserts text into a HTML template");

    parser.addArgument("-t", "--text").required(true).help("text to insert");

    Namespace ns = null;
    ns = parser.parseArgs(args);
    System.out.println("Jvm Version: " + System.getProperty("java.version"));
    System.out.println(generateHtml(ns.getString("text")));
  }
}

Apart from the mvnDeps config that allows you to use third-party libraries in your script, the jvmId config lets you specify exactly what JVM version you wish to use in this script. Like other Mill modules, Mill scripts will default to a Mill’s own default JVM version of zulu:21 if a jvmId is not provided. If you want to use the environmentally installed java command available on your path, you must explicitly set jvmId: system.

Mill will automatically download and cache these artifacts on your behalf, allowing you run these scripts via ./mill and have them behave the same regardless of what environment it is running in without needing any setup.

This script can be run as shown below:

> ./mill Foo.java --text hello
compiling 1 Java source to...
<h1>hello</h1>
> ./mill Foo.java:run --text hello
<h1>hello</h1>

The ./mill Foo.java syntax is shorthand for ./mill Foo.java:run. You can also call other tasks on your script modules, such as Foo.java:assembly below:

> ./mill show Foo.java:assembly # show the output of the assembly task
".../out/Foo.java/assembly.dest/out.jar"

> java -jar ./out/Foo.java/assembly.dest/out.jar --text hello
<h1>hello</h1>

> ./out/Foo.java/assembly.dest/out.jar --text hello # mac/linux
<h1>hello</h1>

Java scripts support of the same configuration keys as Simple Config-Based Modules as part of their //| header comment, and support most of the same tasks via the :run :assembly etc. command-line syntax.

Testing Scripts

Script files can have test suites, usually written in a separate test script. The test script specifies what script it tests via moduleDeps, and can have its own mvnDeps in addition to those of the upstream script. The test script can then exercise functions from the upstream script as shown below:

FooTest.java (download, browse)
//| extends: [mill.script.JavaModule.Junit4]
//| moduleDeps: [Foo.java]
//| mvnDeps:
//| - "com.google.guava:guava:33.3.0-jre"

import static com.google.common.html.HtmlEscapers.htmlEscaper;
import static org.junit.Assert.assertEquals;

import org.junit.Test;

public class FooTest {
  @Test
  public void testSimple() {
    assertEquals(Foo.generateHtml("hello"), "<h1>hello</h1>");
  }

  @Test
  public void testEscaping() {
    assertEquals(Foo.generateHtml("<hello>"), "<h1>" + htmlEscaper().escape("<hello>") + "</h1>");
  }
}
> ./mill FooTest.java
Test FooTest.testSimple started
Test FooTest.testEscaping started

Again, you can pass the name of the task explicitly via :, e.g. :testForked below

> ./mill FooTest.java:testForked # specifying the test task explicitly
Test FooTest.testSimple started
Test FooTest.testEscaping started

The testing framework used in a script is defined by the class specified in the extends clause. The different testing frameworks supported in Testing Java Projects can be used for your scripts: Junit4, Junit5, TestNg, Munit, ScalaTest, Specs2, Utest, Weaver, ZioTest. If you need something not on this list, you can define a Custom Script Module Class.

For scripts that grow larger than a single file, you should convert them to Simple Config-Based Modules.

Programmatic Modules

build.mill (download, browse)
package build
import mill.*, javalib.*

object foo extends JavaModule {
  def mvnDeps = Seq(
    mvn"net.sourceforge.argparse4j:argparse4j:0.9.0",
    mvn"org.thymeleaf:thymeleaf:3.1.1.RELEASE"
  )

  object test extends JavaTests, TestModule.Junit4 {
    def mvnDeps = Seq(
      mvn"com.google.guava:guava:33.3.0-jre"
    )
  }
}

This is an example of Mill’s programmatic configuration syntax. This is slightly more verbose than the YAML syntax shown above, but in exchange allows more flexibility in how tasks are defined and their values are computed. Keys such as mvnDeps: in the YAML syntax correspond directly to the def mvnDeps methods in the programmatic syntax.

build.mill
foo/
    src/
        foo/Foo.java
    resources/
        ...
    test/
        src/
            foo/FooTest.java
out/foo/
    compile.json
    compile.dest/
    ...
    test/
        compile.json
        compile.dest/
        ...

This example places the ScalaModule in the foo/ subfolder, but you can also use define a Root Module to place it at the root of your repository.

Usage of programmatic Mill builds is similar to usage of simple configuration-based Mill builds:

> ./mill resolve foo._ # List what tasks are available to run
foo.assembly
...
foo.compile
...
foo.run
...
> ./mill foo.run --text hello
<h1>hello</h1>
> ./mill foo.test
Test foo.FooTest.testSimple finished, ...
Test foo.FooTest.testEscaping finished, ...
> ./mill foo.assembly # bundle classfiles and libraries into a jar for deployment

> ./mill show foo.assembly # show the output of the assembly task
".../out/foo/assembly.dest/out.jar"

> java -jar ./out/foo/assembly.dest/out.jar --text hello
<h1>hello</h1>

Programmatic Mill build files are written in Scala, but you do not need to have prior experience in Scala to read or write them. Like Gradle Groovy or Maven XML, anyone can learn enough Scala for Mill without needing to become an expert in the language. For simpler builds that do not need the flexibility that programmatic builds provide, YAML-based Config-Based Module definitions are a great alternative.

Custom Build Logic

Mill makes it very easy to customize your build graph, overriding portions of it with custom logic. In this example, we override the JVM resources of our JavaModule - normally the resources/ folder - to instead contain a single generated text file containing the line count of all the source files in that module

build.mill (download, browse)
package build
import mill.*, javalib.*

object foo extends JavaModule {

  /** Total number of lines in module source files */
  def lineCount = Task {
    allSourceFiles().map(f => os.read.lines(f.path).size).sum
  }

  /** Generate resources using lineCount of sources */
  override def resources = Task {
    os.write(Task.dest / "line-count.txt", "" + lineCount())
    super.resources() ++ Seq(PathRef(Task.dest))
  }
}
  • override def resources replaces the previous resource folder provided by JavaModule (labelled resources.super below), including both the previous resource folder super.resources() together with the Task.dest folder of the new task which contains a lint-count.txt file we write.

G allSourceFiles allSourceFiles lineCount lineCount allSourceFiles->lineCount resources resources lineCount->resources run run resources->run resources.super resources.super resources.super->resources
  • os.read.lines and os.write come from the OS-Lib library, which is one of Mill’s Bundled Libraries. You can also import any JVM library you want from Maven Central using //| mvnDeps, so you are not limited to what is bundled with Mill.

  • The override keyword is optional in Mill. It is shown above for clarity, but can be elided for conciseness.

This generated line-count.txt file can then be loaded and used at runtime, as see in the output of mill run below.

> ./mill foo.run
...
Line Count: 17

> ./mill show foo.lineCount
17

> ./mill inspect foo.lineCount
foo.lineCount(build.mill:...)
    Total number of lines in module source files
Inputs:
    foo.allSourceFiles

If you’re not familiar with what tasks you can override or how they are related, you can explore the existing tasks via autocomplete in your IDE, or use the mill visualize.

Custom user-defined tasks in Mill such as def lineCount above benefit from all the same things that built-in tasks do: automatic caching (in the out/ folder), parallelism (configurable via -j/--jobs flag), inspectability (via show and inspect), and so on. While these things may not matter for such a simple example that runs quickly, they ensure that custom build logic remains performant and maintainable even as the complexity of your project grows.

Programmatic Multi-Module Project

build.mill (download, browse)
package build
import mill.*, javalib.*

trait MyModule extends JavaModule {
  object test extends JavaTests, TestModule.Junit4
}

object foo extends MyModule {
  def moduleDeps = Seq(bar)
  def mvnDeps = Seq(
    mvn"net.sourceforge.argparse4j:argparse4j:0.9.0"
  )
}

object bar extends MyModule {
  def mvnDeps = Seq(
    mvn"net.sourceforge.argparse4j:argparse4j:0.9.0",
    mvn"org.thymeleaf:thymeleaf:3.1.1.RELEASE"
  )
}

This is similar to the Multi-Module Projects example above, but using Mill’s programmatic configuration syntax. You can define multiple modules the same way you define a single module, using def moduleDeps to define the relationship between them. Modules can also be nested within each other, as foo.test and bar.test are nested within foo and bar respectively

Note that we split out the test submodule configuration common to both modules into a separate trait MyModule. This Trait Module works like a class in Java, and lets us avoid the need to copy-paste common settings, while still letting us define any per-module configuration such as mvnDeps specific to a particular module. This is a common pattern within Mill builds.

> ./mill bar.test
Test bar.BarTests.simple finished...
Test bar.BarTests.escaping finished...
> ./mill foo.run --foo-text hello --bar-text world
Foo.value: hello
Bar.value: <h1>world</h1>

You can also put the configuration for each submodule in it’s respective folder’s package.mill file, as described in Multi-File Builds. This can be helpful in larger projects to avoid having your build.mill grow large, and aid in discoverability by keeping the build configuration for each module close to the code it is configuring

Programmatic Compatibility Modules

This example is similar to the Maven-Compatible Modules above, but using Mill’s programmatic build.mill files. These are more flexible than the config-driven build.mill.yaml files, and can contain Custom Build Logic and other programmatic configuration

build.mill (download, browse)
package build
import mill.*, javalib.*

object foo extends MavenModule {
  object test extends MavenTests, TestModule.Junit4
  object integration extends MavenTests, TestModule.Junit4
}
> ./mill foo.compile
compiling 1 Java source...

> ./mill foo.test.compile
compiling 1 Java source...

> ./mill foo.test.testForked
...foo.FooTests.hello ...

> ./mill foo.test
...foo.FooTests.hello ...

> ./mill foo.integration
...foo.FooIntegrationTests.hello ...

Realistic Java Example Project

Below, we should a realistic example of a build for a Java project. This example touches on library dependencies, testing, publishing, code generation, and other topics covered in more detail in the Java section of the Mill docs, and you can browse each respective page if you want to learn more.

build.mill (download, browse)
package build
import mill.*, javalib.*, publish.*

trait MyModule extends JavaModule, PublishModule {
  def publishVersion = "0.0.1"

  def pomSettings = PomSettings(
    description = "Hello",
    organization = "com.lihaoyi",
    url = "https://github.com/lihaoyi/example",
    licenses = Seq(License.MIT),
    versionControl = VersionControl.github("lihaoyi", "example"),
    developers = Seq(Developer("lihaoyi", "Li Haoyi", "https://github.com/lihaoyi"))
  )

  def mvnDeps = Seq(mvn"org.thymeleaf:thymeleaf:3.1.1.RELEASE")

  object test extends JavaTests, TestModule.Junit4
}

object foo extends MyModule {
  def moduleDeps = Seq(bar, qux)

  def generatedSources = Task {
    os.write(
      Task.dest / "Version.java",
      s"""
         |package foo;
         |public class Version {
         |    public static String value() {
         |        return "${publishVersion()}";
         |    }
         |}
      """.stripMargin
    )
    Seq(PathRef(Task.dest))
  }
}

object bar extends MyModule {
  def moduleDeps = Seq(qux)
}

object qux extends MyModule

A semi-realistic build setup, combining all the individual Mill concepts:

  • Three JavaModules that depend on each other

  • With unit testing and publishing set up

  • With generated sources to include the publishVersion as a string in the code, so it can be printed at runtime

Note that for multi-module builds like this, using queries to run tasks on multiple modules at once can be very convenient:

__.test
__.publishLocal

Also note how you can use traits to bundle together common combinations of modules: MyModule not only defines a JavaModule with some common configuration, but it also defines a object test module within it with its own configuration. This is a very useful technique for managing the often repetitive module structure in a typical project

> ./mill resolve __.run
bar.run
bar.test.run
foo.run
foo.test.run
qux.run

> ./mill foo.run
foo version 0.0.1
Foo.value: <h1>hello</h1>
Bar.value: <p>world</p>
Qux.value: 31337

> ./mill bar.test
...bar.BarTests.test ...

> ./mill qux.run
Qux.value: 31337

> ./mill __.compile

> ./mill __.test
...bar.BarTests.test ...
...foo.FooTests.test ...

> ./mill __.publishLocal
Publishing Artifact(com.lihaoyi,foo,0.0.1) to ivy repo...
Publishing Artifact(com.lihaoyi,bar,0.0.1) to ivy repo...
Publishing Artifact(com.lihaoyi,qux,0.0.1) to ivy repo...
...

> ./mill show foo.assembly # mac/linux
".../out/foo/assembly.dest/out.jar"

> ./out/foo/assembly.dest/out.jar # mac/linux
foo version 0.0.1
Foo.value: <h1>hello</h1>
Bar.value: <p>world</p>
Qux.value: 31337