October 3, 2018 · 12 min read

Building Scala Projects with Mill The better build tool for Scala

Tl;dr: Mill is a simplified and faster alternative to sbt

Mill is a build tool aimed at providing a better experience for Scala developers. If you’ve used Scala before, you likely used sbt for building your project. sbt is by far the most popular build tool in the Scala community, but if you’re like me, you may have quickly become frustrated with how long specific tasks tend to take. It’s not uncommon for sbt to take several seconds to start up. Those seconds add up throughout a workday.

Here is where Mill shines. Mill provides an intuitive command-line interface for compiling your project, running tests and packaging your code, and it does it with far less overhead than sbt.

[Scala][2] is a programming language intended to merge object-oriented and functional programming into one high-level language. It builds on top of the JVM and provides easy access to the entire ecosystem of JVM-based libraries.

It helps if you have some familiarity with Scala and sbt (or a similar build tool) when going through this tutorial. You should also feel comfortable working from the command-line and know how to create directories and files.

Getting Started

To follow along, you’ll need to install both Scala and Mill. Follow the instructions here and here to do so.

If everything went smoothly, you should be able to print the version info for both.

    $ mill version
    [1/1] version
    0.2.6
    $ scala -version
    Scala code runner version 2.12.6-20180516-151519-unknown -- Copyright 2002-2018, LAMP/EPFL and Lightbend, Inc.

Now we’re ready to start using Mill. Every command in this tutorial will be issued from the command-line, so follow along from your terminal. If you use Intellij, I’ll show you at the end how to generate an Intellij project with Mill.

The Hello World of Mill

Let’s go ahead and create a directory for the project we’ll build throughout this tutorial. From your home directory, run:

    ~ $ mkdir milldemo && cd milldemo

We’ve created a folder on your machine named milldemo, and our pwd should now be ~/milldemo.

Let’s see how easy it is to get off the ground with Mill. Inside the milldemo directory, create a file named build.sc and paste the following contents inside:

build.sc
import mill._, scalalib._
object helloworld extends ScalaModule {
  def scalaVersion = "2.12.6"
}

Rather than prescribe its own DSL or use something like XML for defining build configuration, Mill’s configuration is just Scala.

To demonstrate that this is all we need to start using Mill, let’s build out the remainder of the project structure. We’ll create a subdirectory named helloworld to correspond with the name of the ScalaModule we defined in build.sc. Inside helloworld, create another folder named src and finally create a file named Main.scala. Here’s a simple one-liner to do this in a single step:

  $ mkdir -p helloworld/src && touch helloworld/src/Main.scala

Your directory should now look like this:

├── build.sc
├── helloworld
│   ├── src
│   │   └── Main.scala

One thing I’d like to point out is that this structure doesn’t follow the Scala Style Guide recommendations to follow Java package naming conventions. I’m using this structure here for simplicity.

Let’s write some Scala code now. Like every other beginning tutorial, we’ll start with the Hello World of Scala. Inside helloworld/src/Main.scala, write the following:

helloworld/src/Main.scala
package helloworld

object Main extends App {
  println("Hello World")
}

Here we have a very minimal Scala application that prints Hello World to the console. We’re extending the App trait, so we don’t have to write a main function. App lets us write our code within the body of the Main object, making things less verbose.

Let’s give Mill a run. Mill has various tasks that it can execute for a project. To see a list of all tasks Mill has defined for our helloworld project, run:

    $ mill resolve helloworld._

A whole bunch of tasks will be logged to your terminal. Feel free to explore the various tasks on your own, but for now, we’ll focus on the run task. To run our Hello World project, execute:

    $ mill helloworld.run

This first run might take a few seconds but try it again. Do you notice how fast it is? Thanks to Mill’s cache strategy, every subsequent build should be much faster, and you’re not paying the cost of sbt’s startup time for every single task you run. Pretty great, right?

Congratulations. You’ve now built your first Scala project with Mill.

Time to Kick it Up a Notch

We’re now building a Scala project with Mill, but there’s a lot we haven’t covered yet. How about writing and running unit tests? Can we format our code automatically? Mill can do these things too.

Let’s build something a little cooler than Hello World. Something that gives us a chance to write a unit test or two and requires a bit more code so we can see what Mill’s reformat task can do for us. Borrowing this example from “The Go Programming Language," we’re going to build a simple temperature conversion tool. Given a number, we’ll determine the conversion from both Celsius to Fahrenheit, and Fahrenheit to Celsius.

