Revisit Dependency Injection in Scala

Naoki Takezoe
5 min readJul 20, 2019

--

Dependency Injection (DI) is a common pattern in modern software development. In short, DI is wiring objects. It allows us to separate interface and implementation, but more important thing is we can replace objects depending on the situation. The most obvious use case is replacing objects by mock for testing.

There are various DI containers in any programming languages. For example, Spring and Guice are really common in Java. Of course, we have some options in Scala as well. Scala works on JVM and it has high interoperability with Java, so we can use Spring and Guice even in Scala. Actually, Play Framework which is a full-stack web application framework for Scala adopts Guice as DI container. However, since they are designed for Java, they can’t use all of the power of Scala.

Benefits of compile-time DI

On the other hand, since Scala is a powerful and flexible language, we can achieve similar stuff only with language features. One of them is called CakePattern which uses trait and self typing. CakePattern requires kind of boilerplate, but it allows to resolve object dependencies at compile-time. This means that it doesn’t cause DI related runtime error if compilation is successful.

As you know, we have more functional approaches for DI in Scala like Reader Monad and more advanced ways. Anyway, the characteristics of them like using only language features and resolving everything at compile-time (also some boilerplates are necessary :-P) are similar to CakePattern. Of course, making it using only language functionality is a great advantage of these approaches and it also shows the power of Scala as a programming language.

We also have Scala native DI library like MacWire.

MacWire is macro that generates instance creation code rather than DI container. So it resolves object dependency at compile-time as same as CakePattern. We call this type of DI as compile-time DI. Compile-time DI is well fit to Scala’s statically typing culture. It resolves everything at compile-time as much as possible to reduce the risk of errors at runtime.

import com.softwaremill.macwire._trait SampleModule {
lazy val userRepository = wire[UserRepository]
lazy val userService = wire[UserService]
}

Compile-time DI is great, but one problem in compile-time DI is that it’s difficult to manage the lifecycle of objects. In the real world applications, initialization and shutting down processes for each object can be necessary for resource allocation and release. Certainly, compile-time DI solves wiring objects, but it doesn’t care about lifecycle management.

Airframe: Another DI container for Scala

Finally, I can introduce Airframe, another DI container in Scala. Airframe offers runtime DI, but it allows us to get benefits from Scala language.

Airframe supports constructor injection. It’s very similar to Guice but any annotation is not necessary.

class UserService(userRepository: UserRepository) {
def get(userId: Long): Option[User] = userRepository.get(userId)
}

We can get an instance of this class as follows:

val d: Design = newDesignd.withSession { s: Session =>
val userService = s.build[UserService]
val user = userService.get(1)
}

Airframe creates instances of dependent objects automatically as much as possible. This means that you don’t need to register objects to Airframe explicitly normally. Only if objects can’t be created automatically or if you want to replace these instances with other implementation, you have to register objects explicitly to Design as follows:

val d = newDesign
.bind[UserRepository].to[MockUserRepository]

Airframe also supports in-trait DI. In this case, we have to use a special notation to declare dependent objects in traits. This makes traits depend on Airframe, but we can put together the necessary traits as we need, instead of lining up all necessary objects as constructor parameters.

trait UserService {
val userRepository = bind[UserRepository]
def get(userId: Long): Option[User] = userRepository.get(userId)
}

One advantage of runtime DI is lifecycle management. With constructor injection, we can use annotations to specify lifecycle management methods. These methods are called when an object is created and destroyed. This kind of lifecycle management can be made by managing objects by the container.

trait UserRepository {
@PostConstruct
def init {
...
}

@PreDestroy
def stop {
...
}
}

With in-trait injection, we can do the same things as follows:

trait UserService {
val userRepository = bind[UserRepository]
.onStart { x =>
...
}
.onShutdown { x =>
...
}
)
}

Initialization processes are run in the order of object dependencies, and shutting down processes are run in the opposite. We don’t need to care about the execution order of them.

In my opinion, less configuration and lifecycle management are the main advantages of Airframe although Airframe isn’t compile-time DI. On the other hand, runtime DI may cause some problems like a runtime binding error, unsafe to remove components and runtime overhead.

Mitigate runtime DI problems

To mitigate problems which can happen at runtime debug or to help to solve these problems easily, Airframe provides some features. For example, Airframe shows a line number of in-trait injection in the error message if binding failed.

[MISSING_DEPENDENCY] Binding for String at AirframeInTraitSample.scala:12 is not found: String <- UserRepository

Also, Airframe provides statistics of components usage so that we can find unused components easily.

[coverage]
design coverage: 50.0%
[unused types]
ItemService
[access stats]
[UserService] init:1, inject:1
[UserRepository] init:1, inject:1

Another interesting feature for the debug is component lifecycle tracing. Airframe has ChromeTracer as a default implementation of Tracer. It generates a JSON file and we can visualize it using Chrome browser so that we can know component dependencies and which component takes a long time to initialize.

We have many options for Dependency Injection in Scala other than introduced in this article. I don’t say what is the best solution because it highly depends on the various factors like the type of application, the strategy of development and knowledge of the team. However, knowing various options would be a help to make the right decision in your situation.

--

--