6.2 Explicit Dependency Management

The reason why we sometimes feel tempted to check our dependencies into source control is so that we get the exact same versions across the dependency tree, every time, in every environment.

Including dependency trees in our repositories is not practical, however, given these are typically in the hundreds of megabytes and frequently include compiled assets that are built based on the target environment and operating system[2], meaning that the build process itself is environment-dependant, and thus not suitable for a presumably platform-agnostic code repository.

During development, we want to make sure we get non-breaking upgrades to our dependencies, which can help us resolve upstream bugs, tighten our grip around security vulnerabilities, and leverage new features or improvements. For deployments however, we want reproducible builds, where installing our dependencies yields the same results every time.

The solution is to include a dependency manifest, indicating what exact versions of the libraries in our dependency tree we want to be installing. This can be accomplished with npm (starting with version 5) and its package-lock.json manifest, as well as through Facebook’s Yarn package manager and its yarn.lock manifest, either of which we should be publishing to our versioned repository.

Using these manifests across environments ensures we get reproducible installs of our dependencies, meaning everyone working with the codebase — as well as hosted environments — deals with the same package versions, both at the top level (direct dependencies) and regardless the nesting depth (dependencies of dependencies — of dependencies).

Every dependency in our application should be explicitly declared in our manifest, relying on globally installed packages or global variables as little as possible — and ideally not at all. Implicit dependencies involve additional steps across environments, where developers and deployment flows alike must take action to ensure these extra dependencies are installed, beyond what a simple npm install step could achieve. Here’s an example of how a package-lock.json file might look:

  1. {
  2. "name": "A",
  3. "version": "0.1.0",
  4. // metadata…
  5. "dependencies": {
  6. "B": {
  7. "version": "0.0.1",
  8. "resolved": "https://registry.npmjs.org/B/-/B-0.0.1.tgz",
  9. "integrity": "sha512-DeAdb33F+"
  10. "dependencies": {
  11. "C": {
  12. "version": "git://github.com/org/C.git#5c380ae319fc4efe9e7f2d9c78b0faa588fd99b4"
  13. }
  14. }
  15. }
  16. }
  17. }

Using the information in a package lock file, which contains details about every package we depend upon and all of their dependencies as well, package managers can take steps to install the same bits every time, preserving our ability to quickly iterate and install package updates, while keeping our code safe.

Always installing identical versions of our dependencies — and identical versions of our dependencies' dependencies — brings us one step closer to having development environments that closely mirror what we do in production. This increases the likelihood we can swiftly reproduce bugs that occurred in production in our local environments, while decreasing the odds that something that worked during development fails in staging.