Previously, we discussed Plugin s. Today, let us see how to better organize build code.
- How I SBT -
build.sbt
- How I SBT - Settings & Tasks
- How I SBT - Plugins
- How I SBT - Build Code Organization
- How I SBT - Multi-module Builds
- How I SBT - Publishing a Plugin
- How I SBT - Final Edition - a la carte
Build Code Organization
Build code involves everything SBT will consume for the build
- SBT files -
build.sbt
,plugins.sbt
and any other - Scala files. Wait, what?
Yep, you can have SBT consume Scala files with regular Scala code. But the Scala files must be in the project/
folder.
One thing I certainly do in any project is to declare all dependencies (module references) in one or more Scala files so that all projects in the build use the same1 reference. Also, I like to split the modules into third party and proprietary.
root of the project
├ ...
├ project/
├ ThirdPartyDependencies.scala
├ ProprietaryDependencies.scala
├ plugins.sbt
├ build.properties
├ build.sbt
I will focus on third party, and let you imagine the same for proprietary.
import sbt.*
object ThirdPartyDependencies {
val Decline = "com.monovore" %% "decline" % "2.4.0"
val Ficus = "com.iheart" %% "ficus" % "1.4.3"
object Cats {
val Core = "org.typelevel" %% "cats-core" % "2.12.0"
val Effect = "org.typelevel" %% "cats-effect" % "3.5.5"
val Parse = "org.typelevel" %% "cats-parse" % "1.0.0"
// ...
}
object Http4s {
val Version = "0.23.29"
val Dsl = "org.http4s" %% "http4s-dsl" % Version
val EmberClient = "org.http4s" %% "http4s-ember-client" % Version
val EmberServer = "org.http4s" %% "http4s-ember-server" % Version
val All: List[ModuleID] = Dsl :: EmberClient :: EmberServer :: Nil
}
object Testing {
val ScalaTest = "org.scalatest" %% "scala-test" % "3.12.19" % Test
val ScalaCheck = "org.scalacheck" %% "scalacheck" % "1.14.1" % Test
// ...
val All: List[ModuleID] = ScalaCheck :: ScalaTest :: Nil
}
}
- Dependencies are called Modules in SBT jargon. You can see I have grouped them based on either the ecosystem or functionality;
Http4s
,Cats
,Testing
etc. There are discrete ungrouped dependencies too. - Grouping offers the following advantages:
- Keeps the related modules of a certain ecosystem together. Easier to locate related modules.
- Provides a single place to update versions. No human error updating one version of a module and not another of the ecosystem. For instance,
Http4s
. - Provides the opportunity to define
All
that includes all the libraries of the ecosystem that are relevant to your project.All
makes sense forHttp4s
andTesting
. Not so much forCats
. - The projects in your build definition then refer them by name and leave the mechanics out of the picture. Cleaner presentation. Easy comprehension. In practice, you may not be interested to see the module definition every day every time you look at the build definition. They are available in crisp detail when you want to take a deeper look.
// In build.sbt import ThirdPartyDependencies.* import ThirdPartyDependencies.Testing.* libraryDependencies ++= Cats.Core :: Cats.Effect :: Decline :: Ficus :: Https.All ::: ScalaCheck :: ScalaTest :: Nil
- I order the modules alphabetically, both grouped and ungrouped. I declared the ungrouped first before grouped ones.
- I configured scalafmt to align assignments and symbols like
%
to align in columns. I find it easier to read and make changes.
Plugins
If you know the set of plugins your build must apply for all the projects, you can do the following:
// In your project/ folder
import sbt.*
object AutoApplyPlugin extends AutoPlugin {
override val trigger = allRequirements // Automatically activate this plugin
override val requires =
MyCompilerOptionsPlugin &&
SbtScalafmtPlugin &&
// ... other plugins
}
The above plugin is one-stop shop to enable all the Plugin s your build/project needs. It is automatically activated during project loading, and does not require an explicit mention in .enablePlugins
. Another way to keep your build.sbt
less noisy.
Declaring build dependencies
Didn’t we already see that? libraryDependencies
?
Remember that was in build.sbt
, which was at the root folder of the project. Those are the dependencies for your application. Here, I am talking about dependencies that the build/task may have to use. A common use case is to define / run a task that may have to use a certain library.
Imagine you want to write task that initializes the database of your application with tables and seed data. Typically, you would use Flyway or Liquibase. You would write a setupDB
task in your build.sbt
to use Flyway.configure
or the like. Additionally, you could throw all the Scala code related to setting up DB in project/SetupDB.scala
. Or throw it all in a Plugin 😉
How would SBT know where to refer Flyway
object? In order to let SBT know about the dependencies that it can use for itself when running tasks or build is to create a build.sbt
in the project/
folder. In other words, we are creating a meta build 🤯
// project/build.sbt
libraryDependencies +=
"org.flywaydb" % "flyway-core" % "<version>" ::
// ... add relevant DB trim for flyway
Nil
Cliffhanger
Today, I showed you various ways to organize build code. SBT is great in providing different ways to keep your build code organized. I am a fan of meta build amongst others.
Next time, I will talk about another gold in SBT - Multi-modules! Multi-modules let you split your application or libary code into multiple projects of the same build.
-
I have not come across a situation where I had to use different versions for the same dependency/module. ↩︎