Skip to main content

Documentation Index

Fetch the complete documentation index at: https://mintlify.com/sbt/sbt/llms.txt

Use this file to discover all available pages before exploring further.

Overview

Multi-project builds allow you to organize related projects within a single build. This is useful for modular applications, libraries with multiple components, or monorepo structures.

Defining Projects

From the sbt source (main-settings/src/main/scala/sbt/Project.scala:129-154):
sealed trait Project extends ProjectDefinition[ProjectReference] {
  /** Adds classpath dependencies on internal or external projects. */
  def dependsOn(deps: ClasspathDep[ProjectReference]*): Project =
    copy(dependencies = dependencies ++ deps)

  /** Adds projects to be aggregated. */
  def aggregate(refs: ProjectReference*): Project =
    copy(aggregate = (aggregate: Seq[ProjectReference]) ++ refs)

  /** Appends settings to the current settings sequence. */
  def settings(ss: Def.SettingsDefinition*): Project =
    copy(settings = (settings: Seq[Def.Setting[?]]) ++ Def.settings(ss*))

  /** Sets the base directory for this project. */
  infix def in(dir: File): Project = copy(base = dir)
}

Basic Multi-Project Structure

// Define multiple projects
lazy val root = (project in file("."))
  .aggregate(core, util, app)
  .settings(
    name := "my-project-root"
  )

lazy val core = project
  .in(file("core"))
  .settings(
    name := "core",
    libraryDependencies += "org.typelevel" %% "cats-core" % "2.10.0"
  )

lazy val util = project
  .in(file("util"))
  .dependsOn(core)
  .settings(
    name := "util"
  )

lazy val app = project
  .in(file("app"))
  .dependsOn(core, util)
  .settings(
    name := "app",
    mainClass := Some("com.example.Main")
  )
Directory structure:
my-project/
├── build.sbt
├── core/
│   └── src/main/scala/
├── util/
│   └── src/main/scala/
└── app/
    └── src/main/scala/

Project References

Using project in file(...)

The in method sets the base directory for a project:
lazy val core = project.in(file("core"))
// Equivalent to:
lazy val core = project in file("core")
If the project identifier matches the directory name, you can omit .in(file("name")):
lazy val core = project  // Uses directory "core/"

Aggregation

Aggregation means that running a task on an aggregate project also runs it on aggregated projects. From the sbt build.sbt:164-168:
lazy val sbtRoot: Project = (project in file("."))
  .aggregate(
    (allProjects diff Seq(lmCoursierShaded))
      .map(p => LocalProject(p.id))*
  )

How Aggregation Works

lazy val root = (project in file("."))
  .aggregate(core, util)

lazy val core = project
lazy val util = project
When you run sbt root/compile:
  1. Compiles core
  2. Compiles util
  3. Compiles root
Aggregation is transitive: if A aggregates B and B aggregates C, then running a task on A runs it on B and C.

Selective Aggregation

lazy val root = (project in file("."))
  .aggregate(core, util)
  .settings(
    // Disable aggregation for specific tasks
    publish / aggregate := false,
    publishLocal / aggregate := false
  )

Project Dependencies

Using dependsOn

The dependsOn method creates a classpath dependency between projects:
lazy val core = project

lazy val util = project
  .dependsOn(core)
  // util can now use classes from core
From build.sbt:241-243:
val collectionProj = project
  .in(file("util-collection"))
  .dependsOn(utilPosition, utilCore)

Configuration-Specific Dependencies

lazy val core = project

lazy val testUtil = project
  .dependsOn(core % "test->test")  // testUtil tests depend on core tests

lazy val app = project
  .dependsOn(
    core % "compile->compile",      // Default
    testUtil % "test->compile"       // app tests depend on testUtil compile
  )
Common dependency configurations:
  • "compile->compile" - Default, production code depends on production code
  • "test->test" - Test code depends on test code
  • "test->compile" - Test code depends on production code
  • "compile->test" - Rarely used, production depends on test code

Classpath Dependencies

