Build Tools — SBT and Mill | Scala Programming Guide

- Published on

Understanding SBT: The Scala Build Tool
SBT (Scala Build Tool) is the de facto standard for Scala projects. Unlike Maven's XML-heavy approach, SBT uses Scala itself as its configuration language, making it both powerful and expressive.
Project Structure and build.sbt
A typical SBT project follows this structure:
myproject/
├── build.sbt // Main build definition
├── project/
│ ├── build.properties // SBT version
│ └── plugins.sbt // Build plugins
├── src/
│ ├── main/
│ │ ├── scala/ // Production code
│ │ ├── java/ // Java interop code
│ │ └── resources/ // Config, data files
│ ├── test/
│ │ ├── scala/ // Test code
│ │ └── resources/ // Test fixtures
│ └── it/ // Integration tests
└── target/ // Build output
Here's a comprehensive build.sbt that demonstrates real-world patterns:
// build.sbt
// Core project settings
ThisBuild / organization := "com.mycompany"
ThisBuild / version := "1.0.0"
ThisBuild / scalaVersion := "3.3.1"
lazy val root = project
.in(file("."))
.settings(
name := "invoice-processor",
description := "High-performance invoice PDF extraction and validation"
)
.aggregate(core, api, cli)
// Core library with business logic
lazy val core = project
.in(file("modules/core"))
.settings(
name := "invoice-processor-core",
// Scala compiler options for strict type checking
scalacOptions ++= Seq(
"-deprecation", // Warn about deprecated API usage
"-feature", // Warn about unused features
"-unchecked", // Warn about unchecked operations
"-Xfatal-warnings", // Treat warnings as errors
"-language:implicitConversions",
"-language:postfixOps"
),
libraryDependencies ++= Seq(
// Functional programming
"org.typelevel" %% "cats-core" % "2.10.0",
"org.typelevel" %% "cats-effect" % "3.5.0",
// JSON parsing with Circe (type-safe, purely functional)
"io.circe" %% "circe-core" % "0.14.6",
"io.circe" %% "circe-parser" % "0.14.6",
"io.circe" %% "circe-generic" % "0.14.6",
// PDF processing (Apache PDFBox is the gold standard)
"org.apache.pdfbox" % "pdfbox" % "3.0.0",
// Testing dependencies (scope: test)
"org.scalatest" %% "scalatest" % "3.2.17" % Test,
"org.scalamock" %% "scalamock" % "5.2.0" % Test,
"org.scalacheck" %% "scalacheck" % "1.17.0" % Test,
),
// Java version compatibility
javacOptions ++= Seq(
"-source", "11",
"-target", "11",
"-encoding", "UTF-8"
),
// Publish settings
publishTo := Some("Internal" at "https://nexus.internal/repository/releases"),
credentials += Credentials(
"Sonatype Nexus Repository Manager",
"nexus.internal",
sys.env.getOrElse("NEXUS_USER", ""),
sys.env.getOrElse("NEXUS_PASSWORD", "")
),
// Test settings
Test / testOptions += Tests.Argument(TestFrameworks.ScalaTest, "-oD"),
Test / fork := true,
Test / javaOptions += "-Xmx2g"
)
// REST API service
lazy val api = project
.in(file("modules/api"))
.dependsOn(core % "compile->compile;test->test")
.settings(
name := "invoice-processor-api",
// HTTP4s for functional web development
libraryDependencies ++= Seq(
"org.http4s" %% "http4s-core" % "1.0.0-M40",
"org.http4s" %% "http4s-dsl" % "1.0.0-M40",
"org.http4s" %% "http4s-ember-server" % "1.0.0-M40",
"org.http4s" %% "http4s-circe" % "1.0.0-M40",
"ch.qos.logback" % "logback-classic" % "1.4.14"
),
assembly / mainClass := Some("com.mycompany.api.Main"),
assembly / assemblyMergeStrategy := {
case PathList("META-INF", xs @ _*) => MergeStrategy.discard
case x => MergeStrategy.first
}
)
// Command-line interface
lazy val cli = project
.in(file("modules/cli"))
.dependsOn(core)
.settings(
name := "invoice-processor-cli",
libraryDependencies ++= Seq(
// CLI argument parsing with decline (no reflection overhead)
"com.monovore" %% "decline" % "2.4.1",
"com.monovore" %% "decline-effect" % "2.4.1",
// Progress bars and nice terminal output
"org.jline" % "jline" % "3.24.1"
),
assembly / mainClass := Some("com.mycompany.cli.Main")
)
// Common settings across all projects
inThisBuild(
Seq(
// Repositories to search for dependencies (Maven Central by default)
resolvers ++= Seq(
"Sonatype OSS Snapshots" at
"https://oss.sonatype.org/content/repositories/snapshots",
"Typesafe Repository" at
"https://repo.typesafe.com/typesafe/releases/"
),
// Use coursier for faster dependency resolution
useCoursier := true
)
)
Common SBT Tasks
SBT provides interactive and batch commands for the build lifecycle. The interactive mode (type sbt with no arguments) maintains project state between commands, making recompilation fast after edits with the ~ prefix for file watching. Batch mode integrates with CI/CD pipelines, shell scripts, and automation, executing a single command then exiting. Most development uses interactive mode for rapid feedback, while automated systems use batch mode for consistency. Understanding when to use each mode helps optimize your development workflow.
# Compile production code
sbt compile
# Run all tests with detailed output
sbt test
# Run specific test class
sbt "testOnly com.mycompany.InvoiceParserTest"
# Run tests matching a pattern
sbt "testOnly -- -z \"should extract line items\""
# Launch interactive Scala REPL with project dependencies
sbt console
# Compile then run main class (prompts if multiple exist)
sbt run
# Package as JAR file
sbt package
# Package with all dependencies (fat JAR)
sbt assembly
# Generate API documentation
sbt doc
# Clean all build artifacts
sbt clean
# Show dependency tree
sbt "dependencyTree"
# Check for dependency updates
sbt "dependencyUpdates"
# Enter interactive mode (type commands without 'sbt' prefix)
sbt
> compile
> ~testQuick # Tilde: recompile on file changes
> exit
Managing Dependencies
Dependencies declare external libraries your project needs. The Ivy-style notation (organization %% name % version) is standard—the %% operator automatically appends your Scala version to prevent cross-version conflicts. Scopes like Test, IntegrationTest, and Optional control where dependencies appear: Test dependencies don't bloat production JARs, Optional dependencies are included conditionally, and IntegrationTest separates integration tests from unit tests. Understanding these scopes prevents bloated artifacts and missing dependencies in specific contexts.
// In build.sbt
// Standard format: organization %% name % version
// %% automatically appends Scala version suffix
libraryDependencies ++= Seq(
// Core functional programming
"org.typelevel" %% "cats-core" % "2.10.0",
// HTTP client (built on cats-effect)
"org.http4s" %% "http4s-ember-client" % "1.0.0-M40",
// Parsing with nom-like combinators
"org.typelevel" %% "cats-parse" % "0.3.9",
// Testing only - won't be packaged in release JAR
"org.scalatest" %% "scalatest" % "3.2.17" % Test,
// Integration tests (separate from unit tests)
"org.scalatest" %% "scalatest" % "3.2.17" % IntegrationTest,
// Optional dependency (include when needed)
"io.circe" %% "circe-jackson" % "0.14.6" % Optional,
)
// Exclude transitive dependencies that cause conflicts
libraryDependencies += (
"org.apache.spark" %% "spark-core" % "3.4.0"
excludeAll(
ExclusionRule("org.slf4j"),
ExclusionRule("org.scala-lang")
)
)
Multi-Project Builds
Real applications decompose into multiple subprojects:
// root project aggregates others
lazy val root = project
.in(file("."))
.aggregate(domain, persistence, api, cli)
.settings(
name := "ecommerce-platform"
)
// Domain models (no external dependencies except Cats)
lazy val domain = project
.in(file("modules/domain"))
.settings(
name := "ecommerce-domain",
libraryDependencies += "org.typelevel" %% "cats-core" % "2.10.0"
)
// Database access layer
lazy val persistence = project
.in(file("modules/persistence"))
.dependsOn(domain % "compile->compile;test->test")
.settings(
name := "ecommerce-persistence",
libraryDependencies ++= Seq(
"org.tpolecat" %% "doobie-core" % "1.0.0-RC4",
"org.tpolecat" %% "doobie-h2" % "1.0.0-RC4",
"org.tpolecat" %% "doobie-hikari" % "1.0.0-RC4"
)
)
// REST API service
lazy val api = project
.in(file("modules/api"))
.dependsOn(domain, persistence)
.settings(
name := "ecommerce-api"
)
// CLI for batch operations
lazy val cli = project
.in(file("modules/cli"))
.dependsOn(domain, persistence)
.settings(
name := "ecommerce-cli",
mainClass := Some("com.mycompany.cli.Main")
)
// Shared configuration
inThisBuild(Seq(
// Apply to all projects
scalacOptions ++= Seq("-unchecked", "-deprecation"),
// Dependency overrides (resolve version conflicts)
dependencyOverrides ++= Seq(
"com.google.guava" % "guava" % "32.1.3-jre"
)
))
SBT Plugins
Plugins extend SBT's capabilities:
// In project/plugins.sbt
// Code coverage reporting
addSbtPlugin("org.scoverage" % "sbt-scoverage" % "2.0.9")
// Create fat JARs for deployment
addSbtPlugin("com.eed3si9n" % "sbt-assembly" % "2.1.1")
// Native packaging (RPM, DEB, Docker)
addSbtPlugin("com.typesafe.sbt" % "sbt-native-packager" % "1.9.16")
// Code formatting (scalafmt)
addSbtPlugin("org.scalameta" % "sbt-scalafmt" % "2.5.0")
// Linting (Scalafix)
addSbtPlugin("ch.epfl.scala" % "sbt-scalafix" % "0.11.1")
// Benchmarking with JMH
addSbtPlugin("pl.project13.scala" % "sbt-jmh" % "0.4.7")
// Custom task example: generate Scala code from configuration
lazy val genConfig = taskKey[Seq[File]]("Generate config module")
genConfig := {
val configFile = (Compile / resourceDirectory).value / "application.conf"
val outputDir = (Compile / sourceManaged).value
val configParser = new ConfigParser()
configParser.generateScala(configFile, outputDir)
Seq(outputDir / "Config.scala")
}
Compile / sourceGenerators += genConfig
Mill: The Alternative Build Tool
Mill is a newer build tool that addresses SBT's weaknesses: simpler syntax, faster builds, and better incremental compilation.
# build.sc (Mill build file in Python-like syntax)
import mill._, scalalib._, publish._
object core extends ScalaModule {
// Mill infers project layout automatically
def scalaVersion = "3.3.1"
def ivyDeps = Agg(
ivy"org.typelevel::cats-core:2.10.0",
ivy"org.typelevel::cats-effect:3.5.0",
ivy"io.circe::circe-core:0.14.6",
)
def scalacOptions = Seq(
"-deprecation",
"-unchecked",
"-Xfatal-warnings"
)
object test extends ScalaTests {
def ivyDeps = Agg(
ivy"org.scalatest::scalatest:3.2.17"
)
def testFrameworks = Seq("org.scalatest.tools.Framework")
}
}
object api extends ScalaModule {
def scalaVersion = "3.3.1"
def moduleDeps = Seq(core)
def ivyDeps = Agg(
ivy"org.http4s::http4s-ember-server:1.0.0-M40"
)
}
object cli extends ScalaModule {
def scalaVersion = "3.3.1"
def moduleDeps = Seq(core)
def mainClass = Some("com.mycompany.cli.Main")
}
// Mill commands
// $ mill core.compile
// $ mill core.test
// $ mill api.run
// $ mill cli.assembly (creates fat JAR)
// $ mill show core.ivyDeps (show all dependencies)
Why Teams Choose Mill
- Simpler Configuration: Python-like syntax vs Scala's monadic builders
- Faster Builds: Better incremental compilation, fewer re-compilations
- Clear Dependencies: Explicit
moduleDepsvs crypticdependsOnsyntax - Direct Scala Code: No cryptic settings types like
SettingKey[T] - Better Error Messages: Mill errors are more actionable
However, SBT remains dominant due to ecosystem maturity and plugin availability.