Compile-time Dependency Injection With Play
Gregg Hernandez
Reading time: about 7 min
Topics:
The Play Framework provides a lot of features, which makes it incredibly easy to scale infrastructure. However, scaling a codebase is more difficult. As developers create new components and more complex relationships, the codebase can become unruly for all. To help with this problem, one can use a dependency injection framework.
The two most common approaches are Guice and MacWire. In this post, I’ll primarily explain how compile-time dependency injection works in Play and how MacWire can make it much easier. For samples of different ways to set up Play to use dependency injection, see this github repo.
Compile-time Dependency Injection
Injecting dependencies at compile-time allows you to leverage the compiler to verify that every controller in your application has access to all of the components it will need. That means you don’t need to worry about runtime errors causing crashes and a bad experience for your users. Indeed, compile-time DI (and static typing in general) can reduce the need for a subset of common types of unit tests.
In order to use compile-time dependency injection in Play, you have to create a custom ApplicationLoader and configure Play to use that loader when your application launches (set play.application.loader to point to your loader class). You can see a full sample here.
Using constructor parameters is a simple and straightforward approach to defining dependencies at compile-time. The following class can be said to “depend on” a ControllerComponents instance and a UserModel instance:
class Controller(
val controllerComponents: ControllerComponents,
userModel: UserModel
) extends BaseController {
def user() = Action { request =>
Ok(Json.toJson(userModel.getUsernames()))
}
}
This is about as simple as specifying dependencies can get. There’s no magic—you just say what component you want and you get it. Setting up how dependencies are provided requires a little more work. This is where the application loader comes in.
Loader.scala:
class Loader extends ApplicationLoader {
def load(context: Context): Application = {
LoggerConfigurator(context.environment.classLoader).foreach {
_.configure(context.environment)
}
new Components(context).application
}
}
class Components(context: Context) extends BuiltInComponentsFromContext(context) {
override lazy val httpFilters = Nil
Lazy val userModel: UserModel = new UserModel()
lazy val controller: Controller = new Controller(controllerComponents, userModel)
lazy val router: Router = new Routes(httpErrorHandler, controller)
}
The actual Loader class is mostly boilerplate required to make sure the application is configured correctly when it loads. The injection of dependencies happens in the Components class. Again, there’s no magic here—you just create and pass instances into components that need them. BuiltInComponentsFromContext provides a handful of default Play components that are useful. In this case, I use the ControllerComponents component for our controller and an HttpErrorHandler for the Routes constructor. BuiltInComponentsFromContext requires you to also provide a List[EssentialFilter] and a Router object. Play will generate a Routes class based on the contents of conf/routes for you. I use this to construct a new Router object.
For simple applications, manually injecting dependencies is fairly simple. However, even in simple applications, adding a dependency to a controller requires you to modify both the controller and the application loader. This process is further exacerbated by the Routes constructor that Play generates. Each class you reference in conf/routes will get translated into a constructor parameter of the Routes object. That means adding a controller class requires you to create an instance of the class and explicitly pass it into the Routes constructor. To make matters even worse, if you reorder your conf/routes file or add a new route somewhere in the middle, your generated Routes object will have a new order for its constructor parameters, and you will have to manually fix that as well.
Manually injecting at compile time isn’t scalable and will create a lot of extra work in more complex applications. This is where MacWire comes in.
MacWire
MacWire is a very lightweight macro that generates constructor calls automatically for you. That’s pretty much all it does (okay, it has a few other features, but we only care about this one for now). Using MacWire, I changed the above Components class to the following (full code here):
class Components(context: Context) extends BuiltInComponentsFromContext(context) {
override lazy val httpFilters = Nil
lazy val userModel: UserModel = wire[UserModel]
lazy val controller: Controller = wire[Controller]
lazy val router: Router = {
val prefix = "/"
wire[Routes]
}
}
You will notice two major changes.
First, all “new” calls have been replaced with wire[ClassName]. This is the macro that generates the “new” calls. It does this by examining the default constructor for the class specified and then looking for values of the same types in the current scope. wire[Controller] will get expanded into new Controller(controllerComponents, userModel). That’s the exact same code we wrote manually above. Wire follows normal scoping rules you are already familiar with in Scala. If there are multiple instances that will fulfil a dependency, wire will fail at compile-time saying it can’t decide which instance to use. You will have to resolve this ambiguity manually. MacWire provides Qualifiers to simplify this process when needed.
Second, the definition of router changed. The default constructor of Routes requires a String prefix argument, while the constructor used manually in our original example above is not the default constructor and does not require this argument. I wrapped prefix into the block scope of the wire call so that I don’t accidentally leak prefix into other components that might need a String parameter. In larger projects, this becomes more valuable. In this example, everything would have still worked just fine if I defined prefix outside of the Router block.
Now adding a dependency (or removing a dependency) from a controller only requires you to modify the controller itself. So if you need access to application configuration, you can just add a constructor parameter with the type Configuration to Controller, and like magic, it will inject a Configuration object. Even better, adding, removing, or reordering routes in conf/routes becomes much easier because you no longer have to worry about the order of Routes parameters or anything beyond the specific classes for which you need to provide instances.
One last note on MacWire: I used lazy vals in my examples. I did so because it allows you to specify complicated dependencies without worrying about initialization order between different components. They’ll all be created on demand and you avoid potential null pointer exceptions. You can also use defs if you’d prefer that each class that depends on your def gets a new instance instead of a shared instance.
Runtime DI with Guice
The same application using Guice for runtime DI is much simpler (see the full sample here). You don’t need a custom ApplicationLoader. You just specify dependencies via constructor parameters and add an @Inject() annotation (using Guice is pretty well documented, so I won’t go into any further detail):
class Controller @Inject() (val controllerComponents: ControllerComponents, userModel: UserModel) extends BaseController {
def users() = Action { request =>
Ok(Json.toJson(userModel.getUsernames()))
}
}
The only difference is the addition of the @Inject() annotation. Since Play already knows about ControllerComponents and UserModel has no constructor parameters, nothing more needs to be done.
With Guice, you gain a lot in terms of ease of use. So why even bother with compile-time DI methods like MacWire? Well, you give up fine-grained control of how your application starts, you no longer have a centralized source of how components are created, you give up runtime safety when injection goes wrong, and it’s not always clear if you will get a shared instance or a new copy of a component.
Conclusion
The Scala compiler is very powerful, and we should leverage what it offers as much as possible. Compile-time DI is just one more way to leverage that power.
About Lucid
Lucid Software is a pioneer and leader in visual collaboration dedicated to helping teams build the future. With its products—Lucidchart, Lucidspark, and Lucidscale—teams are supported from ideation to execution and are empowered to align around a shared vision, clarify complexity, and collaborate visually, no matter where they are. Lucid is proud to serve top businesses around the world, including customers such as Google, GE, and NBC Universal, and 99% of the Fortune 500. Lucid partners with industry leaders, including Google, Atlassian, and Microsoft. Since its founding, Lucid has received numerous awards for its products, business, and workplace culture. For more information, visit lucid.co.