Technology  /  Scala

🔴 Scala 21 guides · updated 2026

The JVM's hybrid OOP/functional language — immutability, pattern matching, and the type system that powers Spark and Akka, from first principles to production.

Setting Up a Scala Project: sbt, build.sbt, and Scala CLI Explained

A single scala Hello.scala command is enough to learn syntax, but no real Scala codebase runs that way. Real projects have a directory structure sbt expects, a build.sbt file declaring dependencies and compiler settings, and a test suite wired into the build. This guide sets up a genuine multi-file sbt project and explains exactly what each configuration line does — not just what to type.


Installing Scala and sbt

Terminal window
# Recommended: the Coursier-based installer, handles Scala, sbt, and scala-cli together
curl -fL https://github.com/coursier/coursier/releases/latest/download/cs-x86_64-apple-darwin.gz | gzip -d > cs
chmod +x cs && ./cs setup
scala -version
sbt --version

Coursier (cs) is the modern, recommended way to install the whole Scala toolchain at once — it manages JVM versions, the Scala compiler, and sbt together, avoiding the version-mismatch headaches that came from separately installed pieces in Scala’s earlier tooling era. SDKMAN is a common alternative, particularly for teams already using it to manage JVM versions for Java projects.


The Standard sbt Project Layout

my-scala-project/
├── build.sbt
├── project/
│ ├── build.properties
│ └── plugins.sbt
├── src/
│ ├── main/
│ │ └── scala/
│ │ └── com/example/Main.scala
│ └── test/
│ └── scala/
│ └── com/example/MainSpec.scala
└── target/ ← compiled output, gitignored

This layout isn’t arbitrary — it’s the “Maven standard directory layout” convention that sbt inherited deliberately, specifically so JVM tooling built for Java projects (many IDE plugins, some CI conventions) works against Scala projects with zero extra configuration. src/main/scala holds application code; src/test/scala holds tests; target/ is entirely generated and should never be committed.


build.sbt, Explained Line by Line

build.sbt
ThisBuild / scalaVersion := "3.3.1"
ThisBuild / organization := "com.example"
lazy val root = (project in file("."))
.settings(
name := "my-scala-project",
version := "0.1.0-SNAPSHOT",
libraryDependencies ++= Seq(
"org.scalatest" %% "scalatest" % "3.2.17" % Test,
"com.typesafe" % "config" % "1.4.2"
)
)

ThisBuild / scalaVersion := "3.3.1" — sets the Scala compiler version for the entire build (the ThisBuild scope applies it to every project/module in a multi-module build, not just the current one). Pinning this explicitly matters because Scala’s compiler versions aren’t always drop-in compatible with each other, especially across the Scala 2/Scala 3 boundary.

lazy val root = (project in file(".")) — declares a project rooted at the current directory. lazy val matters here specifically because sbt’s build definition has its own dependency-resolution order, and lazy ensures the project value is only evaluated once its dependencies (other lazy val project definitions, in a multi-module build) are ready — a genuine val here can produce confusing initialization-order errors in anything beyond a single-module project.

libraryDependencies ++= Seq(...) — the dependency list. The %% operator (double percent) versus % (single) is a real, easy-to-miss distinction:

"org.scalatest" %% "scalatest" % "3.2.17" % Test // %% appends the Scala binary version automatically
"com.typesafe" % "config" % "1.4.2" // % — a plain Java library, no Scala version suffix needed

%% tells sbt to append the project’s Scala binary version to the artifact name automatically — "org.scalatest" %% "scalatest" % "3.2.17" actually resolves to the artifact scalatest_3 (for Scala 3) or scalatest_2.13 (for Scala 2.13), because Scala libraries are frequently published separately per major/minor Scala version due to binary incompatibility between them. Forgetting %% for a Scala library, or including it for a pure-Java library (which has no Scala-version-suffixed variants at all), is one of the most common build.sbt mistakes — the error message (“unresolved dependency”) doesn’t always make which mistake occurred obvious at a glance.

% Test — scopes the dependency to the test configuration only, meaning it’s available in src/test/scala but not compiled into the actual production artifact — the same idea as devDependencies in an npm package.json, or a test-scoped Maven dependency.


project/build.properties and project/plugins.sbt

project/build.properties
sbt.version=1.9.7
project/plugins.sbt
addSbtPlugin("org.scalameta" % "sbt-scalafmt" % "2.5.2")

build.properties pins the exact sbt version used to build the project — without it, developers on different machines (or CI, running a different default) could silently use different sbt versions, occasionally producing different build behavior. plugins.sbt lives in the project/ directory specifically because sbt’s own build definition is itself a Scala project one level up — plugins augment sbt itself (adding commands like formatting or linting), which is why they’re declared in a nested build rather than the top-level build.sbt.


Running Common sbt Commands

Terminal window
sbt compile # compile main sources only
sbt test # compile and run the test suite
sbt run # compile and run the app (if it has a main entry point)
sbt console # start a REPL with the project's classpath and dependencies loaded
sbt clean # delete the target/ directory
sbt ~compile # the ~ prefix watches files and recompiles automatically on save
Terminal window
# Interactive sbt shell — starting sbt once and running multiple commands avoids repeated JVM startup cost
sbt
> compile
> test
> run

The ~ prefix on any command (~compile, ~test) enables watch mode — sbt recompiles (or reruns tests) automatically every time a source file changes, which is the standard tight development loop for Scala, roughly equivalent to nodemon or a file-watching test runner in other ecosystems. Starting sbt once as an interactive shell and running commands inside it avoids paying sbt’s JVM startup cost (historically one of its most-criticized characteristics) on every single command.