First, add another project definition to build.sc. We could create an entirely new project in a new directory, but this way we can use a single build.sc file to build multiple projects. Go ahead and add the following to build.sc:

build.sc
object tempconv extends ScalaModule {
  def scalaVersion = "2.12.6"
}

This should look familiar. We now have two projects defined in build.sc, and if you recall from the first time we did this, the next step is to create a directory with the same name as the new project. To do that, run the following:

  $ mkdir -p tempconv/src && touch tempconv/src/Main.scala

With this single bash command, we’ve created the new directory we need and also added an empty Scala file inside it. Our updated directory structure should look like:

├── build.sc
├── helloworld
│   ├── src
│   │   └── Main.scala
├── tempconv
│   ├── src
│   │   └── Main.scala

Now for the implementation. Inside tempconv/src/Main.scala, paste the following code.

helloworld/src/Main.scala
package tempconv

import scala.util.{Try} // 1

object TempConv { // 2
  type Celsius = Float // 3
  type Fahrenheit = Float // 4
  def toFahrenheit(c: Celsius): Fahrenheit = c * 9 / 5 + 32
  def toCelsius(f: Fahrenheit): Celsius = (f - 32) * 5 / 9
}

object Main extends App {
  import TempConv._  // 5
  if (args.length < 1) { // 6
    println("Usage: tempconv <number>")
  }
  for (arg <- args) Try(arg.toFloat).toOption match { // 7
    case Some(t: Float) =>
      println(s"$t°F = ${toCelsius(t)}°C, $t°C = ${toFahrenheit(t)}°F")
    case None => println(s"Unable to parse $arg")
  }
}

There’s a fair amount of code here so let’s go over what it does:

  1. We’re parsing arguments that users pass in, and for some values the parsing can fail. Therefore, we import Try so we can safely do the parsing.
  2. TempConv is an object that contains the logic needed for converting from Celsius to Fahrenheit and vice-versa.
  3. We declare type aliases for Celsius and Fahrenheit, both with underlying types of float.
  4. The methods for converting from one to another are defined here. Feel free to confirm for yourself the relevant formulas: http://allmeasures.com/temperature.html
  5. Inside of Main, we import the contents of the TempConv object so we can call toCelsius and toFahrenheit directly.
  6. If the user doesn’t pass us any arguments, we print some usage instructions.
  7. For every argument the user passes, we try parsing to float, and then we pattern match to see if the parsing succeeded. If it did, we print both the conversion for Celsius to Fahrenheit and Fahrenheit to Celsius.

We have our temperature conversion tool now, so let’s run the thing. From your terminal, run:

    $ mill tempconv.run 32 100

If everything went as planned, you should see the following logged to the console:

32.0°F = 0.0°C, 32.0°C = 89.6°F
100.0°F = 37.77778°C, 100.0°C = 212.0°F

Feel free to pass arguments of your own and see what happens.

One more thing I want to point out about the run command is the -w flag. If you pass -w to mill run, Mill automatically compiles and runs your code each time you make a change. Experiment with that now by changing one of the println statements. Just run:

  $ mill -w tempconv.run 32 100

The -w flag is great for getting quick feedback as you develop.

All right, we’re nearing the end now. Before we wrap, let’s discuss testing, reformatting with the reformat task, and building a fat JAR with the assembly task.

Testing

We want to make sure we have a very accurate temperature conversion tool. One thing we can do to give us confidence is to write unit tests that assert our expected behavior. Configure Mill to enable unit testing by adding the following to the tempconv project in build.sc:

build.sc
object test extends Tests {
  def ivyDeps = Agg(
    ivy"org.scalatest::scalatest:3.0.5",
  )
  def testFrameworks = Seq("org.scalatest.tools.Framework")
}

The complete build.sc should now look like:

build.sc
import mill._, scalalib._

object helloworld extends ScalaModule {
  def scalaVersion = "2.12.6"
}

object tempconv extends ScalaModule {
  def scalaVersion = "2.12.6"
  def mainClass = Some("tempconv.Main")
  object test extends Tests {
    def ivyDeps = Agg(
      ivy"org.scalatest::scalatest:3.0.5",
    )
    def testFrameworks = Seq("org.scalatest.tools.Framework")
  }
}

Here we’re just telling Mill that we’d like to use the ScalaTest framework to write and run our tests.

