Dependency management in a project is a thing that, often-times, nobody thinks about until there is a problem. One of the great things about package managers like Maven, Bundler, PIP and their relatives is that adding a new dependency is a snap. It can take less than thirty seconds to add a new Ruby gem dependency to a Gemfile and install it via Bundler. This is a great convenience to developers; in the “old days” you would need to download and build every C / Perl / Ruby / Java library that you needed.
The value of libraries
Third-party libraries often provide large chunks of functionality that decrease your development time when building a product. For example, user-authentication mechanisms or web crawlers can be extremely time-consuming to implement. Even a simple implementation could take days or weeks to build an initial version of; even longer to implement these correctly as often the first 90% of development happens quickly followed by the second 90% that is required to make sure everything works correctly.
Why would anyone want to re-implement a user-authentication mechanism? Chances are that most projects would not want to. There are lots of edge-cases to consider and a community-managed project is quite likely to have addressed most, if not all, of those already through the course of its development. As a result, you can increase your development velocity by leveraging the combined development effort of the community at large. Bug fixes and improvements come in through the project’s contributors, you don’t need to do a lot of maintenance for that specific set of functionality, everybody wins.
The downside of dependencies
There is a flip-side to this: dependencies are like a relationship. You take from them, and maybe you give some back, but ultimately you are tying up some part of your future with this new dependency. If you depend on the Apache Commons IO package you are implicitly saying “I trust that the Commons IO package will bring me more value than hassle.” More often than not these are reasonable assumptions but it’s a good idea to weigh the pros and cons before consuming any dependency.
Some potential problems that might stem from upstream dependencies:
- A widespread bug is caused when updating a particular library
- Taking a new version of a library requires numerous updates other dependencies
- System libraries need to be updated for a new version of a dependency
- Taking (or avoiding) a new version of a library leads to a security vulnerability
- Deployed artifacts now consume an astronomical amount of space for every deployment
- Two, or more, dependencies cause irreconcilable conflicts at build- or (even worse) run-time
Dependency overload
Consider an imaginary Rails application that we are working on. We need an HTTP
client (of course!) so we add httparty
. Soon we find that we need a
parallelizing HTTP client later on so we add typhoeus
and faraday
. Now we
want to add a web-crawler to our app and we add magic-spider-foo
(which just
depends on curb
). Now, at this point we have no fewer than three completely
different HTTP clients in your project, along with all of their dependencies
both pure-Ruby and native (typhoeus
depends on libcurl, as does curb
, each
of which may have wildly different expectations of underlying versions).
Additionally we now have various parts of the system using totally different
HTTP clients making HTTP calls, so now we have completely different code and
behavior in various portions of your code when making HTTP calls to upstream
services. (Which means we need to test all of these configurations and can’t
necessarily share test components between them).
Native dependencies in languages like Ruby, Java or Python can be challenging to manage because they often require very specific underlying library versions that do not always map well to pre-built packages for your platform. As a result they can be difficult to deploy and manage without the aid of an additional configuration management tool such as Puppet or Chef to ensure that the system libraries are available, or without managing packages for your particular deployment platforms.
Ruby is not unique in this, any sufficiently complex programming language (which
is to say all of them) have the potential for a wild garden of dependencies. For
example, Maven offers the <exclusions>
tag that permits you to suppress
downstream dependencies (effectively pruning your dependency graph by hand).
This is used in order to prevent compile-time or runtime issues from occurring
when you have conflicting APIs (i.e conflicting sub-versions of a project that
are not compatible). This tells us that this is a hard problem(tm) and that
sometimes a human being has to intervene on behalf of the dependency manager.
Not all libraries are created equal
The quality bar for all projects is not held equal. In particular, many open source projects see many contributors and sometimes even change ownership which leads to discontinuity in vision and oversight. This is not to say that open source projects are bad, in fact they are incredibly useful and provide a lot of value. It is important to evaluate the quality of a project when taking a dependency on it; effectively you are counting on that project’s functionality to provide you with enough value to justify not implementing it yourself.
Copy-pasting code
There are times when it makes more sense to copy and paste some code rather than
consume an entire library. If you only need a handful of methods, as long as
they are independent, it may make more sense to simply lift that portion of code
(with appropriate credit and licensing compliance) into your application. One
does not always need the entire something the size of the entire Apache commons-lang
package to implement the isEmpty
method on a string.
Some folks may cry that this is not “DRY” enough or that you “might miss out
on bug fixes upstream”. Certainly this is true for the entirety of commons-lang
–
nobody should re-implement all of it in their application, these libraries
exist to make your life easier. I have personally encountered a number of projects
where an attempt to maximize the body of shared code caused more harm than it
did good. In particular, systems that rely on a multitude of services suffer when
you tie them together using too much common code.
Adopting a new pet
So you’ve decided that you need to take on a new dependency - great! There are a number of things to consider when taking on a third party library:
- Licensing
- Performance
- Security
- Functionality
License management
Software licensing is a jungle - there are so many licenses available and a number of them are in direct conflict with what most people would consider business requirements. A lot of businesses do not want to (or simply cannot) release their source code for the things they are working on. That doesn’t mean that these companies are bad, or evil, or that they don’t contribute in other ways or on other projects. What it does mean though, is that it can be very challenging to know exactly what licenses are in the third-party dependencies that you have taken.
Dealing with licenses
Leverage a tool, such as Pivotal’s LicenseFinder, which is capable of analyzing your dependency graph and generating a report of licenses that are being used. The great thing about LicenseFinder is that it works with a number of package-management systems across a variety of languages including Maven, Bundler, PIP and other popular tools. It can even scan licenses in polyglot projects where your Ruby application may have some Node.js dependencies, for example.
Performance problems
Lots of libraries experience performance regressions. Oftentimes they don’t even know it; they don’t use the library the way that you do and, even if they were aware of your usage, likely don’t have a way to test the things you do.
Catching performance regressions
When writing unit and integration tests (you are doing this, aren’t you?) make sure that you are factoring in performance tests that execute your code with timing data. Keep track of how that performance changes and consider setting a baseline for these tests. If the timing exceeds a certain threshold it’s worth investigating; this is good practice even if you don’t have any upstream dependencies.
Security issues
Security issues are particularly hairy; the larger your dependency graph, the larger the surface area for exposure is. Every dependency you take has the potential to expose your software to bugs and security flaws. If you don’t have a team dedicated to tracking vulnerabilities in the wild and notifying you when they happen and helping you patch them you will be on the hook for this.
Working to avoid security holes
Minimize your dependencies where it makes sense; know what sort of functionality each dependency has and where potential holes might affect you. Follow the mailing lists and keep up-to-date with the change logs of your dependencies. Security holes can appear in any library so the smaller your chain of dependencies the fewer things you have to keep track of. Admittedly this can be a lot of work, particularly for complex or rapidly changing dependencies; it helps to have a team that focuses on security for your organization but not everyone has access to such a resource.
Ensuring proper behavior
In a similar vein alongside performance issues are regressions in system
behavior. Bugs (or “features” as they are sometimes called) can introduce
side-effects in your stack. These new behaviors can introduce localized or
systemic issues in your application. Consider the capability for any Ruby
library to monkey-patch core parts of the Ruby standard library. Imagine if one
of your dependencies had taken it upon themselves to alter something like
Net::HTTP
to their liking – what might happen to the rest of your
application’s HTTP requests? (Hint: It’s not a pretty sight, I’ve been bitten
by this once.)
Minimizing disruption due to poorly behaving libraries
Using continuous integration and writing comprehensive tests that not only perform localized unit tests but that exercise your dependencies at a variety of depths will help to catch these types of issues. In our case, updating a gem caused a cascading failure through our tests. This let us catch the issue before it made its way to production; things would have been less pleasant had we not been able to do so. Another helpful feature of most modern package managers is the ability to pin versions; in this way you can accept what should be minor changes (say, point releases) without much oversight but require manual changes to import new major versions of a dependency.
Weigh your options
It’s hard to get a good feeling for just how well-behaved or large a dependency is going to be; some things that seem rather innocuous can have far-reaching effects on your application’s stability, security and performance. Even mature, well established, projects that have plenty of experience releasing software for years will periodically introduce bugs that can affect your code. Through a combination of observation, testing and general awareness you can maximize the value that you get from third party packages while (hopefully) minimizing the blast radius when things do go wrong.