From the sbt source (main-settings/src/main/scala/sbt/ClasspathDep.scala):
sealed trait ClasspathDep[PR <: ProjectReference] {
  def project: PR
  def configuration: Option[String]
}
Create custom classpath dependencies:
lazy val app = project
  .dependsOn(
    core,
    util % "compile->compile;test->test",
    integration % "it->compile"
  )

Real-World Example from sbt

From build.sbt, the sbt project structure:
lazy val sbtRoot: Project = (project in file("."))
  .aggregate(
    (allProjects diff Seq(lmCoursierShaded))
      .map(p => LocalProject(p.id))*
  )
  .settings(
    minimalSettings,
    Utils.noPublish,
    publishLocal := {},
    mimaPreviousArtifacts := Set.empty
  )

lazy val collectionProj = project
  .in(file("util-collection"))
  .dependsOn(utilPosition, utilCore)
  .settings(
    name := "Collections",
    testedBaseSettings,
    libraryDependencies ++= Seq(sjsonNewScalaJson.value),
    libraryDependencies ++= Seq(scalaPar),
    mimaSettings,
    conflictWarning := ConflictWarning.disable
  )

lazy val bundledLauncherProj =
  (project in file("launch"))
    .enablePlugins(SbtLauncherPlugin)
    .settings(
      minimalSettings,
      inConfig(Compile)(Transform.configSettings)
    )
    .settings(
      name := "sbt-launch",
      moduleName := "sbt-launch",
      description := "sbt application launcher",
      autoScalaLibrary := false,
      crossPaths := false
    )

Project References Types

LocalProject

References a project in the current build by ID:
lazy val core = project

lazy val app = project
  .dependsOn(LocalProject("core"))  // Reference by ID

RootProject

References the root project of another build:
lazy val app = project
  .dependsOn(RootProject(file("../other-project")))

ProjectRef

References a specific project in another build:
lazy val app = project
  .dependsOn(ProjectRef(file("../other-build"), "subproject"))

Cross-Project Settings

Sharing Settings

Define common settings and reuse them:
val commonSettings = Seq(
  organization := "com.example",
  scalaVersion := "3.3.1",
  scalacOptions ++= Seq(
    "-deprecation",
    "-feature"
  )
)

lazy val core = project
  .settings(commonSettings)
  .settings(
    name := "core"
  )

lazy val util = project
  .settings(commonSettings)
  .settings(
    name := "util"
  )
From build.sbt:63-113:
def commonSettings: Seq[Setting[?]] = Def.settings(
  headerLicense := Some(
    HeaderLicense.Custom(
      """sbt
        |Copyright 2023, Scala center
        |Copyright 2011 - 2022, Lightbend, Inc.
        |Copyright 2008 - 2010, Mark Harrah
        |Licensed under Apache License 2.0 (see LICENSE)
        |""".stripMargin
    )
  ),
  scalaVersion := baseScalaVersion,
  evictionErrorLevel := Level.Info,
  resolvers += Resolver.sonatypeCentralSnapshots,
  testFrameworks += TestFramework("hedgehog.sbt.Framework"),
  // ...
)

def baseSettings: Seq[Setting[?]] =
  minimalSettings ++ 
  Seq(Utils.projectComponent) ++ 
  Utils.baseScalacOptions ++ 
  Licensed.settings

ThisBuild Settings

Settings that apply to all projects:
ThisBuild / organization := "com.example"
ThisBuild / version := "1.0.0"
ThisBuild / scalaVersion := "3.3.1"

lazy val core = project  // Inherits ThisBuild settings
lazy val util = project  // Inherits ThisBuild settings

Project Configuration

Adding Configurations

From ProjectExtra.scala:162-175:
extension (self: Project)
  /** Adds configurations to this project. */
  def overrideConfigs(cs: Configuration*): Project =
    self.copy(
      configurations = Defaults.overrideConfigs(cs*)(self.configurations),
    )

  /** Adds configuration at the *start* of the configuration list. */
  private[sbt] def prefixConfigs(cs: Configuration*): Project =
    self.copy(
      configurations = Defaults.overrideConfigs(self.configurations*)(cs),
    )
Custom configurations:
val IntegrationTest = config("it") extend(Test)