We’re going to add just two simple tests that assert our temperature conversion is working as expected. Create a new file inside tempconv/test/src and name it TempConvSpec.scala. Paste the following code inside:

tempconv/test/src/TempConvSpec.scala
package tempconv

import org.scalatest.{ FlatSpec }
import tempconv.TempConv._

class TempConvSpec extends FlatSpec {
  it should "convert 32°F to 0°C" in {
    assert(toCelsius(32) == 0)
  }

  it should "convert 0°C to 32°F" in {
    assert(toFahrenheit(0) == 32)
  }
}

We have two simple tests here. The first tests our toCelsius function and asserts that 32 degrees Fahrenheit is equal to 0 degrees Celsius. Our second test confirms that the opposite is true by calling toFahrenheit with 0 and asserting the result is equal to 32.

We can run our tests now with:

  $ mill tempconv.test

Just like the run command, we can pass -w to the test command to run our tests every time we make a change. Try running the tests with mill -w tempconv.test and see if you can think of any other useful tests to add.

Formatting with Scalafmt

If you’ve been copying and pasting the code as you followed along with this tutorial, you probably haven’t been too concerned with how the code was formatted. I’d assume that when you pasted the code in, indentation looked pretty consistent. Formatting is one of those topics in programming that can elicit various opinions. Are tabs or spaces better? How long is too long for a line of code to be? However, consistency is more important in any software project, and often it’s better to adopt a set of standards agreed upon by the community so that others can jump into your code quickly. Scalafmt is a code formatter for Scala that comes with a set of rules most would consider reasonable defaults. Mill has built-in support for Scalafmt, so let’s format our code with it now.

To enable the reformat task for a project, we need to make a couple of changes to build.sc. First, import scalafmt._ and then add with ScalafmtModule to each module you want to be able to reformat. Our updated build.sc should look like the following:

import mill._, scalalib._, scalafmt._

object helloworld extends ScalaModule with ScalafmtModule {
  def scalaVersion = "2.12.6"
}

object tempconv extends ScalaModule with ScalafmtModule {
  def scalaVersion = "2.12.6"
  def mainClass = Some("tempconv.Main")
  object test extends Tests {
    def ivyDeps = Agg(
      ivy"org.scalatest::scalatest:3.0.5",
    )
    def testFrameworks = Seq("org.scalatest.tools.Framework")
  }
}

To format the helloworld project we wrote first, run:

  $ mill helloworld.reformat

Do the same for the tempconv project:

  $ mill tempconv.reformat

It’s a good habit to reformat your code before committing your changes. I won’t cover it here, but it’s possible even to configure most editors and IDEs to do the reformatting for you when you save a file.

Fat Jars with Assembly

Now for the final topic we’ll cover in this post; building what is affectionately called a “Fat JAR.” Sometimes a Scala developer sees a need to ship their code in a format that is executable by others without them needing the Scala toolchain. Many people have a JVM installed, but fewer have Scala installed. One case where I had to worry about this was when building an AWS Lambda function in Scala. AWS Lambda doesn’t support Scala, but it supports Java. So if we can produce a self-contained JAR capable of being run by the Java toolchain, our code can run in environments like AWS Lambda. The name “Fat JAR” comes from the fact that we’re bundling everything we need from the Scala standard library for our JAR to be executable by the Java runtime (JRE). Let’s take a look at this in action.

To build a fat JAR, run mill tempconv.assembly. Mill places a jar in out/tempconv/assembly/dest/out.jar. To see how you can run this with the java command, run:

  $ java -jar out/tempconv/assembly/dest/out.jar 32 100

Intellij Support

As promised, I’ll now show you how to generate an Intellij project using Mill. Throughout this tutorial, we’ve largely stuck with the command line, but I know many people prefer working in an IDE. To create the Intellij project files, run the following from the terminal:

  $ mill mill.scalalib.GenIdea/idea

Where to Go From Here?

We now have a small, but real-world project using Mill as the build tool. Hopefully you come away from this post with an appreciation for Mill’s lightweight configuration and minimal overhead when performing tasks like compilation and testing. Think of all those precious seconds throughout the day you can have back just by swapping out build tools.

There’s a lot more Mill can do for you. I encourage you to read the docs to learn more.

The completed project can be found here.

If you got stuck at any point throughout the tutorial, feel free to leave a comment down below, or open an issue at the completed project on Github.

© 2024 Mike Perry