The letter A styled as Alchemists logo. lchemists
Published October 15, 2022 Updated January 4, 2023
Cover (Generated by DALL-E)
Dependency Injection Containers

A container is a pattern which is built upon SOLID design principles, namely: Dependency Inversion Principal which is the D in SOLID. In short, this pattern allows you to register and request a set of related components via a single container or multiple containers so you can inject, reuse, and swap out related dependencies with minimal effort.

We’ll spend the rest of this article delving into what this pattern is, why it is important, and how to properly use it in your own code.

Fundamentals

I’m assuming you are familiar SOLID design principles, especially Dependency Injection. The terminology around all of these patterns can get confusing, so use the following as a reference since all terminology is related but distinct:

For the purposes of this article, we’ll only be focused on Dependency Injection but it’s good to be aware of all of the above. More often than not, injection of your dependencies is done via your constructor but can have other forms of injection such as Setter Injection or Interface Injection. This article will only be focused on Constructor Injection, though.

Dependency Injection

The following demonstrates the injection of the HTTP class and Logger instance as dependencies to the Pinger class.

class Pinger
  def initialize http: HTTP, logger: Logger.new(STDOUT)
    @http = http
    @logger = logger
  end

  def call(url) = logger.info { "Status for #{url} is #{http.get(url).status.code}." }

  private

  attr_reader :http, :logger
end

Notice, in the above, the Pinger class has the HTTP and Logger dependencies injected via the constructor. This means you could easily swap HTTP or Logger with any object that quacks like them (i.e. same behavior). In addition — when using a robust testing framework like RSPec — you could inject spies for testing purposes as well. Example:

http = class_spy HTTP
logger = instance_spy Logger

Pinger.new(http:, logger:)

💡 Check out this earlier article on RSpec test doubles if interested in learning more about the power of spies.

Now that we are on the same page in terms of dependency injection, we can discuss how to group our dependencies within a container for reuse with basic and advanced implementations.

Containers

Containers are a simple mechanism in which to group related objects (i.e. components) in order to register, resolve, and release them. This pattern also goes by another name as described by Mark Seemann: Register Resolve Release. This means every container should:

  • Register: Related components are registered within a container.

  • Resolve: A component, once registered, can later be acquired for use within multiple objects.

  • Release: Once the components of the container are resolved, the container is disposed.

This is a useful breakdown of how the life cycle of a container works and what its sole purpose is. We’ll dive into what the register and resolve steps look like when I talk about hashes and Dry Container next but the release step will be covered later in the Advanced section when talking about the Infusible gem in order to make this behavior automatic.

Hash

If we refactor the earlier Pinger implementation to use a hash container, we’d end up with the following code:

CONTAINER = {
  http: HTTP,
  logger: Logger.new(STDOUT)
}.freeze

class Pinger
  def initialize container: CONTAINER
    @container = container
  end

  def call(url) = logger.info { "Status for #{url} is #{http.get(url).status.code}." }

  private

  attr_reader :container

  def http = container.fetch __method__

  def logger = container.fetch __method__
end

With the above implementation, we now have a way to reuse our CONTAINER constant across multiple objects that might need an HTTP and/or Logger object at a slight cost of introducing a potential Primitive Obsession code smell (not necessarily bad, in this case, but important to point out).

This container constant is particularly handy when working with multiple network related objects which all need an HTTP client for API requests, a logger for information, and maybe even other objects like instrumentation or exception monitoring because now we can define all of the components within the container once and reuse the container in multiple objects. Otherwise, if this wasn’t the case, you’d want to avoid the container altogether and inject the dependencies directly into the Pinger constructor.

Lastly, a useful aspect of this design is being able to use the #[] method — or #fetch for robustness — to resolve components.

Dry Container

While hashes are the quickest way to leverage containers, you’ll soon outgrow them. The best gem in the Ruby ecosystem, at the moment, for encapsulating this pattern is: Dry Container. For example, here’s what the implementation looks like when refactored to use Dry Container:

module Container
  extend Dry::Container::Mixin

  register(:http) { HTTP }
  register(:logger) { Logger.new(STDOUT) }
end

class Pinger
  def initialize container: Container
    @container = container
  end

  def call(url) = logger.info { "Status for #{url} is #{http.get(url).status.code}." }

  private

  attr_reader :container

  def http = container[__method__]

  def logger = container[__method__]
end

The difference between a Hash and a Dry Container might not seem like a lot at first. In fact, the refactoring required very few changes to the original implementation. The biggest difference, with a Dry Container, is we have a more robust interface which allows us to register and resolve components in a thread safe manner that a Hash can’t because Dry Container is built upon Concurrent Ruby.

Advanced

With the fundamentals of containers understood, we can move on to more sophisticated usage. The first of which is automatic injection of dependencies.

Automatic Injection

