Why We Should Inject Dependencies

5 points by PuercoPop


PuercoPop

One of the things that I find more interesting from Hanakai is their approach to dependency injection. dry-container+dry-autoject is the first dependency injection framework that looked aligned with Ruby values 'values'.

Most of the time one doesn't need to do dependency injection in Ruby, and keyword arguments + memoization can be used in most of the cases where I would want to reach for DI. This post explains the perspective dry-container and friends are coming from.

How they divide arguments to the class vs to the method is a little different to how I normally write Ruby but it is a divide that I can make sense of. In the spectrum of what is a dependency there are several answers. From the infra perspective it might be a 3rd party service (Postgres, Redis, MailChimp, etc). A maximalist view in the language perspective might be that collaborator classes are a dependency [in the global namespace] and that is the perspective where the proposed divide is coming from.

The way I would regularly write the class would be to inline some dependencies like UserRepository, which I normally don't try to substitute during tests. And things like Confirmation mailer would be a memoized method which we can override with a keyword argument during tests.

class UserRegistration
  def initialize(email, password, confirmation:); ...; end
  def confirmation; @comfirmation ||= ConfirmationMailer.new

The resulting service class might look a little unusual at first, but the design is more testable without resorting to things like method stubs.

evert

I don't understand why this is a yes or no question to people. Either you don't need it, or you do and when that answer changes for that particular case you change it then.

nick4

Dependency injection is an incredibly powerful tool! But I have deep suspicions around systematizing it in this manner. While I haven't written any Hanami, I have a lot of experience with this pattern in the .NET world, and I think it has a pretty disastrous effect on teams there.

Your DI container tends to end up massive and starts containing business logic that should belong elsewhere. Your example is a great one of what I'd be hesitant to do, actually!

container.register :confirmation do
  if Enabled?(:notification_service)
    NotificationService.new
  else
    ConfirmationMailer.new
  end
end

Here you've hide away a pretty core decision (push notifications vs emails, I think) that your application makes in a DI container. Instead, a class relevant to sending confirmations should be making that decision.

Here this looks like a feature flag that may be enabled at startup. This pattern gets a lot worse once you start needing runtime dependency decisions. I've seen teams cram some truly awful code into DI containers to achieve that.

DI containers often become an obsession too. DI is a tool, and you should not use a single tool in every place, but these frameworks really encourage that all-or-nothing mentality. Sometimes you do in fact need to just write new! And DI containers really discourage that and limit the kinds of solutions people reach to when designing a system.

A good example of this is your example only has singleton objects, and I think DI containers really push people towards singleton objects. They're useful, but when they're the only solution you see, you get can some really messy code.

DI does let you achieve more testable units of code, but I think you've misread the linked article on testing behavior, not implementation.

From that article:

tests should focus on testing your code's public API, and your code's implementation details shouldn't need to be exposed to tests.

The UserRegistration registration class is likely not your public API! In a web application, your HTTP endpoints are! UserRegistration is an internal class that you've now coupled to a test, making it harder to change.

In short, I think it's ok to have hardcoded dependencies sometimes, and it's ok to just new and pass some arguments sometimes.