Dear friends, please watch President Zelenskyy's speech. 🇺🇦 Help our brave mates in Ukraine with a donation.

Dependency Injection in Ruby - GOD Level! Meet dry-system! (Part 2)

Episode #15

In the last episode, I've covered deeply the reasons behind the existence of dry-container gem and shown how to implement dependency injection in Ruby from scratch.

This pattern, however powerful, can easily get annoying due to a lot of additional code one needs to write - and in most cases, it's repeatable code, nothing challenging, so boring.

important

This episode is the Part 2 of digging into dependency injection in Ruby. Check out Part 1 here! Episode #14 - Dependency injection in Ruby from Zero to Hero!

This episode is a direct follow-up on that topic and today I'll focus exactly on problems related to: more overhead, and more code to be written, showcasing two great additions to dry-container, which are

By the end of this episode, you'll be able to make use of all the power dependency injection offers to you, without ANY excuses, as this DRY combo just solves everything.

Let me now then go through those remaining issues that can stop you from leveraging DI in your ruby applications.

Remaining problems with dependency injection in ruby.

While dry-container is awesome, there is still a lot of manual work to be done to actually register all dependencies for the first time.

You still need to know the exact way to register all components, even if you need to do it only once.

Then our initialize methods may become quite extensive, as I have shown in HME007.

Those methods have no logic at all. It's just defining some readers and assigning dependencies to them, so maybe we could automate this?

# lib/my_app/utils/loggers/io_logger.rb

module MyApp
  module Utils
    module Loggers
      class IOLogger
        def call(msg)
          puts msg
        end
      end
    end
  end
end
# lib/my_app/utils/services/subscriptions/email_subscription.rb

module MyApp
  module Utils
    module Services
      module Subscriptions
        class EmailSubscription
          attr_reader :logger
          def initialize(logger:)
            @logger = logger
          end

          def call(email)
            logger.call("@-_-@")
          end
        end
      end
    end
  end
end
# slices/blog/commands/become_awesome_subscriber

module Blog
  module Commands
    class BecomeAwesomeSubscriber
      attr_reader :logger, :service
      def initialize(logger:, service:)
        @logger = logger
        @service = service
      end

      def call(**args)
        logger.call("starting subscription...")
        service.call(args[:email])
        logger.call("subscribed to newsletter!")
      end
    end
  end
end

Good News!

Dry-rb team have you covered!

Slim down your classes with dry-auto_inject!

There is a gem that allows exactly for that. It's dry-auto_inject, one of the first DRY libraries written by Peter Solnica, Tim Riley and Nikita Shilnikov. What it does, is allowing you to automatically create attr_readers in your classes and override initialize methods, removing tons of code you'd need to write without it.

With this little gem we can now simplify our registration file by automatically resolving all dependencies!

First of all, in my container file, I just create Deps constant which will be the only thing I'll use across my classes.

require 'dry-container'
# define the container
class Container
  extend Dry::Container::Mixin
  import Repositories

  register('my_app.utils.loggers.io_logger') do
    MyApp::Utils::Loggers::IOLogger.new
  end

  register('my_app.utils.services.subscriptions.email_subscription') do
    MyApp::Utils::Services::Subscriptions::EmailSubscription.new
  end

  register('blog.commands.become_awesome_subscriber') do
    Blog::Commands::BecomeAwesomeSubscriber.new
  end
end

Deps = Dry::AutoInject(MyContainer)

And the cool thing that I cannot stop highlighting, is that this gem does not work exclusively with dry-container! It works with ANY container, as long, as it responds to the [] method!

It's just awesome!

Now in all our files, we can replace all initialize methods together with attr_reader definitions by including our Deps module.

module MyApp
  module Utils
    module Services
      module Subscriptions
        class EmailSubscription
          include Deps[logger: 'my_app.utils.loggers.io_logger']

          def call(email)
            logger.call("@-_-@")
          end
        end
      end
    end
  end
end

module Blog
  module Commands
    class BecomeAwesomeSubscriber
      include Deps[
        logger: 'my_app.utils.loggers.io_logger',
        service: 'my_app.utils.services.subscriptions.email_subscription'
      ]

      def call(**args)
        logger.call("starting subscription...")
        service.call(args[:email])
        logger.call("subscribed to newsletter!")
      end
    end
  end
end

This extremely simplifies the whole process of defining and injecting dependencies across the whole system.

:::notice As I mentioned at the beginning of this short series, keep in mind that systems actually grow!

I worked with projects where I had hundreds of files with 5-7 dependencies each, and in such scenarios you'll quickly appreciate this optimization. :::

Plese notice, that the rest of the classes did not change at all, as dry-auto_inject allows us to freely name any dependency we load from the container, so long names or paths are issues neither.

Using this code also didn't change at all.

cmd = Container['blog.commands.become_awesome_subscriber']
cmd.call(email: 'awesomesubscriber@hanami.mastery')

You still can just access the container, but now all our dependencies are automatically resolved based on the container definition!

However, can you spot further possible improvements on this?

Dry-System for what?

Having that covered, what's the point of dry-system?

In my article about dry-rb dependency graph, I've highlighted, that based on the gem relationships you can conclude which gems are supposed to be used directly, and which are designed as a low-level building blocks for other libraries.

dry-system is a high-level gem designed specifically for a direct use.

While dry-auto_inject and dry-container are extremely powerful, you'll still have a lot of manual work required to register all components and across large systems you probably would like to avoid that.

If I would have more than 50 files to be manually registered by the container, I'd think 10 times. before I'll actually do it and more likely would end up with a conclusion, that it's not worthy.

Imagine a boot file manually registering hundreds of services and utility classes and you'll quickly feel the pain.