Multi-Module Projects

// build.sbt for a project with a shared library and a separate application
lazy val core = (project in file("core"))
.settings(
name := "core",
libraryDependencies += "org.typelevel" %% "cats-core" % "2.10.0"
)
lazy val app = (project in file("app"))
.dependsOn(core)
.settings(
name := "app"
)

.dependsOn(core) makes the app module able to import and use everything core exposes — this is how real Scala projects (and Spark applications with shared utility code, in particular) split a large codebase into independently compilable, independently testable modules while still sharing code cleanly, without duplicating it or publishing an internal library to a package repository just to share it internally.

core module

(shared domain logic, no dependency on app)

app module

(depends on core, has the entry point)

core/src/test

(tests core in isolation)

app/src/test

(tests app, can use core's code too)


Scala CLI — The Lightweight Alternative

Terminal window
# A single-file script, no project structure needed at all
scala-cli run Hello.scala
# Adding a dependency inline, with no build.sbt
scala-cli run Hello.scala --dependency "com.typesafe:config:1.4.2"
//> using scala "3.3.1"
//> using dep "com.typesafe:config:1.4.2"
@main def hello(): Unit =
println("Hello with a dependency, no build.sbt needed")

The //> using directives at the top of a file are scala-cli’s own lightweight configuration mechanism — dependencies, Scala version, and compiler flags declared directly in the source file, avoiding a separate build file entirely for scripts and small tools. This genuinely is the better choice for a one-off script, a coding exercise, or quickly testing a library’s API — reach for full sbt once the project has more than a handful of files, needs a real test suite wired into CI, or needs multi-module structure.


A Realistic build.sbt for a Small Application

ThisBuild / scalaVersion := "3.3.1"
lazy val root = (project in file("."))
.settings(
name := "order-processor",
version := "0.1.0",
libraryDependencies ++= Seq(
"org.scalatest" %% "scalatest" % "3.2.17" % Test,
"com.typesafe" % "config" % "1.4.2",
"ch.qos.logback" % "logback-classic" % "1.4.14"
),
scalacOptions ++= Seq(
"-deprecation", // warn on use of deprecated APIs
"-feature", // warn when using a language feature that needs an explicit import
"-unchecked" // warn on unchecked type erasure issues
)
)

scalacOptions configures compiler warning/error behavior — -deprecation and -feature in particular surface real, actionable warnings that are easy to silently ignore without them explicitly enabled, and enabling them from the start of a project (rather than retrofitting them onto a large codebase later, where they can produce an overwhelming number of warnings at once) is standard practice for teams that care about code health.


IDE Setup: Metals

Terminal window
# VS Code: install the "Scala (Metals)" extension, then open the project folder
# Metals detects build.sbt automatically and imports the build on first open

Metals is the language server providing autocomplete, go-to-definition, inline type information, and real-time error checking for Scala across VS Code, Neovim, and other editors supporting the Language Server Protocol. On first opening a project, Metals runs an “import build” step — it asks sbt (or Mill, another less common Scala build tool) to report the project’s exact classpath and dependencies, which is why the very first open of a new or unfamiliar Scala project is often noticeably slower than subsequent ones: that import step is being cached for next time. IntelliJ IDEA’s Scala plugin is the other mainstream option, with its own independent build-import mechanism rather than Metals — either is a reasonable choice, and switching between them mid-project is uncommon but not harmful.

Common Setup Mistakes

SymptomLikely cause
”unresolved dependency” for a Scala libraryMissing %% — the Scala binary version suffix wasn’t appended automatically
sbt uses a different Scala version than expectedbuild.properties or scalaVersion mismatch between what’s declared and what’s actually installed
Test dependency accidentally bundled into the production artifactMissing % Test scope on a dependency that should only be available for tests
sbt compile is extremely slow on every single changeNot using ~compile (watch mode) or the interactive shell — restarting sbt fresh for every command re-pays JVM startup cost
A multi-module project can’t find code from another moduleMissing .dependsOn(otherModule) in the dependent project’s settings

Frequently Asked Questions

Do I need sbt for every Scala project? No — scala-cli handles single-file scripts and small tools well without any build file at all. Reach for sbt once you need multi-module structure, a real dependency management story with version conflict resolution, or CI integration that expects a standard build tool.

Why is sbt’s startup so slow compared to other build tools? sbt itself runs on the JVM and has historically had meaningful cold-start overhead loading its own build definition. Using the interactive shell (starting sbt once, running many commands inside it) avoids re-paying that cost repeatedly, which is the standard mitigation rather than something that’s been fully eliminated.

What’s the difference between % and %% in a dependency declaration? %% automatically appends the project’s Scala binary version to the library name, needed for any library that’s actually written in Scala and published per-Scala-version. % is for plain Java libraries (or the rare Scala library that deliberately publishes a version-independent artifact), which have no such per-version variants.

Should target/ be committed to git? No — it’s entirely generated build output (compiled classes, downloaded dependency caches in some configurations), and should always be in .gitignore, the same as node_modules or a Python venv.

Can I mix Scala 2 and Scala 3 code in one project? Not directly compiled together in the same module — but Scala 3’s compiler has a Scala 2.13 compatibility mode, and multi-module builds can mix modules on different Scala versions if their dependency relationships allow it. For most new projects, standardizing on Scala 3 throughout is simpler and is what this series assumes going forward.


What’s Next

You now have a real, working sbt project structure — not just a single-file script — with dependency management and a repeatable build. Next: Variables and Basic Types in Scala, where we cover val vs var, Scala’s type inference in depth, and why Scala treats if, for, and blocks as expressions rather than statements.