Demystifying Dependency Injection with Airframe
If I mention the word Dependency Injection (DI) in Scala, I might receive a lot of negative responses. This is largely because:
- Understanding the concept of Inversion of Control (IoC) is still difficult. To fully understand its real benefits, we also need actual coding experiences of using some DI framework.
- People recall bad experiences when using the Cake Pattern, or they might have suffered from steep-learning curve of huge IoC container frameworks like Spring, etc.
- This topic can easily lead to a war between purely-functional approach v.s. others, especially in the Scala world. For example, why do you need DI in Scala? I will try to answer this using this post.
For a while, let’s forget about dependency injection. It just addresses some of the common coding patterns that we need to solve in daily application development. For example, we need an efficient way to:
- Build service objects by passing required objects
- Minimize application code by removing unnecessary objects
- Manage service lifecycles, such as start-up and shutdown processes
If we can solve these issues conveniently, the choice of a DI framework (or not using any DI framework at all) doesn’t matter.
Airframe is a library of lightweight building blocks for Scala, which simplifies these programming concerns, and helps us to reduce the number of things to consider when building applications. More importantly while using Airframe we don’t need to use the word dependency injection at all.
My thought process is now more about how to build services by combining other service objects, rather than how to use DI framework itself. You can find more detailed examples in the documentation of Airframe, so in this article, I will focus on how Airframe can simplify your daily application development.
Building Service Objects
Let’s consider building a service object (Service) which takes a configuration object (ServiceConfig):
This code looks good at first glance because it is just regular Scala code, but if you need to add more service objects, you will notice that the actual application configuration and service instantiation will happen separately. The code below shows an example of reading configurations from a YAML file, packing configurations for multiple services into ConfigSet, and then instantiating service objects:
In the above example, you basically need to think the following things:
- How to pass configuration objects to services (e.g., by packing service configurations into a single object like ConfigSet, or by using local variable references)
- How to instantiate services by composing required objects.
- Additionally you may need to think about the timing to instantiate these service objects. The above code might not be appropriate if you need to start other service objects before starting WebApp. In this case you also need to care about the initialization order.
With Airframe, you can forget about these details and can start writing the service initialization logic in a more straightforward manner:
In this example, you only need to think about:
- What configuration objects will be necessary (bind[X])
- When to build service objects (build[WebApp])
Then Airframe will take care of the rest. This is possible because:
- Service object constructors already define their required objects as constructor arguments. So we can automatically instantiate objects if we can find (or build) required constructor argument values.
- Airframe can pass objects through a Session instance, which holds references to instantiation rules (e.g., constructor argument types) and pre-defined object instances.
Airframe takes advantage of these facts and generates code using Scala macros to build service objects on your behalf. If you need more flexibility, Airframe also supports building instances from Scala traits and provider functions. This type of automated instance construction is called auto-wiring, while manually passing objects to constructors is called hand-wiring. In Airframe you can use hand-wiring as well if you need to instantiate objects explicitly.
One of the advantages of using Airframe is you can separate the concern of how to build service objects from writing core application code. This has been improving my productivity a lot because I always can start from writing minimum code.
For example, when I need to write an application which requires some thread pool, database access, etc., I just start writing a code like this:
By using bind[X] syntax of Airframe, I can write the first draft of the code even if there is no ThreadPool nor DbService implementation yet. I know how I want to use services like ThreadPool and DbServices to make an SQL query. I don’t need to care about how to build ThreadPool and DbService at this phase. I just know I need these services in this application, and directly expressed this idea into the code.
Airframe helps writing service logic by using only necessary objects. A general practice of writing test code for complex services is using mocks (by using Mockito, etc.). One of the reasons why you need mocks is that it is usually difficult to implement all interface methods for testing, and providing null implementation via mocking can reduce such burdens. But if you already have minimized the code, you don’t need to create mocks at all. You can just replace some of required objects into convenient ones for testing (e.g., using some in-memory DBMS, instead of launching an actual DBMS server, etc.)
When implementing the above DbService, I would notice that I need some connection pool and database configurations (e.g., database types, host, port, etc.). So I can put its concrete implementation inside DbService trait. ConnectionPool usually requires startup and shutdown processes. In order to ensure having these resource management steps, I will add onStart/onShutdown hooks, which will be called at session start/terminate phases.
RIIA (Resource Instantiation Is Resource Allocation) is a common practice in C++ to ensure writing resource initialization/deallocation code inside constructor/deconstructor. Even though Java and Scala have no deconstructor, Airframe’s lifecycle management hooks allow writing such resource management code in a proper location.
Airframe uses FILO (First-In Last-Out) order by default for resource start-up and shutdown processes. This means resources allocated first will be released last. This ordering is appropriate for the most of the cases. Extensions of Guice, such as airlift-bootstrap (used in Presto, a distributed query engine by Facebook) and guice-bootstrap (used in Embulk, a bulk data input/output connector) also follow FILO ordering.
Choosing The Right Style for You
Actually it depends on your team’s expertise:
- If your team is already familiar with a purely-functional programming (FP) style using tagless-final and cats-effect in Scala, a purely-functional approach would work. You can still combine this FP approach with Airframe later.
- If your team members are not familiar with FP style programming, or have used Google Guice in Java, Airframe is a good choice in Scala since it basically follow the same syntax with Guice, while making it more suitable for Scala.
- If you prefer compile-time check, consider using MacWire. In Java, there is Dagger2, which is popular for developing Android applications, but its annotation processor based implementation doesn’t work for Scala.
For more detailed comparisons of various approaches, refer the following article, Airframe: DI Framework Comparison:
If you prefer a pure-Scala approach, I won’t stop you. It’s actually worth trying. But remember, the problem I have talked here is how to reduce what you need to think for getting the practical benefits in your programming task.
Actually using purely functional style in Scala will be a long journey. If you look at the history of FP in Scala, which became popular after the book Functional Programming in Scala was published in 2014, you will notice that there have been many attempts to implement DI-like concepts in Scala: Cake Pattern, Reader Monad, Tagless Final (used in scalaz and cats), etc.
The tagless final approach looks promising considering the recent popularity of cats, but its learning cost until finding the best practice would be still high. Eugene Yokota’s blogs (herding cats and learning scalaz) will be good learning materials, but simply choosing which library to use (cats or scalaz?) is also a big decision, although these two libraries are addressing almost the same problem. In addition, to use FP approaches, you need to convince your team members to follow some specific FP style, because understanding the rationale on using abstractions like F[_] requires some basic experiences and background of FP. And also you need to find a way to practically teach this FP style to new engineers.
I can say pursuing FP approach in Scala and getting programming supports from a framework like Airframe are totally different problems. So I think a response like, “(If you use FP) You don’t need DI in Scala” is not properly answering the question on how to reduce the efforts for building and managing service objects. In fact you need to reduce both of the learning cost and the actual coding cost to practically save your time. If you are thinking about the idiomatic FP style for Scala while developing applications, you are already distracted.
I have explained three key points that need to be addressed in daily programming:
- How to build service objects
- How to minimize service code
- And how to manage resources
Airframe is useful to reduce these concerns that occur frequently in application development. Especially this one-page quick start guide is all you need to know to start using Airframe, so you don’t need to learn what is IoC, or what is dependency injection to enjoy the benefits of Airframe.
Takeaway message: Let’s forget about the dependency injection. This might be used somewhere, but this is not what you need to care about.