But then imagine if all of those files in your projects, would be registered automatically, and all dependencies would be automatically resolved, without you being stressed about a typo.

Imagine whole trees of dependencies loaded by thread-safe environment, without a need to write a single initialization method!

Guest what!

Dry-RB team has you covered!

dry-system composes dry-container and dry-auto_inject together, adding powerful autoloading capabilities and configuration options to your application.

So Let's look at the example.

Creating container in dry-system

First let me visit my container file.

# system/container.rb

require 'dry/system/container'

class Container < Dry::System::Container
  configure do |config|
    config.root = Pathname('.')
  end
end

This little snippet uses dry-container under the hood, but it also leverages the power of dry-configurable to allow easy-to-use, thread-safe configuration for the gem.

info

Check out how to configure anything in Ruby using dry-configurable I've talked about in episode 5.

So what's the point of it?

Well. So far, we needed to manually register all our files, didn't we?

However, now If we'll add a new directory to components files, we can now auto-register all our dependencies!

require 'dry/system/container'

class Container < Dry::System::Container
  configure do |config|
    config.root = Pathname('.')
    config.component_dirs.add 'lib'
    config.component_dirs.add 'slices'
  end
end

We completely don't need all the container registration code anymore, as everything is just automatically resolved based on our file structure!

This is the real game-changer!

With automatic registrations, and automatic injection we now can write our apps with 0 overhead whatsoever!

Nothing changes in our classes, nothing changes in the usage, but just every little piece of repeatable work is get off of our hands.

As I myself am productivity madman, I just cannot appreciate this enough.

Even though dry-validation is the actual, most popular gem from the dry family, for me it's dry-system, or rather dependency combo, that is my favorite trio.

Configurable file name resolver!

:::Warning Configurable Acronyms!

The IOLogger class defined above will raise an error when you try to load it with default configuration, because it interprets the file_name as IoLogger. Fortunately dry-system uses dry-inflector to transform strings, which I covered in episode #4!

To configure the acronyms, you just need to pass a custom inflector!

class Container < Dry::System::Container
  configure do |config|
    #...

    config.inflector = Dry::Inflector.new do |inflections|
      inflections.acronym('IO')
    end
  end
end

This will allow you to define IOLogger without a need to save it in the i_o_logger.rb file. Sorry for cutting it out from the video, editing error :(.

Hanami and dry-system

It's worth to mention, that Hanami 2 uses dry-system to manage all dependencies across the project. This is why it allows you to create truly majestic monoliths, with reduced technical debts when your project succeeds.

As dry-system is already configured, and container is not used directly, as a developer I am only interacting with the Deps module I had above! The rest is just a useful thing for me to understand, but it's completely irrelevant to start with Hanami!

If I'll create a file in the slice, it's automatically picked up by the dry-system, registered in the container, and ready to be used.

Multiple containers per slice.

In Hanami, each slice has dedicated container, which can be resolved independently. This allows to load a whole tree of dependencies for a single slice, or set of slices in a single pod, without loading anything from other parts of the system.

The only two things you really need to care about in Hanami, is that you can access the slice container - and this is an object using dry-container under the hood, which allows you to quickly browse and access registered dependencies.

main_container = Main::Container
main_container.keys
# => []
sandbox[development]> main_container['repositories.articles']
=> #<Main::Repositories::Articles struct_namespace=Main::Entities auto_struct=true>
sandbox[development]> main_container.keys
# => ["repositories.articles"]
sandbox[development]> main_container.finalize!
# => Main::Container
sandbox[development]> main_container.keys
# => ["repositories.articles",
#  "application.notifications",
# ...
#  "application.rack_logger",
#  "view.parts.article",
#  "actions.articles.drafts",
#  "actions.articles.published",
#  "actions.home.show",
#  "repositories.authors",
#  "views.articles.drafts",
#  "views.articles.published",
#  "views.home.show"]

Then inside your objects, you will have Deps module that you can use to streamline the process of composing objects together and allowing you to forget about implementing initialize methods over and over again.

# /slices/main/actions/articles/index.rb

module Main
  module Actions
    module Articles
      class Index < Main::Action
        include Deps[
          repo: 'repositories.articles'
        ]

        def handle(req, res)
          res.render view, articles: repo.all
        end
      end
    end
  end
end

Everything else is just tackled by dry-system and you can forget about it.

Summary

Managing dependencies in the growing systems is a complicated problem to tackle, and dry-system with configuring three big features together, solves all the possible issues one could have when starts to work with dependency injection in Ruby.

People can say that DI in Ruby is sometimes weird and I can agree with that. DI like any pattern solves some problems, but introduces different overhead. dry-system, however, is a tool suited specifically to address and solve those issues.

As each of great gems from dry family, dry-system does one thing and does it great. So great, that projects like Hanami can use it by default, integrate it Zeitwerk or other great tools, ensuring developers will have great time not only when they start the application, but especially when the apps become Majestic Monoliths or Multi-Repo, mircro-service based projects.

That's all for today, I hope you enjoyed this episode and you'll find it useful. I use dependency injection in all my ruby apps - previously rails, now more Hanami, for years already, and I love it particularly because of existence of dry-system.

Encourage you to give it a try in your projects too!

Become an awesome subscriber!

If you want to see more content in this fashion, Subscribe to my YT channel, Newsletter and follow me on Twitter!

Thank you!

Recent sponsors
  • DNSimple - which kindly joined to my platinum sponsorship tier allowing me to delegate a bit of work related to editing videos for my tutorials.
  • Andrzej Krzywda
  • Sebastjan Hribar
Other thanks

Do you know great Ruby gems?

Add your suggestion to our discussion panel!

I'll gladly cover them in the future episodes! Thank you!