Appearance
Bali DI for Scala
Bali DI for Scala is a pure def macro which transforms the abstract syntax tree to automate dependency injection. It currently supports Scala 2.12 and 2.13, with Scala 3 on the roadmap.
This project is a sibling of Bali DI for Java. As a sibling, it is based on the exact same concepts and aims for feature parity.
NOTE
Bali is also an island between Java and Lombok in Indonesia. For disambiguation, the name of this project is "Bali DI", not just "Bali", where DI is an acronym for dependency injection. In code however, the term "DI" is dropped because there is no ambiguity in this context.
Getting Started
Bali DI for Scala is implemented as a def macro for the Scala compiler. If you use SBT, you need to add the following dependency to your project:
scala
libraryDependencies += "global.namespace.bali" %% "bali-scala" % "0.5.4" % Provided
NOTE
This is a compile-time-only dependency - there is no runtime dependency of your code on Bali DI for Scala!
Sample Code
This project uses a modular build. The module bali-scala-sample
provides lots of sample code showing how to use the annotations and generated source code. The module is not published on Maven Central however - it is only available as source code. You can browse the source code here or clone this project.
The module is split into different packages with each package representing an individual, self-contained showcase. For example, the package bali.scala.sample.greeting
showcases a glorified way to produce a simple "Hello world!" message by using different components with dependency injection.
Tutorial
WeatherStation
NOTE
The source code shown in this section is available on GitHub.
Let's define our first component:
scala
trait Clock {
def now: java.util.Date
}
In a component type, every abstract member is a dependency, so the Clock
trait has a single dependency named now
with type Date
. We want our clock to return a fresh Date
on every call, so now
is declared as a def
. This component doesn't have any domain logic, so there are no other members.
Next, let's look at a more advanced component and its companion object:
scala
import bali.Lookup
trait Temperature[U <: Temperature.Unit] {
@Lookup("tempValue")
val value: Float // (1)
@Lookup("tempUnit")
val unit: U // (2)
override def toString: String = f"$value%.1f˚ $unit"
}
object Temperature {
sealed trait Unit
type Celsius = Celsius.type
object Celsius extends Unit { override def toString: String = "C" }
type Fahrenheit = Fahrenheit.type
object Fahrenheit extends Unit { override def toString: String = "F" }
}
The component trait Temperature
has two abstract members and thus, two dependencies:
value
, with typeFloat
.unit
, with typeU <: Temperature.Unit
.
We want our temperature instances to be immutable, so both members are declared as a val
.
The @Lookup
annotation on each dependency defines an alias name for it. This is used to bind each dependency to some element in some module context - more on that later.
Next, let's look at our last component:
scala
trait WeatherStation[U <: Temperature.Unit] extends Clock {
def temp: Temperature[U]
override def toString: String = s"$now: $temp"
}
This component also has two dependencies:
now
, with typeDate
, inherited from traitClock
.temp
, with typeTemperature[U]
.
We want our weather station to return a fresh Temperature
on each call, so temp
is declared as a def
again.
NOTE
The component trait WeatherStation
reuses the component trait Clock
by inheritance and the component trait Temperature
by composition: There is no limitation on how you arrange component types into a larger dependency graph. Because dependencies are resolved just-in-time, your dependency graph may even be circular!
Eventually, all the abstract methods in the components you've seen so far get implemented in a very simple manner by forwarding the call as-is to some module context. So for example, the abstract method now
in the component trait Clock
gets implemented as follows:
scala
final override def now: Date = context.now
Likewise, if a dependency is declared as a val
, then it gets implemented as a lazy val
. So for example, the abstract method value
in the component trait Temperature
gets implemented as follows:
scala
final override lazy val value: Float = context.tempValue
Note that the implementation respects the alias defined by the @Lookup("tempValue")
annotation before.
NOTE
A dependency may have type parameters and parameter lists: The parameter lists are also forwarded in this case - more on that later.
In Scala, a module context is either:
- Any place in your code where you call the
def
macrobali.scala.make
, or - any type annotated with
@bali.Module
.
The make
macro implements a module in place by creating an instance of an anonymous inner class which extends it type parameter and then implementing all abstract methods as shown before.
Let's look at an example:
scala
import bali.scala.make
object MyApp extends App { context =>
def now = new Date
val clock = make[Clock] // (1)
}
The line with the call to make[Clock]
gets expanded as follows:
scala
val clock = new Clock {
final override def now: Date = context.now
}
NOTE
If the type to make isn't abstract, then no anonymous inner class is created, and the default constructor is called instead. So for example, a call to make[Date]
simply gets expanded to new Date
.
With this in mind, let's look at the module type for our weather station:
scala
import bali.Module
import java.util.concurrent.ThreadLocalRandom
@Module
trait WeatherStationModule {
val april: WeatherStation[Temperature.Celsius] // (1)
protected def now: Date // (2)
protected def temp: Temperature[Temperature.Celsius] // (3)
protected def tempValue: Float = ThreadLocalRandom.current.nextDouble(5d, 25d).toFloat
protected final val tempUnit = Temperature.Celsius
}
Like a component type, a module type can also have abstract members (numbered 1 to 3 in this case). The difference is that in a module type, its abstract methods get implemented as a recursive call to the make
macro. So for example, the abstract method now
(no. 2 in the previous example) gets implemented as follows:
scala
final override protected def now: Date = make[Date] // (2)
The call to make[Date]
is then recursively expanded to new Date
.
NOTE
Why is this a recursive call? Because you need to call make[WeatherStationModule]
in order to let it implement the method now
in the first place!
Finally, let's look at some test code for our module:
scala
import bali.scala.make
import bali.scala.sample.weatherstation.Temperature.Celsius
import org.scalatest.matchers.should.Matchers._
import org.scalatest.wordspec.AnyWordSpec
import java.util.Date
class WeatherStationModuleSpec extends AnyWordSpec {
"A WeatherStationModule" should {
val module = make[WeatherStationModule] // (1)
import module._
"report typical April weather" in {
april.now should not be theSameInstanceAs(april.now)
new Date should be <= april.now
val temp = april.temp
temp should not be theSameInstanceAs(april.temp)
temp.value shouldBe temp.value
temp.value should be >= 5f
temp.value should be < 25f
temp.unit shouldBe temp.unit
temp.unit shouldBe Celsius
}
}
}
Putting all the puzzle pieces together, we can conclude that the line with the call to make[WeatherStationModule]
gets expanded by the macro as follows:
scala
val module = new WeatherStationModule {
final override lazy val april: WeatherStation[Celsius] = make[WeatherStation[Celsius]]
final override protected def now: Date = make[Date]
final override protected def temp: Temperature[Celsius] = make[Temperature[Celsius]]
}
Next, another round is started to recursively expand the generated make[...]
calls and form the final result:
scala
val module = new WeatherStationModule { context =>
final override lazy val april: WeatherStation[Celsius] = new WeatherStation[Celsius] {
final override def temp: Temperature[Celsius] = context.temp
final override def now: Date = context.now
}
final override protected def now: Date = new Date
final override protected def temp: Temperature[Celsius] = new Temperature[Celsius] {
final override lazy val value: Float = context.tempValue
final override lazy val unit: Celsius = context.tempUnit
}
}
As you can see, Bali DI automates the generation of a lot of boilerplate code for you, but there's much more to it than just that. For a general discussion of its design concept, features and benefits please check the Overview page.