
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!