Less dependency hell
In the last few days I've covered gems, Bundler and the single global namespace, and I'm now ready to make this point: Ruby has a natural cap on dependency hell and I love it!
One of the most bizarre things about modern web development are those node_modules folders found in modern frontend projects. In my experience they commonly contain millions of lines of code across thousands of packages.
A concrete example would be our 9-year-old frontend repository at work, which, to be fair, is a monorepo including three different sub-applications, but still: 6,052,697 lines of code across 4,449 packages.
Now a curious observation is that the number of unique packages is actually quite a bit lower at 2,205. This implies that 2,244 of them are installations of the same package, but with different versions. Now in no way does it seem even remotely fine to have 2,205 dependencies in the first place. But I'm trying to make the point that we've more than duplicated the number of installed dependencies by virtue of allowing multiple versions of the same package to be installed at the same time.
In Ruby, since we have to contend with the global namespace, it doesn't make sense to allow multiple versions of the same gem to be loaded at the same time, because they would end up clobbering each other. Hence Bundler is designed to resolve to only a single version of each gem in the dependency graph.
Perhaps some may be inclined to question whether this is a problem or a limitation of the language, but in my experience it is a blessing since it pushes the community toward good dependency hygiene:
- It naturally reduces the total number of dependencies in a project.
- It makes it easier to troubleshoot, edit, patch and test dependencies since there is never any reason to second-guess which version of a gem is being used in which part of the code.
- Whenever there's a conflict between two gems sharing the same transitive dependency, it creates a forcing function for the community to resolve the conflict by swiftly moving everyone forward to the latest version.
- If there's a conflict and one of the gems is no longer maintained, it must either be resolved by finding new maintainers, or by simply getting rid of the abandoned dependency altogether, which is a good outcome since it both reduces dependencies and teaches us to be more careful about adding dependencies in the first place.
I feel like these factors have compounded over the years as the Ruby community has matured and lessons have been learned. Most major Ruby and Rails codebases I'm familiar with these days tend to run one of the latest versions of Ruby, Rails and other dependencies and do not introduce additional dependencies lightly.
Another huge reason why we use relatively few dependencies is that Ruby's standard library is so extensive. There really isn't any need for micro-packages to fill in the gaps.
That is not to say that we never go through dependency hell, just that it tends to be more manageable compared to certain other ecosystems 🙈
Wrapping up, in comparison to our 9-year-old frontend repository with its 4k+ packages, our 20-year-old Rails monolith has a total of 316 dependencies. Still a lot, but at least it feels like the right order of magnitude for a project of its size and age.