The biggest benefit of using a container is when you couple the Dry Container and Infusible gems together so the components of your container are automatically injected. If we refactor our code, this time with automatic injection, our implementation becomes:

module Container
  extend Dry::Container::Mixin

  register(:http) { HTTP }
  register(:logger) { Logger.new(STDOUT) }
end

Import = Infusible.with Container

class Pinger
  include Import[:http, :logger]

  def call(url) = logger.info { "Status for #{url} is #{http.get(url).status.code}." }
end

Notice how compact the implementation is versus any of the earlier examples. You immediately are able to define a container, import it, and selectively choose which components within the container you want to inject without having to define the constructor or fetch the dependences once injected. 🎉

In addition, this partially satisfies the release step of this pattern because Infusible only references the container in order to inject the necessary dependencies via the constructor but does not strictly dispose of the container once finished. Instead the container remains accessible for future use. Definitely check out the Infusible project documentation for further details since there is a lot it can do.

Namespaces

With Dry Containers, we can organize them further by using namespaces. Namespaces are handy for situations where you need sub-structures within your container for organization purposes. Example:

class Container
  extend Dry::Container::Mixin

  namespace :outer do
    register(:inner) { "demo" }
  end
end

Container["outer.inner"]  # "demo"

Note the use of string keys separated by dot notation. Symbols or strings can be used when registering components at the root level but when using namespaces you’ll need to revert to strings with dot notation.

Merges

Merging containers is useful when you need to combine the components of an existing container (usually a superset) into a new container. There are two ways you can merge a container and it depends if they are modules or classes. My recommendation is to stick with modules since they are more flexible due to multiple inheritance without the rigidity of class hierarchies but, depending on your situation, you can use classes too.

Modules

With modules you only need to merge the desired container and then register additional functionality as usually done without a merge. Example:

module PrimaryContainer
  extend Dry::Container::Mixin

  register(:blue) { "Blue" }
end

module SecondaryContainer
  extend Dry::Container::Mixin

  merge PrimaryContainer

  register(:green) { "Green" }
end

puts SecondaryContainer[:blue], SecondaryContainer[:green]  # "Blue\nGreen"

You can also namespace a merged container:

module SecondaryContainer
  extend Dry::Container::Mixin

  merge PrimaryContainer, namespace: :primary
end

puts SecondaryContainer["primary.blue"]  # "Blue"

Classes

To merge container classes, you only need to subclass them. Example:

class PrimaryContainer
  extend Dry::Container::Mixin

  register(:blue) { "Blue" }
end

class SecondaryContainer < PrimaryContainer
  register(:green) { "Green" }
end

puts SecondaryContainer[:blue], SecondaryContainer[:green]  # "Blue\nGreen"

Guidelines

  • Only register instances as dependencies within your container because this yields several advantages:

    • Instances are easier to work with and swap with different implementations. There are exceptions to this rule as denoted with the use of the HTTP gem in the examples above.

    • Using only instances means you reduce the complexity of toggling between, and managing, different class and instance implementations which reduces your flexibility.

  • Use modules instead of classes for your containers for improved dependency injection.

  • Avoid using complex or deeply nested namespaces because this will increase code complexity.

  • Avoid using multiple merged containers which increases code complexity and oftentimes introduces an unnecessary object hierarchy.

Summary

As with any pattern, there are always advantages and disadvantages. The following summarizes what those are.

Advantages

  • Ability to register, configure, and instantiate related components and reuse them across multiple objects.

  • Ability to register cross-cutting services which are common to multiple objects such as logging, exception reporting, metric/statistical reporting, and so forth.

  • Ability to register components which are ignorant of object hierarchies without being deeply embedded or specifically defined.

  • Components are thread safe since all objects registered within the container are backed by Concurrent Ruby.

  • Registration of components is immutable, by default, and can’t be registered twice. Additionally, once a component is resolved, the same object is answered back each time.

  • Containers — especially when coupled with Infusible — don’t effect the design of your implementation since the container is only a delivery mechanism and can be replaced with similar objects using the same Object API.

  • Dependencies can be easily stubbed out at any layer of your stack without having to force the dependency to be passed down multiple layers which can be tedious to wire up properly (especially when not wanting to create complex setups for testing purposes).

Disadvantages

  • Containers can become a junk drawer of discombobulated objects without proper discipline.

  • Each container is not frozen by default which means it can have additional objects registered after creation (sometimes this is a good thing but also deviates from the original intent of what a container is suppose to be).

  • Each component registered within the container is not frozen by default.

  • Can make your components more dispersed instead of centralized since they are indirectly defined instead of being directly defined within the same source file.

Conclusion

After having struggled with managing dependencies within complex systems in the past, I find containers to be far more advantageous than without. I hope this new insight has expanded what is possible within your own architectures as well. Enjoy!