Effective Ruby dependency injection at scale
Last week we found a pattern for functional command objects that uses dependency injection to make it possible to decompose things into small, reusable pieces. Our example was also small: it showed just three objects working together. This has long been my stumbling block with dependency injection in Ruby. All the explanations and examples showed simple implementations, not large applications. They would show one or two dependencies in use, not one or two hundred. Nothing zoomed back to show how this approach would scale across a whole app.
Today, let’s go large.
We need just one thing to make dependency injection work at scale: an inversion of control container.
Before we go further, let’s reacquaint ourselves with the CreateArticle
command we used last time:
class CreateArticle
attr_reader :validate_article, :persist_article
def initialize(validate_article, persist_article)
@validate_article = validate_article
@persist_article = persist_article
end
def call(params)
result = validate_article.call(params)
if result.success?
persist_article.call(params)
end
end
end
This uses constructor dependency injection to work with validate_article
and persist_article
as collaborators. Here’s how we can use dry-container to make sure those dependencies are available when we need them:
require "dry-container"
# Set up the container
class MyContainer
extend Dry::Container::Mixin
end
# Register our objects
MyContainer.register "validate_article" do
ValidateArticle.new
end
MyContainer.register "persist_article" do
PersistArticle.new
end
MyContainer.register "create_article" do
CreateArticle.new(
MyContainer["validate_article"],
MyContainer["persist_article"],
)
end
# Now we can use `CreateArticle` with its dependencies already available
MyContainer["create_article"].("title" => "Hello world")
A simple way to think of an inversion of control container is as a big ol’ hash that manages access to your app’s objects. Here, we’ve registered our 3 objects using blocks to instantiate them when accessed. The deferred evaluation of the blocks means that we can also use them to access other objects in the container, which is how we provide the dependencies to create_article
. And just like that, we can call MyApp::Container["create_article"]
and it’s fully configured, ready for use. With a container in place, we can register our app’s objects once, and use them everywhere.
dry-container also supports registering your objects within namespaces, to make working with large numbers of objects easier. In a real app, we might be working with names like "articles.validate_article"
and "persistence.commands.persist_article"
instead of the simple identifiers we’ve used in our small example.
We’re off to a good start, but in a large app, we’ll want to avoid as much configuration boilerplate as possible. We can do this in two more steps. The first is to use an auto-injection system for our objects’ dependencies. Here’s how it looks using dry-auto_inject:
require "dry-container"
require "dry-auto_inject"
# Set up the container
class MyContainer
extend Dry::Container::Mixin
end
# This time, register our objects without passing any dependencies
MyContainer.register "validate_article", -> { ValidateArticle.new }
MyContainer.register "persist_article", -> { PersistArticle.new }
MyContainer.register "create_article", -> { CreateArticle.new }
# Set up an AutoInject to use our container
AutoInject = Dry::AutoInject(MyContainer)
# And auto-inject the dependencies into CreateArticle
class CreateArticle
include AutoInject["validate_article", "persist_article"]
# AutoInject makes `validate_article` and `persist_article` available to use
def call(params)
result = validate_article.call(params)
if result.success?
persist_article.call(params)
end
end
end
Using auto-injection here has allowed us to reduce the boilerplate around object registration with the container. We no longer need to work out the list of dependencies to pass to CreateArticle.new
when registering it. Instead, we can specify the dependencies directly in the class. The module included by AutoInject[*dependencies]
sets up .new
, #initialize
and attr_readers
, which pull the object’s dependencies from the container and make them available for us to use.
Declaring an object’s dependencies right alongside where they’ll be used is a powerful shift. It makes the collaborators clear without the need for a rote #initialize
method, and it also allows this list to be easily updated as the object’s responsibilities change over time.
This is starting to feel nice and workable, but there’s still one thing left to clean up: those repetitive container registrations we saw at the top of the last example. We can do this using dry-component, a full-featured dependency management system based around dry-container and dry-auto_inject. It takes care of everything you need to use this technique across all parts of your app, but for now I’ll focus on just one feature: automatic dependency registration.
Let’s say our three objects are defined in lib/validate_article.rb
, lib/persist_article.rb
, and lib/create_article.rb
files. We can make them all available in a container automatically with the following setup in a top-level my_app.rb
file:
require "dry-component"
require "dry/component/container"
class MyApp < Dry::Component::Container
configure do |config|
config.root = Pathname(__FILE__).realpath.dirname
config.auto_register = "lib"
end
# Add "lib/" onto Ruby's load path
load_paths! "lib"
end
# Finalize the container to run the auto-registration
MyApp.finalize!
# Now everything is ready for us to use
MyApp["validate_article"].("title" => "Hello world")
Now we’re down to zero lines of repetitive boilerplate, and we still have a fully functional app! The auto-registration works using a simple convention around file and class names. Directories get turned into namespaces, so an Articles::ValidateArticle
class in lib/articles/validate_article.rb
would be available for you in the container articles.validate_article
without any extra work. We get the convenience of a Rails-like convention without any of the issues around class autoloading.
With dry-container, dry-auto_inject, and dry-component, you have everything you need to work with a whole app’s worth of small, focused, reusable components brought together using dependency injection. Your app will become easier to build, and more importantly, easier to follow, extend, and refactor when you come back to it later on!
Our focus today has been on the pure mechanics of setting up this arrangement. Next week, we’ll look at the principles behind inversion of control containers and how they lead to better object-oriented design.
Read part 1 of this series: My past and future Ruby
Part 2: Inactive records: the value objects your app deserves