Skip to Content
Course content

34: Building with sbt Multi-Module Projects

Click on the "Edit" button in the top corner of the screen to edit your slide content.

I've noticed a recurring pattern with developers moving into larger Scala projects: they treat every logical separation of code as a reason to create a brand new, independent sbt project. You'll see a utils folder, a domain folder, and an api folder, each with its own build.sbt and its own separate lifecycle. When they want the api to use the domain, they start trying to publish local jars to Maven or manually tinkering with classpaths.

The "Separate Repository" Fallacy

The misconception is that "modular code" must equal "separate build projects." It doesn't. If you have three different projects that are always deployed together and developed by the same team, managing three separate build definitions is just overhead. You end up playing "version tag," where you update a method signature in the domain project, publish a snapshot, and then hope the api project picks up the change without a clean command.

It's a fragile way to work. You're adding friction to your inner loop—the time it takes to change a line of code and see the result. In reality, you want a single root project that orchestrates multiple sub-modules. This gives you the logical boundaries of separate projects but the developer experience of a single monolithic build.

Orchestrating Your Domain with Multi-Modules

Let's look at a real scenario. Imagine we're building a Payment Processing System. We need a payment-core module for the business logic, a payment-api for the REST endpoints, and a payment-cli for administrative scripts. We want the API and CLI to depend on Core, but Core should know absolutely nothing about how it's being called.

Instead of three folders with three build.sbt files, we use one root build.sbt. Here is how I would structure that:


lazy val commonSettings = Seq(
  scalaVersion := "3.3.1",
  organization := "com.paymentapp"
)

lazy val core = (project in file("core"))
  .settings(commonSettings)

lazy val api = (project in file("api"))
  .dependsOn(core)
  .settings(commonSettings)

lazy val cli = (project in file("cli"))
  .dependsOn(core)
  .settings(commonSettings)

lazy val root = (project in file("."))
  .aggregate(core, api, cli)
  .settings(commonSettings)

Notice the aggregate call on the root project. This is a nuance that often trips people up. dependsOn tells sbt that the api needs the core classpath to compile. aggregate tells sbt that when I run compile or test from the root directory, it should trigger those tasks for all the aggregated projects too. Without aggregate, running compile at the root does... well, nothing, because the root project itself has no code.

Managing Dependencies without the Circular Headache

One thing I want to warn you about is the "circular dependency trap." As your project grows, you'll be tempted to let core depend on api just for one specific utility class. Don't. sbt will throw a fit, and more importantly, your architecture is rotting. If you hit a circular dependency in a multi-module build, it's a flashing red light telling you that you need a fourth module—perhaps payment-shared—that both core and api can depend on.

I also recommend using a commonSettings sequence as shown above. It keeps your build file dry. If you decide to upgrade your Scala version or add a common compiler plugin, you change it in one place rather than hunting through five different module definitions. It's a small detail, but it saves a lot of frustration during maintenance.




📋 Practical Task

Refactoring a Monolithic Order Management System into Modules

You have been handed a monolithic Scala project where the database logic, the business rules, and the JSON serialization are all mixed together in one folder. Your task is to refactor the sbt structure into a multi-module project to enforce a strict dependency hierarchy.

Requirements:

  • Create a root build.sbt that manages three modules: order-model, order-persistence, and order-web.
  • The order-model module should be the base (no dependencies on other internal modules).
  • The order-persistence module must depend on order-model.
  • The order-web module must depend on both order-model and order-persistence.
  • The root project must be configured so that running test at the root level executes tests for all three modules.
  • Extract shared settings (like scalaVersion and organization) into a common variable to avoid repetition.

Verification: Your solution is correct if you can run sbt compile from the root and it successfully compiles the modules in the correct order (Model → Persistence → Web) without any manual navigation into subdirectories.

Rating
0 0

There are no comments for now.

to be the first to leave a comment.