Scala - Build Tools (SBT) Tutorial

SBT follows a conventional directory layout that separates source code, resources, and build definitions. A minimal project requires only source files, but production projects need explicit...

Key Insights

  • SBT uses an incremental compilation model that only recompiles changed files and their dependencies, making it significantly faster than full recompilation for large Scala projects
  • Build definitions are written in Scala itself using a declarative DSL, allowing programmatic build logic while maintaining readability through auto-plugins and settings
  • SBT’s task graph system enables parallel execution of independent tasks and sophisticated dependency management through resolvers and scope-specific configurations

Understanding SBT Project Structure

SBT follows a conventional directory layout that separates source code, resources, and build definitions. A minimal project requires only source files, but production projects need explicit configuration.

my-project/
├── build.sbt                 # Primary build definition
├── project/
│   ├── build.properties      # SBT version specification
│   └── plugins.sbt           # Build-level dependencies
├── src/
│   ├── main/
│   │   ├── scala/           # Application code
│   │   └── resources/       # Runtime resources
│   └── test/
│       ├── scala/           # Test code
│       └── resources/       # Test resources
└── target/                   # Compiled artifacts (generated)

The build.sbt file defines project metadata, dependencies, and custom tasks:

name := "my-scala-app"
version := "0.1.0"
scalaVersion := "3.3.1"

libraryDependencies ++= Seq(
  "org.typelevel" %% "cats-core" % "2.10.0",
  "org.scalatest" %% "scalatest" % "3.2.17" % Test
)

scalacOptions ++= Seq(
  "-deprecation",
  "-feature",
  "-unchecked"
)

The project/build.properties file locks the SBT version:

sbt.version=1.9.7

Dependency Management and Resolvers

SBT uses Apache Ivy for dependency resolution with Maven-compatible syntax. The %% operator automatically appends the Scala binary version to artifact names.

// Maven coordinates: groupId % artifactId % version
libraryDependencies += "ch.qos.logback" % "logback-classic" % "1.4.11"

// Scala library (binary version appended automatically)
libraryDependencies += "io.circe" %% "circe-core" % "0.14.6"

// Multiple configurations
libraryDependencies ++= Seq(
  "com.typesafe.akka" %% "akka-actor-typed" % "2.8.5",
  "com.typesafe.akka" %% "akka-stream" % "2.8.5",
  "com.typesafe.akka" %% "akka-testkit" % "2.8.5" % Test
)

Custom resolvers enable fetching artifacts from private repositories:

resolvers ++= Seq(
  "Sonatype OSS Snapshots" at "https://oss.sonatype.org/content/repositories/snapshots",
  "Company Nexus" at "https://nexus.company.com/repository/maven-releases",
  Resolver.mavenLocal
)

// Credentials for private repositories
credentials += Credentials(Path.userHome / ".sbt" / ".credentials")

The credentials file format:

realm=Sonatype Nexus Repository Manager
host=nexus.company.com
user=deployment
password=secret

Multi-Module Projects

Large applications benefit from splitting code into modules with explicit dependencies. Define subprojects in build.sbt:

lazy val root = (project in file("."))
  .aggregate(core, api, cli)
  .settings(
    name := "my-application"
  )

lazy val core = (project in file("core"))
  .settings(
    name := "my-application-core",
    libraryDependencies ++= Seq(
      "org.typelevel" %% "cats-effect" % "3.5.2"
    )
  )

lazy val api = (project in file("api"))
  .dependsOn(core)
  .settings(
    name := "my-application-api",
    libraryDependencies ++= Seq(
      "com.typesafe.akka" %% "akka-http" % "10.5.3"
    )
  )

lazy val cli = (project in file("cli"))
  .dependsOn(core)
  .settings(
    name := "my-application-cli",
    libraryDependencies ++= Seq(
      "com.github.scopt" %% "scopt" % "4.1.0"
    )
  )

Each subproject maintains its own source directory under the specified path. Navigate between projects in the SBT shell:

sbt:root> project core
sbt:core> compile
sbt:core> project api
sbt:api> test

Custom Tasks and Settings

SBT’s task system allows defining reusable build operations. Tasks have dependencies that execute automatically:

lazy val generateBuildInfo = taskKey[Seq[File]]("Generate build information")
lazy val buildInfoPackage = settingKey[String]("Package for build info")

