A container is a pattern which is built upon SOLID design principles, namely the Dependency Inversion Principle 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 related dependencies with minimal effort.
Let’s delve into what this pattern is, why it is important, and how to properly use it in your own code.
Fundamentals
The assumption is that you are familiar SOLID design principles, especially the Dependency Inversion Principle. We’ll only be focused on the Dependency Inversion Principle and Dependency Injection. Injection of your dependencies is normally done via your constructor but can have other forms of injection such as Setter Injection or Interface Injection. We’ll only be focused on Constructor Injection, though.
Dependency Injection
The following demonstrates the injection of the HTTP class and Cogger instance as dependencies to the Pinger class.
class Pinger
def initialize http: HTTP, logger: Cogger.new
@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 Cogger dependencies injected via the constructor. This means you could easily swap HTTP or Cogger 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 Cogger::Hub.new
Pinger.new(http:, logger:)
💡 See RSpec Test Doubles to learn more.
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: 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 discussing hashes and Containable 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: Cogger.new}.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 Cogger 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.
Containable
While hashes are the quickest way to leverage containers, you’ll soon outgrow them. To gain more capabilities, you’ll want to use Containable. For example, here’s what the implementation looks like when refactored to use Containable:
module Container
extend Containable
register :http, HTTP
register(:logger) { Cogger.new }
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 container might not seem like a lot at first. In fact, the refactoring required very few changes to the original implementation. The biggest difference, by using Containable, 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 Containable 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 Containable 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 Containable
register :http, HTTP
register(:logger) { Cogger.new }
end
Dependencies = Infusible[Container]
class Pinger
include Dependencies[: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, include, 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 Containable, 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 Containable
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.
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.
Advantages
The advantages of containers are:
-
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 instance is answered by default.
-
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
The disadvantages of containers are:
-
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 dependency registered within the container is not frozen by default.
-
Containers can become a junk drawer of discombobulated objects without proper discipline.
Conclusion
Dependency management can be unwieldy and complex but now you’ve learned how to tame your dependencies through the use of containers. Even better, you are able to reduce the number of instances created for even better memory management.