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.
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
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
It's just awesome!
Now in all our files, we can replace all
initialize methods together with
attr_reader definitions by including our
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: 'firstname.lastname@example.org')
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!
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.
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!
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
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.
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.
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!
- 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
- La-Rel Easter - for the great Cover Image!