Code Dependencies: A Devil in Disguise
As developers, we rely heavily on code dependencies – third-party libraries, frameworks, and modules that provide ready-made functionality that we can leverage in our applications. On the surface, dependencies seem immensely beneficial, allowing us to develop faster by assembling much of our app from existing building blocks. A recent survey of over 4 million websites found the median web app uses 31 separate JavaScript dependencies^1.
However, dependencies come with some pretty big risks and downsides that often get overlooked amidst the allure of speedy development. Make no mistake – improperly managed dependencies will eventually lead you down the fiery path of maintenance hell. In this article, we‘ll take an honest look at the pros and cons of code dependencies and discuss strategies for using them judiciously to keep your app on the side of the angels.
The Temptation of Dependencies
There‘s no doubt using dependencies makes development faster and easier, especially in the beginning. Most modern web apps rely on dozens of dependencies to handle things like user authentication, data access, UI rendering, state management, and more. Imagine having to write all that functionality from scratch!
By using well-tested libraries to handle common needs, you can focus your precious development time and brainpower on your app‘s core value – the "secret sauce" that makes it unique and useful. This is the productive upside of dependencies that makes them so tempting. No wonder 97% of companies say they use open source dependencies in their applications^2.
The Risks Lurking Below
So what‘s the problem with assembling an app entirely out of third-party parts? Well, it‘s a lot like building a house of cards. It may go up quickly, but it‘s precariously fragile.
First and foremost, a dependency is code that you didn‘t write and don‘t fully control. When you use a library, you‘re putting your faith in its creators to maintain and evolve it in a stable, backward-compatible way. Dependencies are often complex with their own dependencies, so a seemingly small change can have huge rippling effects.
The JavaScript world is infamous for how quickly popular tools and frameworks come and go. Anyone remember ExtJS, YUI, knockoutJS, Backbone and the early days of Angular? All were must-use dependencies at one point that have since faded or dramatically changed. Apps heavily reliant on them had to rewrite huge amounts of code just to keep up.
Even hugely popular tools backed by big companies are not immune. In 2016, Facebook announced it was shutting down its Parse mobile backend-as-a-service platform, leaving apps built on it scrambling to find a replacement. Migrations are costly, time-consuming, and take focus away from improving the app itself.
A 2021 analysis by software supply chain management company Sonatype found 29% of the world‘s most popular open-source projects have had at least one public security vulnerability^3. The infamous npm left-pad
incident showed how one unpublished 11-line dependency could instantly break thousands of projects^4.
The Coupling Trap
Perhaps the biggest insidious risk of dependencies is tight coupling – when your app‘s code becomes deeply intertwined with and reliant on a dependency. Some signs you may be tightly coupled:
- Directly instantiating classes from the dependency
- Inheriting from or extending a dependency‘s base classes
- Pervasive usage of a dependency‘s specific methods and conventions
Coupling makes it very difficult to remove or replace a dependency without major surgery to your codebase. It‘s like a parasitic vine that slowly grows around a tree until they are inseparable. The longer it goes on, the worse it gets. Escaping the coupling trap is one of the main reasons apps eventually get rewritten from the ground up.
Hidden Dependencies
It‘s not just your direct dependencies you have to worry about. Those dependencies have their own dependencies, and so on, forming a vast tree of indirect "transitive" dependencies in your app. The deeper this dependency tree goes, the higher the risk.
A 2020 report by open source security firm Contrast Security found transitive dependencies account for 80% of the total code in the average application — code the developers have likely never even seen^5! It‘s no wonder transitive dependencies are a leading source of risk and vulnerabilities.
Even more insidious are "container escape" vulnerabilities that arise when a dependency‘s environment bleeds through abstraction barriers into places it shouldn‘t. Researchers at Georgia Tech found dozens of real-world Node.js apps with prototype pollution issues that allowed dependency code to overwrite supposedly isolated objects^6. Yikes!
Real-World Nightmares
If you think dependency dangers are purely theoretical, think again. Here are a few real-world stories of dependency drama:
-
In 2016, a hacker gained access to a developer‘s npm account and injected malicious code into a package called
event-stream
that was a dependency for millions of projects. The malicious version tried to steal bitcoins from other apps^7. -
In 2018, the original author of the popular Node.js web framework Koa abruptly deleted his GitHub account and took all his code with him, breaking thousands of dependent projects overnight^8.
-
In 2022, widely used npm packages
colors
andfaker
had deliberately sabotaged versions published as part of a political protest by one of their maintainers^9. The malicious code resulted in infinite loops and crashed apps.
Scared yet? You should be! But don‘t despair, there are ways to use dependencies responsibly.
Taming Dependencies
So what‘s a loving, conscientious developer to do? We can‘t abandon dependencies altogether – that would be like reinventing the wheel over and over. But we must defend against their risks. Here are some strategies I‘ve found effective:
1. Carefully evaluate each dependency
Don‘t just grab every shiny new library that crosses your path. Do your research and choose dependencies that are stable, actively maintained, well-documented, and widely used. Avoid anything that‘s brand new or unproven. I like to divide potential dependencies into three risk categories:
- Green: Stable, mature, heavily used, and unlikely to change much
- Yellow: Somewhat new or niche, but actively developed, so some risk of change
- Red: Bleeding edge, low usage, high change risk – avoid if possible!
2. Quarantine dependencies
Keep dependencies isolated from your core domain/business logic code. That stuff is the heart of your app – its "secret sauce". If you must use a dependency there, inject it in so it can be easily replaced.
Segregating dependencies at the edges of your app with a thin anti-corruption layer is even better. That way dependency changes can‘t easily leak in and infect the rest of the codebase.
3. Have an escape plan
Even with precautions, one of your dependencies will eventually change or die in a problematic way. Don‘t be caught with your pants down!
Have a contingency plan to replace problematic dependencies quickly. Regularly back up your dependency source code in case it suddenly gets taken offline. Keep your eyes open for newer, better alternatives. Don‘t be afraid to swap out dependencies pre-emptively before they become time bombs.
4. Watch for warning signs
Generate and review a dependency graph for your app to visually gauge your reliance on third-party code. Here‘s an example for a TypeScript project:
If your dependency graph looks like an impenetrable spider web, that‘s a major red flag. Some other coupling warning signs:
- Classes with a high number of dependency imports
- Dependency imports in many files across the codebase
- Direct usage of dependency classes/methods in your business logic
- Sub-classing or extending dependency base classes
A good rule of thumb: no more than 100 dependencies per project, and no single file should import more than 10 dependencies^10. If you spot these smells, take action to refactor and get dependencies under control before they control you.
5. Stay up-to-date, but not too up-to-date
Keeping your dependencies updated is important for getting bug fixes and security patches. However, living on the bleeding edge is risky. A good compromise is following semantic versioning and specifying acceptable version ranges in your package manifest:
- Patch versions (e.g. 1.0.x) for low-risk bug fixes
- Minor versions (e.g. 1.x.0) for backward-compatible new features
- Major versions (e.g. 2.0.0) for big, breaking changes – update with caution!
6. Monitor and scan continuously
Checking your dependencies once isn‘t enough. You need to continuously monitor them for new security vulnerabilities and license issues. Open source tools like npm-audit and commercial services like Snyk and WhiteSource are great for automating this.
Gartner estimates by 2025, 45% of organizations worldwide will experience an attack on their software supply chains (including open source dependencies)^11 – so vigilance is key!
Conclusion: Trust No One But Yourself!
Despite their benefits, dependencies are inherently untrustworthy. Even the most reputable ones can pull the rug out from under you when you least expect it.
The key is to be pragmatic and defensive in how you use them. Limit usage in your core domain, keep a watchful eye on them, and always have a Plan B. It‘s okay to use dependencies to speed development, but never, ever trust them completely with your app‘s future.
In the immortal words of the X-Files, "Trust no one". With dependencies, a little healthy paranoia goes a long way. Stay vigilant, my friends!