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.