The letter A styled as Alchemists logo. lchemists Syndication Icon

Putin's War on Ukraine - Watch President Zelenskyy's speech and help Ukraine fight against the senseless cruelty of a dictator!

Published March 6, 2022 Updated May 28, 2022
Auto Injector Icon

Auto Injector

0.5.0

Automates the injection of class dependencies. 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 gems enhances object construction with dependencies in mind from the start.

This gem is inspired by and based off 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.

  • Only keyword arguments — no aliasing — is supported while the original Dry AutoInject will support aliases, positional, or hash arguments too.

The entire architecture centers around 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 AutoInjector in action:

Import = AutoInjector[{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 AutoInjector, you have the ability to define common dependencies that can be injected without having to do the manual setup normally required to define your 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

  1. Ruby.

  2. An understanding of SOLID design principles.

Setup

To install, run:

gem install auto_injector

Add the following to your Gemfile file:

gem "auto_injector"

Usage

There is basic and advanced usage. We’ll start with basic and work our way down to advanced usage.

Basic

This gem requires three steps for proper usage:

  1. A container.

  2. An injector.

  3. A class and/or multiple classes for dependencies to be injected into.

Let’s walk through each staring with defining a container of dependencies.

Containers

A container provides a common object for which you can group related dependencies for injection into one or more classes for reuse. My recommendation is to use the Dry Container gem to define your containers but a primitive Hash or any object which responds to the #[] message works too.

For documentation purposes, we’ll use the Dry Container gem. Here’s how to create 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 "auto_injector"

Import = AutoInjector[Container]

Dependencies

With your container and injector defined, now 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 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"]
end

All keys are always converted to symbols when determining which dependencies to inject.

Explicit Dependencies

Earlier, when demonstrating basic usage, all dependencies were injected by default:

class Pinger
  include Import[:http, :logger]
end

…​but we could have had a different class, lets say 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

You could also have a different class that only cares about logging but not the HTTP dependency. This allows you to reuse your injector (i.e. Import) in multiple situations as makes sense.

Custom Initialization

Should you want to use auto-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, **dependencies
    super(**dependencies)

    @http = http
  end

  private

  attr_reader :http
end

The above will ensure the logger gets passed upwards to the injector so it’s properly defined and accessible to your class as your custom defined 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. Which ever 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 = AutoInjector[Container]

# Our action class which uses Auto Injector to inject 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 Auto Injector stubbing for testing purposes.
require "dry/container/stub"
require "auto_injector/stub"

RSpec.describe Action do
  # Required: You must refine Auto Injector to leverage stubbing of your dependencies.
  using AutoInjector::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 auto-injected dependencies. All you need to do is use the refinement and define what you want to stub 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 "auto_injector/stub"

RSpec.shared_context "with application dependencies" do
  using AutoInjector::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.

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 which makes logical sense for the namespace you are working in. You want to avoid using containers as a junk drawer for throwing any random object 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 broken down and simplified further.

  • Use injectors to define containers you want to auto-inject. You can define these along with your containers or within separate files. Like containers, they should be namespaced and related to the objects that need them.

  • Use the Import constant to define your injectors much like you’d use Container to define your containers. These should be defined in separate files for improved fuzzy file finding.

  • Use **dependencies as your named keyword splat argument when defining an initializer which needs to pass auto-injected dependencies upwards. This improves readability by clearly identifying your auto-injected dependencies.

Development

You can also use the IRB console for direct access to all objects:

bin/console

Tests

To test, run:

bundle exec rake

Credits