lazy val app = project
  .configs(IntegrationTest)
  .settings(
    inConfig(IntegrationTest)(Defaults.testSettings)
  )
In the sbt shell:
# List all projects
sbt> projects

# Switch to a project
sbt> project core

# Run task on specific project
sbt> core/compile
sbt> util/test

# Run task on all projects
sbt> compile  # From root, compiles all aggregated projects

Build-Level Settings

Commands and Hooks

lazy val root = (project in file("."))
  .settings(
    onLoadMessage := {
      s"""
        |Welcome to ${name.value} ${version.value}
        |Type 'help' for available commands
      """.stripMargin
    },
    commands += Command.command("hello") { state =>
      println("Hello from custom command!")
      state
    }
  )
From build.sbt:171-185:
lazy val sbtRoot: Project = (project in file("."))
  .settings(
    onLoadMessage := {
      val version = sys.props("java.specification.version")
      """           __    __
        |     _____/ /_  / /_
        |    / ___/ __ \/ __/
        |   (__  ) /_/ / /_
        |  /____/_.___/\__/
        |Welcome to the build for sbt.
        |""".stripMargin +
        (if (version != "17")
           s"""!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
               |  Java version is $version. We recommend java 17.
               |!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!""".stripMargin
         else "")
    }
  )

Auto Plugins

Enabling Plugins per Project

lazy val core = project
  .enablePlugins(ScalafmtPlugin, MimaPlugin)
  .disablePlugins(AssemblyPlugin)

lazy val app = project
  .enablePlugins(JavaAppPackaging, DockerPlugin)

Practical Patterns

Library with Multiple Modules

lazy val root = (project in file("."))
  .aggregate(core, io, http)
  .settings(
    publish / skip := true  // Don't publish root
  )

lazy val core = project
  .settings(
    name := "mylib-core",
    libraryDependencies += "org.typelevel" %% "cats-core" % "2.10.0"
  )

lazy val io = project
  .dependsOn(core)
  .settings(
    name := "mylib-io",
    libraryDependencies += "co.fs2" %% "fs2-io" % "3.9.3"
  )

lazy val http = project
  .dependsOn(core, io)
  .settings(
    name := "mylib-http",
    libraryDependencies += "org.http4s" %% "http4s-dsl" % "0.23.23"
  )

Application with Service Modules

lazy val root = (project in file("."))
  .aggregate(domain, database, api, app)
  .settings(
    publish / skip := true
  )

lazy val domain = project
  .settings(
    name := "domain",
    libraryDependencies += "org.typelevel" %% "cats-core" % "2.10.0"
  )

lazy val database = project
  .dependsOn(domain)
  .settings(
    name := "database",
    libraryDependencies += "org.tpolecat" %% "doobie-core" % "1.0.0-RC4"
  )

lazy val api = project
  .dependsOn(domain, database)
  .settings(
    name := "api",
    libraryDependencies += "org.http4s" %% "http4s-dsl" % "0.23.23"
  )

lazy val app = project
  .dependsOn(api)
  .enablePlugins(JavaAppPackaging)
  .settings(
    name := "app",
    mainClass := Some("com.example.Main")
  )

Best Practices

Aggregate at the root: Create a root project that aggregates all subprojects for convenient task execution.
Use consistent naming: Name your project identifiers to match directory names for clarity.
Avoid circular dependencies: Projects cannot depend on each other in a cycle. Structure your dependencies as a DAG (directed acyclic graph).
Share common settings: Extract common settings into vals or use ThisBuild scope to avoid repetition.
// Good - DRY principle
val commonSettings = Seq(
  scalaVersion := "3.3.1",
  scalacOptions ++= Seq("-deprecation", "-feature")
)

lazy val core = project.settings(commonSettings)
lazy val util = project.settings(commonSettings)

// Bad - repetitive
lazy val core = project.settings(
  scalaVersion := "3.3.1",
  scalacOptions ++= Seq("-deprecation", "-feature")
)
lazy val util = project.settings(
  scalaVersion := "3.3.1",
  scalacOptions ++= Seq("-deprecation", "-feature")
)

References