
Infusible
2.1.0
Automatically infuses dependencies within your object through the use of advanced dependency injection. Dependency injection — the D in SOLID design — is a powerful way to compose complex architectures from small objects which have a single responsibility — the S in SOLID design.
This gem is inspired by the Dry AutoInject gem. There are a few major differences between this gem and the original Dry AutoInject gem which are:
-
All injected dependencies are private by default in order to not break encapsulation and a major reason why this gem was created.
-
Uses keyword arguments only while the original Dry AutoInject gem supports positionals or hash arguments in addition to keyword arguments.
The entire architecture centers on the injection of a container of dependencies. A container can be any object that responds to the #[]
message and pairs well with the Dry Container gem but a primitive Hash
works too. Here’s a quick example of Infusible in action:
Import = Infusible.with({a: 1, b: 2, c: 3})
class Demo
include Import[:a, :b, :c]
def to_s = "My injected dependencies are: #{a}, #{b}, and #{c}."
end
puts Demo.new # My injected dependencies are: 1, 2, and 3.
By using infusing dependencies into your object, you have the ability to define common dependencies that can be injected without having to do the manual setup normally required to define a constructor and set private instance variables.
Features
-
Ensures injected dependencies are private by default.
-
Uses a slimmed down architecture with a strong focus on keyword arguments.
-
Built on top of the Marameters gem.
Requirements
-
Ruby.
-
Knowledge of SOLID design principles.
Setup
To install with security, run:
# 💡 Skip this line if you already have the public certificate installed.
gem cert --add <(curl --compressed --location https://alchemists.io/gems.pem)
gem install infusible --trust-policy HighSecurity
To install without security, run:
gem install infusible
You can also add the gem directly to your project:
bundle add infusible
Once the gem is installed, you only need to require it:
require "infusible"
Usage
There is basic and advanced usage. We’ll start with basic and work our to advanced usage.
Basic
This gem requires three steps for proper use:
-
A container.
-
An import constant.
-
A class and/or multiple classes for dependencies to be injected into.
Let’s walk through each staring by defining a container of dependencies.
Containers
A container provides a common object for which you can group related dependencies for injection and reuse. Dry Container is recommended for defining your dependencies but a primitive Hash
or any object which responds to the #[]
message works too.
For documentation purposes, the Dry Container gem will be used. The following creates a simple container where you might want to use the HTTP gem to make HTTP requests and log information using Ruby’s native logger.
require "http"
require "logger"
module Container
extend Dry::Container::Mixin
register(:http) { HTTP }
register(:logger) { Logger.new STDOUT }
end
Injectors
Once your container is defined, you’ll want to define the corresponding injector for reuse within your application. Defining an injector only requires two lines of code:
require "infusible"
Import = Infusible.with Container
Dependencies
With your container and import defined, you can inject your dependencies by including what you need:
class Pinger
include Import[:http, :logger]
def call url
http.get(url).status.then { |status| logger.info %(The status of "#{url}" is #{status}.) }
end
end
Now when you ping a URL, you’ll see the status of the server logged to console using all injected dependencies:
Pinger.new.call "https://duckduckgo.com"
# I, [2022-03-01T10:00:00.979741 #81819] INFO -- : The status of "https://duckduckgo.com" is 200 OK.
Advanced
When injecting your dependencies you must always define what dependencies you want to require. By default, none will be injected. The following will demonstrate multiple ways in which to manage the injection of your dependencies.
Keys
You can use symbols, strings, or a combination of both when defining which dependencies you want to inject. Example:
class Pinger
include Import[:http, "logger"]
def call = puts "Using: #{http.inspect} and #{logger.inspect}."
end
Namespaces
To access namespaced dependencies within a container, you only need to provide the fully qualified path. Example:
class Pinger
include Import["primary.http", "primary.logger"]
def call = puts "Using: #{http.inspect} and #{logger.inspect}."
end
The namespace (i.e. primary.
) and delimiter (i.e. .
) will be removed so only http
and logger
are defined for use (as shown in the #call
method). Only dots (i.e. .
) are allowed as the delimiter between namespace and dependency.
Aliases
Should you want to rename your namespaced dependencies to something more appropriate for your class, use a hash. Example:
class Pinger
include Import[client: "primary.http"]
def call = puts "Using: #{client.inspect}."
end
The aliased "primary.http"
will be defined as client
when imported (as shown in the #call
method).
You can also mix names, namespaces, and aliases for injection as long as the aliases are defined last. Example:
class Pinger
include Import[:configuration, "primary.logger", client: :http]
def call = puts "Using: #{configuration.inspect}, #{logger.inspect}, and #{client.inspect}."
end
Explicit Dependencies
Earlier, when demonstrating basic usage, all dependencies were injected by default:
class Pinger
include Import[:http, :logger]
end
…but we could have a different class — like a downloader — that only needs the HTTP client. In that case, we could import the same container but only require the HTTP dependency. Example:
class Downloader
include Import[:http]
end
This allows you to reuse your importer (i.e. Import
) in as many situations as makes sense while improving performance.
Custom Initialization
Should you want to use injection in combination with your own initializer, you’ll need to ensure the injected dependencies are passed upward. All you need to do is define the injected dependencies as your last argument and then pass them to super
. Example:
class Pinger
include Import[:logger]
def initialize(http: HTTP, **)
super(**)
@http = http
end
private
attr_reader :http
end
The above will ensure the logger gets passed upwards for infusion and is accessible to your class as an HTTP dependency.
Inheritance
When using inheritance or multiple inheritance, the child class' dependencies will take precedence over the parent’s dependencies as long as the keys are the same. Consider the following:
class Parent
def initialize logger: Logger.new(StringIO.new)
@logger = logger
end
private
attr_reader :logger
end
class Child < Parent
include Import[:logger]
end
In the above situation, the child’s logger will be the logger that is injected which overrides the default logger defined by the parent. This applies to multiple inheritance too. Example:
class Parent
include GeneralImport[:logger]
end
class Child < Parent
include Import[:logger]
end
Once again, the child’s logger will take precedence over the what is provided by default by the parent. This also applies to multiple levels of inheritance or multiple inherited modules. Whichever is last, wins. Lastly, you can mix and match dependencies too:
class Parent
include Import[:logger]
end
class Child < Parent
include Import[:http]
end
With the above, the child class will have access to both the logger
and http
dependencies.
⚠️ Be careful when using parent dependencies within your child classes since they are private by default. Even though you can reach them, they might change, which can break your downstream dependencies and probably should be avoided or at least defined as protected
by your parent objects in order to avoid breaking your parent/child relationship.
Tests
As you architect your implementation, you’ll want to test your injected dependencies. You’ll also want to stub, mock, or spy on them as well. Testing support is built-in for you by only needing to require the stub refinement as provided by this gem. For demonstration purposes, I’m going to assume you are using RSpec but you can adapt for whatever testing framework you are using.
Let’s say you have the following implementation that combines both Dry Container (or a primitve Hash
would work too) and this gem:
# Our container with a single dependency.
module Container
extend Dry::Container::Mixin
register(:kernel) { Kernel }
end
# Our import which defines our container for potential injection.
Import = Infusible.with Container
# Our action class which injects our kernel dependency from our container.
class Action
include Import[:kernel]
def call = kernel.puts "This is a test."
end
With our implementation defined, we can test as follows:
# Required: You must require Dry Container and Infusible stubbing for testing purposes.
require "dry/container/stub"
require "infusible/stub"
RSpec.describe Action do
# Required: You must refine Infusible to leverage stubbing of your dependencies.
using Infusible::Stub
subject(:action) { Action.new }
let(:kernel) { class_spy Kernel }
# Required: You must define what dependencies you want to stub and unstub before and after a test.
before { Import.stub kernel: }
after { Import.unstub :kernel }
describe "#call" do
it "prints message" do
action.call
expect(kernel).to have_received(:puts).with("This is a test.")
end
end
end
Notice that there is very little setup required to test the injected dependencies. You only need to use the refinement and define what you want stubbed in your before
and after
blocks. That’s it!
While the above works great for a single spec, over time you’ll want to reduce duplicated setup by using a shared context. Here’s a rewrite of the above spec which significantly reduces duplication when needing to test multiple objects using the same dependencies:
# spec/support/shared_contexts/application_container.rb
require "dry/container/stub"
require "infusible/stub"
RSpec.shared_context "with application dependencies" do
using Infusible::Stub
let(:kernel) { class_spy Kernel }
before { Import.stub kernel: }
after { Import.unstub :kernel }
end
# spec/lib/action_spec.rb
RSpec.describe Action do
subject(:action) { Action.new }
include_context "with application dependencies"
describe "#call" do
it "prints message" do
action.call
expect(kernel).to have_received(:puts).with("This is a test.")
end
end
end
A shared context allows you to reuse it across multiple specs by including it as needed.
In both spec examples — so far — you’ll notice only RSpec before
and after
blocks are used. You can use an around
block too. Example:
around do |example|
Import.stub_with kernel: FakeKernel do
example.run
end
end
⚠️ I mention around
block support last because the caveat is that you can’t use an around
block with any RSpec test double since RSpec can’t guarantee proper cleanup. This is why the RSpec before
and after
blocks were used to guarantee proper setup and teardown. That said, you can use fakes or any object you own which isn’t a RSpec test double but provides the Object API you need for testing purposes.
Development
To contribute, run:
git clone https://github.com/bkuhlmann/infusible
cd infusible
bin/setup
You can also use the IRB console for direct access to all objects:
bin/console
Architecture
This gem automates a lot of the boilerplate code you’d normally have to do manually by defining your constructor, initializer, and instance variables for you. Normally, when injecting dependencies, you’d do something like this (using the Pinger
example provided earlier):
class Pinger
def initialize http: HTTP, logger: Logger.new(STDOUT)
@http = http
@logger = logger
end
def call url
http.get(url).status.then { |status| logger.info %(The status of "#{url}" is #{status}.) }
end
private
attr_reader :http, :logger
end
When you use this gem all of the construction, initialization, and setting of private instance variables is taken care of for you. So what you see above is identical to the following:
class Pinger
include Import[:http, :logger]
def call url
http.get(url).status.then { |status| logger.info %(The status of "#{url}" is #{status}.) }
end
end
Your constructor, initializer, and instance variables are all there. Only you don’t have to write all of this yourself anymore. 🎉
Style Guide
When using this gem, along with a container like Dry Container, make sure to adhere to the following guidelines:
-
Use containers to group related dependencies that make logical sense for the namespace you are working in and avoid using containers as a junk drawer for throwing random objects in.
-
Use containers that don’t have a lot of registered dependencies. If you register too many dependencies, then that means your objects are too complex and need to be simplified further.
-
Use the
Import
constant to define what is possible to import much like you’d use aContainer
to define your dependencies. Defining what is importable improves performance and should be defined in separate files for improved fuzzy file finding. -
Use
**
to forward keyword arguments when defining an initializer which needs to pass injected dependencies upwards.
Tests
To test, run:
bin/rake
Credits
-
Built with Gemsmith.
-
Engineered by Brooke Kuhlmann.