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.