buildInfoPackage := "com.myapp.build"

generateBuildInfo := {
  val file = (Compile / sourceManaged).value / "BuildInfo.scala"
  val contents =
    s"""package ${buildInfoPackage.value}
       |
       |object BuildInfo {
       |  val version: String = "${version.value}"
       |  val scalaVersion: String = "${scalaVersion.value}"
       |  val buildTime: Long = ${System.currentTimeMillis}L
       |}
       |""".stripMargin
  
  IO.write(file, contents)
  Seq(file)
}

Compile / sourceGenerators += generateBuildInfo.taskValue

This task generates a Scala source file during compilation containing build metadata. Access it in your code:

package com.myapp

object Main extends App {
  println(s"Version: ${build.BuildInfo.version}")
  println(s"Built with Scala ${build.BuildInfo.scalaVersion}")
}

Plugins and Auto-Plugins

Plugins extend SBT functionality. Add them in project/plugins.sbt:

addSbtPlugin("com.github.sbt" % "sbt-native-packager" % "1.9.16")
addSbtPlugin("org.scalameta" % "sbt-scalafmt" % "2.5.2")
addSbtPlugin("ch.epfl.scala" % "sbt-scalafix" % "0.11.1")
addSbtPlugin("org.scoverage" % "sbt-scoverage" % "2.0.9")

Enable and configure plugins in build.sbt:

enablePlugins(JavaAppPackaging, DockerPlugin)

// Native packager settings
maintainer := "dev@company.com"
packageSummary := "My Scala Application"
packageDescription := "A production-ready Scala service"

// Docker configuration
dockerBaseImage := "eclipse-temurin:17-jre"
dockerExposedPorts := Seq(8080)
dockerRepository := Some("registry.company.com")

// Scalafmt settings
scalafmtOnCompile := true

// Scoverage settings
coverageMinimumStmtTotal := 80
coverageFailOnMinimum := true
coverageExcludedPackages := ".*\\.build\\..*"

Testing and Continuous Integration

SBT integrates with testing frameworks through library dependencies. Configure test execution:

libraryDependencies ++= Seq(
  "org.scalatest" %% "scalatest" % "3.2.17" % Test,
  "org.scalatestplus" %% "mockito-4-11" % "3.2.17.0" % Test
)

Test / parallelExecution := true
Test / fork := true
Test / testOptions += Tests.Argument(TestFrameworks.ScalaTest, "-oD")

Create a test suite:

package com.myapp

import org.scalatest.flatspec.AnyFlatSpec
import org.scalatest.matchers.should.Matchers

class CalculatorSpec extends AnyFlatSpec with Matchers {
  "Calculator" should "add two numbers correctly" in {
    val result = Calculator.add(2, 3)
    result shouldBe 5
  }
  
  it should "multiply two numbers correctly" in {
    Calculator.multiply(4, 5) shouldBe 20
  }
}

For CI/CD pipelines, use SBT’s batch mode:

# Run tests with coverage
sbt clean coverage test coverageReport

# Build Docker image
sbt docker:publishLocal

# Create distribution package
sbt stage

Performance Optimization

SBT performance improves significantly with proper JVM tuning and caching. Create .sbtopts:

-J-Xms2G
-J-Xmx4G
-J-XX:+UseG1GC
-J-XX:MaxMetaspaceSize=1G
-J-XX:ReservedCodeCacheSize=256M
-Dsbt.coursier.ttl=24h

Enable incremental compilation and caching:

// In build.sbt
Global / concurrentRestrictions := Seq(
  Tags.limit(Tags.CPU, Runtime.getRuntime.availableProcessors()),
  Tags.limit(Tags.Test, 1)
)

// Use cached resolution
updateOptions := updateOptions.value.withCachedResolution(true)

// Turbo mode for faster startup
ThisBuild / turbo := true

Use SBT’s server mode for interactive development:

# Start SBT shell once, keep it running
sbt

# Continuous compilation and testing
sbt:root> ~compile
sbt:root> ~test

The tilde operator watches for file changes and automatically triggers tasks, eliminating startup overhead between compilations.

Liked this? There's more.

Every week: one practical technique, explained simply, with code you can use immediately.