Dependency Management Is More Than Just Package Management
- 8 minutes read - 1607 wordsDependency Management is a Big Deal, it’s important, and yet a lot of projects – and some entire ecosystems – do a terrible job at it. Often it seems that people either pretend it doesn’t matter or they just ignore it. (Or are unaware of the need for dependency management.)
Go does a great job at helping developers with package management, but dependency management is more than just package management.
Here are some strategies to follow to do a better job of dependency management.

What’s the weakest link in your dependency chain? Photo by Alan Levine CC-BY
There are at least three types of dependencies (direct & indirect) in most projects:
- library dependencies
- toolchain dependencies
- platform dependencies
Library Dependencies
In most modern programming environments, it is almost trivial to add a library dependency to your projects. This creates a direct dependency on a particular version of that library. It also creates indirect dependencies on all the “downstream” dependencies of that library – and so on, recursively, until an entire tree of libraries has been added.
For example, adding Gin to a Go project:

Screenshot of adding Gin to a Go project, including transitive dependencies.
This feature is great, because you don’t have to reinvent the wheel for every bit of functionality in your project.
It’s also a huge risk because you shouldn’t necessarily trust random bits of code from random strangers around the world. But amazingly, this strategy for importing libraries mostly works. You import a library, take a skim through the code, do some testing, and ship your project.
Now comes the tricky part – managing your dependencies. There are a broad range of strategies that projects follow, existing along a spectrum of sorts. These are not all good strategies!
- “vendor” everything and upgrade very infrequently
- “pin” dependencies to known-good versions and use only install those versions when building (possibly pulling from a local cache)
- soft-pin to a range of possible versions that you expect are not going to introduce regressions in yourapp
- YOLO … just build using whatever the latest published versions of your dependencies are (direct or indirect)
In the most conservative form, you actually pull the code of dependencies into your own repo or mirror and build it from there. This gives you complete control over patching and when to upgrade to new versions. In my experience, organizations that choose vendoring are very slow to upgrade dependencies (sometimes simply never upgrading something that is working).
Pinning, as done in go.mod/go.sum, is almost as conservative, but it allows for easier upgrades just by changing the pinned tag. Combined with a local cache or mirror of dependencies, it provides nearly the same level of control as full vendoring but with more flexibility. In this strategy a build tool will always just pull the same pinned versions of all dependencies.
One risk here is that, without a local cache, a dependency version can disappear without warning, possibly breaking your CI and preventing you from shipping. If you’re running a business with critical library dependencies you should definitely either maintain a local mirror of all of your dependencies, or you should fully vendor everything. Once you’ve integrated it, you should treat it like it’s your own code, because it’s just as critical to building your product.
What I’m calling “soft-pinning” is just a version spec in a dependency file that allows for a range of versions for dependencies. For example, instead of exactly gin v1.7.7, any version v1.7.x where x≥5. This might be reasonable if you completely trust the developer of such a dependency. You have to trust not just their code but also their dependency management. Because if you’ve soft-pinned a library that releases a new 2.15.8 and they have soft-pinned several deps that have released new versions, then you might suddenly have a dozen new packages show up in your CI at 2am on a given Tuesday. (Don’t do this if you can help it, the risks outweigh the flexibility.)
The YOLO strategy is the most carefree: always just build with whatever is the latest. It will probably work out, right? (Don’t do this, it’s bonkers.)
Toolchain Dependencies
The toolchain for a given project is arguably the most critical type of dependency. By “toolchain”, I’m referring to all of the tools you need in order to build a shippable product. This includes the compiler, linker, static analysis tools, test runners – anything that is invoked by your CI pipeline. In fact, the toolchain should probably also include the scripts that make up the pipeline, because it’s not always straightforward to assemble the right set of commands to build the product.
Clearly some of these are more critical than others. In a crisis where you’ve somehow managed to lose all the copies of your test runner, but you still have the compiler, linker, and scripts then you can build and ship.
This example seems implausible, but I once had the new owner of a former employer’s corporate assets contact me to see if I could help them figure out how to build the code, because apparently nobody could find tool installation CDs or figure out the magic incantations to build the product.
As noted above, if you’re running a business that relies on a specific set of tools, you should have carefully archived copies of the exact versions in the entire toolchain that you use to deliver your product or service.
With modern programming environments we have good strategies for managing this, but it’s still not something that is universally well practiced. Even if you’re paying attention and think you’re doing a good job, it’s easy to make mistakes.
Let’s look at Aklatan as an example.

Screenshot of Aklatan’s CI script.
Control mechanism: This script explicitly specifies that Go 1.17.6 is used to build the project. If this was a work project and I gained team members helping me to build this, we’d want to make sure we were all using the same version. This can be critically important with other languages that are somewhat less stable between releases than Go. But it’s still critical for Go that we’re at least on the same major release, because if a team member running Go 1.18 locally starts using new language features they are going to break CI (and everyone else on the team).
Risk: It is incredibly easy for version drift to creep in, especially if
you don’t have a strictly uniform developer OS environment. I just checked
and I’ve got 1.17.2 on my dev server, 1.17.8 on my laptop, and as shown
above, 1.17.6 in CI. Oops. What happened: I pinned 1.17.6 in CI at the
beginning of the project but have since pulled upgrades to the tools via
apt update
on my laptop. And that’s just one person on a tiny project –
imagine how hard this is to manage on a team of a dozen people with running
a free-choice mix of Debian, Arch, Fedora, and that one Windows dev.
Control mechanism: In addition to specifying the Go version, it’s also worth noting that each of the tools installed by the script are pinned to a specific version. Golangci-lint is v1.43.0, go-junit-report is v0.9.1, and semgrep is 0.86.5 running on python 3.10.4.
Risk: This seems like it is tightly specified, but there’s a catch. By
only specifying the version for semgrep as the direct dependency, I’m
allowing the indirect dependencies to drift. I compared two python
virtualenvs: one from last week, and one from this week, and found that in
just a few days’ time, three out of 22 packages had released new versions
and were installed into the new installation. In order to fully specify the
semgrep venv, I should be using the output from pip freeze > requirements.txt
, committing the requirements.txt, and then using pip install -r requirements.txt
in the CI script.
Platform Dependencies
If you’re releasing application software into the world, you have less control over platform dependencies because you probably have to support a variety of platforms.
But if you’re developing a web app or for some other closed system, you can tightly control your platform and its dependencies.
Managing platform dependencies is a critical activity. You can waste time if you have a lot of packages associated with your platform, and you upgrade every package any time there’s a new release. On the other hand, you can’t ignore security updates. If you’re running on a server (VM or metal), you can install these updates manually or automatically.
If you’re running your deployments in containers there’s an extra step: container must be rebuilt with new versions every time you want to include a new package update. This is a bit more work, but it also provides a bit of extra control. It also makes the inclusion of the upgrades explicit in your version history and it provides an audit trail, and it can use roughly the same workflow as including new library or toolchain versions.
Finally, echoing the notes above: if your business relies on a specific platform to deliver your app, you should maintain careful backups of the exact set of packages that you rely on.
Next Time
On Friday we’ll look at input validation (e.g. forms) with Gin.
Next week we’ll add just a little bit of styling to make the app just a little bit less ugly.