How I SBT - IV

HKT HKT
Views
Article hero image

Previously, we discussed Plugin s. Today, let us see how to better organize build code.

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 for Http4s and Testing. Not so much for Cats.
    • 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.


  1. I have not come across a situation where I had to use different versions for the same dependency/module. ↩︎

scala sbt series