08 · sbt Deep Dive¶
Every project so far has used a single-project build.sbt with one or two
settings. This module covers the sbt features you'll actually reach for on
a real project: adding library dependencies properly, splitting a codebase
into multiple modules, adding plugins, and writing your own custom tasks.
Anatomy of build.sbt¶
// build.sbt
ThisBuild / scalaVersion := "3.3.3"
ThisBuild / version := "0.1.0-SNAPSHOT"
lazy val root = (project in file("."))
.settings(
name := "myapp",
libraryDependencies += "com.lihaoyi" %% "upickle" % "3.3.1"
)
ThisBuild / settingapplies a setting to every project in the build, not just one —scalaVersionandversionalmost always belong here.project in file(".")defines a project whose sources live in the given directory (.for the build root).%%picks the library build matching your project's Scala binary version automatically (so"com.lihaoyi" %% "upickle" % "3.3.1"resolves toupickle_3against a Scala 3 project) — use plain%only for Scala-version-independent (pure Java) artifacts.% Test(seen in Module 5) scopes a dependency tosrc/test/scalaonly.
Multi-module builds¶
A real application often splits into a shared "core" module and one or more
consumers (a CLI, a web server, a test harness) that depend on it. Each
module gets its own project in file(...) and its own src/main/scala:
myapp/
├── build.sbt
├── project/
│ └── build.properties
├── core/
│ └── src/main/scala/Greeter.scala
└── cli/
└── src/main/scala/Main.scala
// build.sbt
ThisBuild / scalaVersion := "3.3.3"
lazy val core = (project in file("core"))
.settings(name := "myapp-core")
lazy val cli = (project in file("cli"))
.dependsOn(core) // cli can use anything public in core
.settings(
name := "myapp-cli",
libraryDependencies += "com.lihaoyi" %% "upickle" % "3.3.1"
)
lazy val root = (project in file("."))
.aggregate(core, cli) // running a task on root runs it on both
.settings(name := "myapp")
// core/src/main/scala/Greeter.scala
package myapp.core
object Greeter:
def greet(name: String): String = s"Hello, $name, from core!"
// cli/src/main/scala/Main.scala
import myapp.core.Greeter
@main def cliMain(): Unit = println(Greeter.greet("world"))
dependsOn and aggregate solve two different problems that look similar
at first: dependsOn(core) gives cli's code access to core's classes
(a compile-time dependency); aggregate(core, cli) just means "running
compile/test/etc. on root should also run it on these projects" (a
command fan-out, with no code sharing implied). Forgetting dependsOn
and only using aggregate is a common mistake — the modules will build
independently but cli won't actually be able to import anything from
core.
sbt "cli/run" (with the module name prefixed) runs a task scoped to one
specific module — plain sbt run in a multi-module build asks sbt to
figure out which project has a runnable main, which is ambiguous once you
have more than one.
Plugins¶
Plugins add new tasks and settings to sbt itself, declared in
project/plugins.sbt (a separate, meta-level build file — notice it's
inside the project/ directory, not the project root). A very common one
is sbt-assembly, which bundles a module and all its dependencies into a
single runnable "fat jar":
sbt "cli/assembly"
# ...
# [info] Built: cli/target/scala-3.3.3/myapp-cli-assembly-0.1.0-SNAPSHOT.jar
java -jar cli/target/scala-3.3.3/myapp-cli-assembly-0.1.0-SNAPSHOT.jar
# Hello, world, from core!
That jar bundles core's classes, cli's classes, and upickle's classes
all together — it runs with plain java -jar, with no sbt or Scala
installation required on the machine that runs it. This is exactly how
you'd package the Module 10 project for
distribution.
Custom tasks¶
A taskKey declares a brand-new sbt command with its own logic, defined in
build.sbt right alongside the project settings:
lazy val hello = taskKey[Unit]("prints a friendly greeting")
hello := {
println("Hello from a custom sbt task!")
}
Tasks can depend on other tasks and settings using .value inside the
task body — for example, a task that runs after compilation and reports how
many source files were compiled, or one that shells out to a code
formatter before compile runs. This is how most sbt plugins are built
internally: a plugin is really just a packaged set of custom taskKeys and
settingKeys, plus logic wiring them into existing tasks like compile or
test.
The trap: run/test scope confusion in multi-module builds¶
Once a build has more than one project, unscoped commands (compile,
run, test) apply to whichever project is currently the sbt shell's
active project (the root project, by default, unless you project clid
into another one first) — not necessarily the one you meant. Two
reliable habits avoid the confusion entirely:
- Prefix the module explicitly:
sbt "cli/run",sbt "core/test". - Or switch the active project inside an interactive sbt shell
(
sbtthenproject clithen plainrun), and switch back (project root) when you're done.
How It Actually Works¶
build.sbt isn't a config file in the INI/YAML sense — it's Scala source
code, evaluated by sbt's own embedded interpreter, that builds up a
dependency graph of settings and tasks. name := "hello-scala" is
itself a method call: := is defined on SettingKey, and it returns a
Setting[T] value describing "when this key is asked for, produce this
value" — sbt collects every such Setting across your build.sbt (and any
plugins) into one big graph before running anything. This is why sbt
startup and reload feels like a compile step: it genuinely is one — your
build definition is compiled once and cached, which is also why the first
sbt invocation after touching build.sbt is slow (recompiling the build
definition itself) while subsequent commands in the same session are fast.
Tasks (compile, run, test, and custom ones you define with .value)
are the dynamic counterpart to settings: where a setting is computed once
per session and cached, a task re-runs its body every time it's invoked,
and its dependencies are wired up through the .value macro — writing
(Compile / sources).value inside a task body doesn't call a method at
runtime, the macro rewrites your task definition at compile time into an
explicit dependency edge in sbt's task graph, so sbt knows to run
sources first and thread its result in. This static graph-building is
also why you can't write if (cond) x.value else y.value naively — both
branches' dependencies get wired in regardless of which branch runs,
because the graph is fixed before any task executes.
Multi-module scoping (root/compile, sub-module/test) reflects the same
graph: each subproject gets its own axis in the settings/task key space
(project, configuration, task), and run/test without a scope resolve
against whichever project's build.sbt context you're currently in at the
sbt shell — the classic "trap" of running test at the aggregate root and
expecting it to mean one specific submodule comes directly from this
three-axis key resolution defaulting differently than you'd guess.
Cheat sheet¶
| Concept | Syntax | Purpose |
|---|---|---|
| Cross-versioned dependency | "org" %% "lib" % "1.0" |
Resolves the right Scala-version build automatically |
| Test-only dependency | "org" %% "lib" % "1.0" % Test |
Available only under src/test/scala |
| Multi-module project | lazy val x = (project in file("x")) |
One module, own source tree and settings |
| Code dependency between modules | .dependsOn(other) |
Lets this module import the other's classes |
| Command fan-out across modules | .aggregate(a, b) |
Running a task on the parent runs it on a and b too |
| Add a plugin | project/plugins.sbt → addSbtPlugin(...) |
New sbt-wide tasks/settings |
| Custom task | lazy val t = taskKey[Unit]("...") + t := { ... } |
Your own sbt <name> command |
| Run a specific module | sbt "modulename/run" |
Avoids ambiguity in multi-module builds |
Exercise¶
Turn a single-module project into two: a model module containing a
case class Product(name: String, price: Double) and a def
applyDiscount(p: Product, pct: Double): Product, and an app module that
dependsOn(model) and has a @main printing a discounted product. Add
sbt-assembly as a plugin and produce a runnable fat jar for app with
sbt "app/assembly". Finally, add one custom taskKey[Unit] called
banner to the root build that prints your project's name and version
(read name.value and version.value inside the task body), and confirm
sbt banner prints it.