Skip to content

Instantly share code, notes, and snippets.

@serodriguez68
Last active April 18, 2020 10:44
Show Gist options
  • Save serodriguez68/29f6be8d51aceff3d1fd202ee4355bf2 to your computer and use it in GitHub Desktop.
Save serodriguez68/29f6be8d51aceff3d1fd202ee4355bf2 to your computer and use it in GitHub Desktop.
Dependency Injection with defaults in Ruby by overriding the new method

Dependency Injection with defaults in Ruby by overriding the new method

This technique his heavilly inspired by Tom Dalling's Dependency Injection Containers vs Hard-coded Constants article. In particular, it proposes an alternative implementation of his "Hybrid Approach" using a build class method.

The main problems with Tom's implementation using build

  • It introduces a new way of instantiating objects via the .build method. This new API requires other developers to be aware that they must use .build if they want an instance of the object with default dependencies.
  • Following from the point above, in cases where we want to use cascading default dependencies, we must remember to use the dependencies .build method to benefit from their default behaviour.

Our approach: overriding .new

This approach overrides the class' .new method to provide the same behaviour with a less surprising API.

class UserRegistrator
  def self.new(**deps)
    deps[:validator] ||= UserValidator.new
    deps[:repo] ||= UserRepository.new
    super(deps)
  end

  attr_reader :validator, :repo, :other_arg_with_no_default

  def initialize(validator:, repo:, other_arg_with_no_default:)
    @validator = validator
    @repo = repo
    @other_arg_with_no_default = other_arg_with_no_default
  end

  def call(params)
    if validator.validate(params)
      repo.save(params)
    else
      false
    end
  end
end

# Usage with default dependencies
UserRegistrator.new(other_arg_with_no_default: :foo).call(name: 'Foo')

# Usage with injected dependencies
ru = UserRegistrator.new(validator: OtherUserValidator, repo: OtherUserRepository, other_arg_with_no_default: foo)
ru.call(name: 'Foo')

# Testing
let(:validator) { instance_double(UserValidator, validate: true) }
let(:repo) { instance_double(UserRepository, save: true) }
subject { UserRegistrator.new(validator: validator, repo: repo, other_arg_with_no_default: :foo) }

This approach works like this:

  • We override .new to use the injected dependencies or set the defaults if they were not provided.
  • super calls Ruby's actual .new method, which internally calls the instance's #initialize.

For reference, roughly speaking, .new works like this in Ruby:

class Class
  def new(*args, &block)
    obj = allocate
    obj.initialize(*args, &block)
    obj
  end
end

Dependencies can also use dependency injection with defaults. The classes that use these dependencies don't need to be aware of an alternate method for initializing them with defaults.

class UserValidator

  def self.new(**deps)
    # A dummy default dependency just to show the point of nested dependency construction
    deps[:subvalidator] ||= OpenStruct.new(validate: true)
    super(deps)
  end

  attr_reader :subvalidator

  def initialize(subvalidator:)
    @subvalidator = subvalidator
  end

  def validate(params)
    subvalidator
    # ...
    true
  end
end

class UserRepository

  def self.new(**deps)
    # A dummy default dependency just to show the point of nested dependency construction
    deps[:user_relation] ||= OpenStruct.new(insert: true)
    super(deps)
  end

  attr_reader :user_relation

  def initialize(user_relation:)
    @user_relation = user_relation
  end

  def save(params)
    # Do something
    user_relation.insert
    true
  end
end

Drawbacks vs using the .build implementation

  • It is a bit more challenging to understand what is going on (But the API just works as expected).
  • We can no longer read the default arguments as we are used to reading them in Ruby since they are not part of the initialize method's signature or body.
  • It opens the possibility for having 2 places for default arguments: .new and #initialize.

Thanks

This came out of some great discussions with Phillip Waldorf and Tim Riley.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment