sbt, dependencies and project structure
Write settings and tasks, manage libraries and resolvers, split a build into modules, and publish a versioned artifact.
Settings, tasks and scopes
// build.sbt
ThisBuild / scalaVersion := "3.5.0"
ThisBuild / organization := "com.example"
// a custom task: a setting computes a value once, a task runs on demand
lazy val printVersions = taskKey[Unit]("Print the tool versions")
printVersions := {
val sv = scalaVersion.value
val jv = System.getProperty("java.version")
streams.value.log.info(s"scala $sv on JVM $jv")
}
// a setting that derives from another
lazy val buildInfo = settingKey[String]("A build identifier")
buildInfo := s"${organization.value}:${name.value}:${version.value}"
// scoping: version differs per configuration
Compile / scalacOptions ++= Seq("-deprecation", "-feature")
Test / scalacOptions ++= Seq("-Wunused:all")
// keep everything in one file for a single module
lazy val root = (project in file("."))
.settings(
name := "shop",
buildInfo,
Test / fork := true,
Test / parallelExecution := true
)- A setting (
:=) is evaluated once per sbt session; a task runs on every invocation. Using a task where a setting belongs is a common source of confusing output. - Scopes read left to right: configuration, then project, then task.
Compile / scalacOptionsmeans "for the compile configuration of this project". .valueextracts a setting or task result and creates the dependency edge. Without it the two are unrelated.~compilere-runs the task whenever a watched source file changes;reloadpicks up build definition changes.
Multi-module builds
// a root aggregator plus modules with a real dependency direction
lazy val core = (project in file("modules/core"))
.settings(
name := "shop-core",
libraryDependencies += "org.typelevel" %% "cats-core" % "2.12.0"
)
lazy val api = (project in file("modules/api"))
.dependsOn(core) // api can see core's main sources
.settings(
name := "shop-api",
libraryDependencies ++= Seq(
"org.http4s" %% "http4s-ember-server" % "0.23.27",
"org.http4s" %% "http4s-dsl" % "0.23.27"
)
)
lazy val root = (project in file("."))
.aggregate(core, api) // one command builds all of them
.settings(
name := "shop-root",
publish / skip := true // the aggregator itself is not published
)
// test-only dependency, and a dependency shared by every module
// .dependsOn(core % "compile->compile;test->test") // share test helpers too| Directive | Meaning | Use for |
|---|---|---|
.dependsOn(a) | Compile and runtime dependency | The usual module edge |
.aggregate(a) | Run the task in a too | A root project that builds everything |
%% | Append the Scala binary version | Any Scala library |
% | No cross-version suffix | Pure Java libraries |
% Test | Test configuration only | Test frameworks and generators |
% Provided | Compile only, not packaged | APIs supplied by the runtime, such as Spark |
Keep the dependency direction acyclic and pointing inward: the domain module knows nothing about the HTTP module. sbt does not prevent a cycle in the build graph, but the compiler will refuse one at the source level, which is a worse error to diagnose.
Cross-building and publishing
// cross-build for both Scala lines
ThisBuild / crossScalaVersions := Seq("3.5.0", "2.13.14")
// publish to a company repository
publishTo := Some("Company" at "https://repo.example.com/maven-releases")
credentials += Credentials(Path.userHome / ".sbt" / ".credentials")
// a library that must not ship test sources
Compile / packageBin / packageOptions +=
Package.ManifestAttributes("Implementation-Version" -> version.value)sbt +compile # compile for every crossScalaVersions entry
sbt +publishLocal # install into the local Ivy repository
sbt evicted # show which dependency version won and why
sbt dependencyTree # the full resolution graph
sbt ";clean;test" # several commands in one invocation⚠️
The Scala binary version must match between your code and every Scala library you depend on. Mixing an artifact built for
_2.13 with a _3 build produces NoClassDefFoundError or a binary-incompatible MethodNotFoundError at run time, not at compile time.FAQ
What is the difference between compile and Test scope?
A
compile dependency is available to main sources and is packaged with the artifact. A Test dependency is visible only to test sources and never shipped, which is where test frameworks belong.Why does sbt say the dependency version was evicted?
The resolution graph contained several versions of the same artifact and sbt picked one. Run
sbt evicted and either align the versions or add an explicit dependencyOverrides entry to state your choice.Related
Setting up Scala 3: Coursier, Scala CLI and sbt Testing with ScalaTest and MUnit
Last refreshed 2026-09-18.