Dep MgmtLANGUAGE-SPECIFICSIMPLIFIEDCONTESTED

Transitive Dependencies

A small package can carry a large graph you did not choose, cannot audit, and run in production with your own privileges.

The requirement, the obvious build, and why it breaks

Every lesson starts where the work starts: someone asked for something, and the first implementation that comes to mind survives until the requirement changes.

The question

I added one package and my install pulled in four hundred. What is my actual relationship to the ones I never named?

The requirement

A tiny helper is needed for parsing a colour string. The package is 40 lines, has no bugs, and is exactly right. Installing it adds 312 packages to the tree.

The obvious build

Transitive dependencies are the direct dependency's problem. Its maintainer chose them and presumably vetted them; we depend on the thing we named, and the rest is an implementation detail.

Why it breaks

It is an implementation detail right up to the moment it is a vulnerability, at which point it is unambiguously your production incident and your customer notification.

How it breaks as requirements change
  • It is an implementation detail right up to the moment it is a vulnerability, at which point it is unambiguously your production incident and your customer notification.
  • You cannot fix it directly. The patch has to travel from the vulnerable package, through however many maintainers sit between it and you, each of whom releases on their own schedule — and the chain is only as fast as its slowest link.
  • The "presumably vetted" assumption is doing enormous work. Most maintainers picked their dependencies the same way you picked yours: quickly, once, years ago.
  • The graph also decides things you care about: install time, image size, cold-start time, and how many independent parties can push code into your build.
  • And it changes without you. A patch release of a direct dependency can add a new transitive subtree, which is a supply-chain change delivered as a bug fix.
RequirementConstraintsInvariantsResponsibilitiesBoundariesInterfacesStateDependenciesFailureImplementationTestsFeedbackEvolution

What limits the solution, and what must never stop being true

This domain leads with these two. A design that ignores its constraints is not a design, and an invariant nobody named is one nothing is protecting.

Constraints
  • The resolver installs the closure automatically; there is no supported way to take the top of the tree and none of the rest.
  • The security scanner reports on every node in the closure and blocks the deploy, without distinguishing what is reachable from your code.
  • Reading 312 packages is not a thing anyone will do, this quarter or ever.
Invariants
  • Whatever is in the closure runs with your process's privileges — its filesystem access, its network access, its environment variables. There is no sandbox between a transitive dependency and your secrets (Trust Boundaries).
  • The resolved graph must be recorded, or the thing you tested and the thing you shipped are different programs.

Who owns what, and where the seams fall

Responsibilities decide boundaries; boundaries decide what an interface has to say.

Responsibilities
  • You own the closure operationally: it runs on your machines, under your compliance obligations, in front of your customers.
  • Your direct dependency's maintainer owns the *choice*, and their judgement about depth is now part of your evaluation of them.
  • Your build tooling owns making the closure visible — the lockfile and the generated inventory are what convert an invisible obligation into a reviewable one.
  • Nobody owns a package five levels down that three of your direct dependencies happen to share. That shared node is where an incident is most likely and least expected.
Boundaries
  • The boundary a transitive dependency crosses is the process, not the module. Encapsulating a library in your code hides its API from your callers; it does nothing to constrain what the library's own dependencies can do at runtime.
  • A real boundary against the closure is a runtime one — a permission model, a separate process, a locked-down build environment — and it lives outside this domain (Least Privilege as a Design Decision).
  • What this domain controls is depth of the *direct* choice: preferring a dependency with a shallow graph is the only leverage exercised at design time.

One line in, three hundred out

The install log is the most honest document in dependency management, and almost nobody reads it. It is worth reading once, slowly, for a package you consider trivial.

The point is not that 312 is a bad number. It is that you approved one package and are now running 312, each of which can execute code in your build, in your process, with your environment.

What "add one small package" actually resolved to
1$ add color-string@1.9.1
2
3+ color-string 1.9.1 # the 40 lines you wanted
4 + color-name 1.1.4
5 + simple-swizzle 0.2.2
6 + is-arrayish 0.3.2
7
8... and via the build plugin that consumes it:
9
10 312 packages added
11 87 distinct maintainers with publish rights
12 6 packages last released > 4 years ago
13 2 packages with a single maintainer and no 2FA
14
15What you reviewed: 1 line
16What you now run: 312 packages, in-process, with your env

The three lines that matter are the last three. Not the count — the number of independent parties who can push code into your next build, and how many of them have stopped paying attention.

Two dependencies, two different obligations

Direct and transitive dependencies feel like the same thing because the resolver treats them the same way. They are not: they differ in who chose them, what you can do about them, and how a fix reaches you.

The row that decides most arguments is the last one. For a direct dependency, fixing a problem is work you can schedule. For a transitive one, it is a wait you cannot, and the only lever available is to remove or replace the parent.

  • The middle row is the one people skip: the runtime makes no distinction at all, which is why "it is only transitive" is not a security argument.
  • Overrides are the one direct lever on a transitive node, and they are a fork of someone else's resolution — useful, and debt from the moment they are added (What Technical Debt Actually Is).
  • The last row is where design lives. Every other row is response; this one is choice.
DirectTransitive
Who chose itYou did, in a reviewable commitA maintainer you have never met, at a time you cannot see
Who runs itYouYou
Can you remove itYes — find the call sites and deleteOnly by removing or replacing whatever pulled it in
How a fix arrivesYou bump the versionEvery maintainer between you and the fix has to release, in order
What you can do todayContain it, pin it, replace it, justify itInventory it, override it deliberately, or choose a shallower parent next time

The package as a unit you now own

DOMAIN-SPECIFICIn a regulated or high-assurance setting — payments, healthcare, anything with an SBOM obligation to a customer — the inventory is a deliverable and depth translates directly into audit hours and contractual exposure, so a shallow graph is worth paying real features for. In an internal tool with no external users the same graph is a mild annoyance, and spending a week on it is the more expensive mistake.

It is clarifying to run a dependency through the same responsibility questions you would run one of your own modules through. The answers are usually worse than for anything you wrote, and the "changes when" list is the finding.

Note that none of the reasons it changes are yours. That is the whole relationship: a unit inside your system whose entire set of change triggers belongs to other people.

responsibilitiesis-arrayish@0.3.2 — four levels down, present in every production buildA transitive package, examined the way you would examine your own code
Knows
  • Nothing about your domain
  • That it is being called with a value of unknown provenance
Does
  • One type check, in about a dozen lines
  • Runs in your process, with your environment, your filesystem handle and your network access
Depends on
  • Its own registry entry
  • One maintainer's account remaining uncompromised
  • The publish pipeline of whoever owns it today
Changes when — 5 distinct reasons
  • Its maintainer chooses to publish
  • Ownership of the package is transferred
  • The registry account is compromised
  • A parent widens or narrows its range
  • Your resolver version changes how it deduplicates

Five reasons to change, none of them yours, guarding twelve lines of logic you could write in a minute. That asymmetry is the argument — not that this specific package is dangerous, but that the cost of owning it is entirely disconnected from the value it provides. The response is not "ban small packages": it is that when you are choosing between two direct dependencies, the one that does not drag in a dozen of these is cheaper to own forever, and that is worth thirty seconds of looking.

How to build it

Most important first.

  • Read the graph before you add, not after. Most package managers will print the resolved tree for a candidate in one command; thirty seconds of looking is the entire intervention.
  • Treat graph depth as a selection criterion alongside features, the way you already treat licence and maintenance. Between two adequate libraries, the shallow one is materially cheaper to own.
  • Prefer dependencies from ecosystems and authors that themselves depend on little. Depth is inherited, and a maintainer who vendors rather than depends has already done this work for you.
  • Generate an inventory of the closure as a build artefact, so the question "what do we run" has an answer that is not a person's memory.
  • Distinguish reachable from present. A vulnerability in a code path your program never calls is a real obligation and a different priority from one on your request path — and saying so out loud is how a team stops treating every scanner finding identically.
  • When the chain will not move, override the resolution deliberately, with a comment and a date, and track it as debt rather than as a fix (Deliberate Debt).

What the next change costs

The field this whole domain exists for. A structure is only better if it makes the change after this one cheaper — and it is worth saying which changes it does not help.

Cost of the next change
  • Patching a vulnerability you own directly: one bump, one CI run. Patching one four levels down: zero code changes and an unbounded wait, because the cost is not work, it is other people's release cadence.
  • The next dependency you add has a cost equal to its own closure minus the overlap with what you already have — which is why the second package from a given ecosystem is usually far cheaper than the first.
  • Removing a direct dependency only removes the subtree nothing else references. Teams routinely delete a package and see the vulnerability count barely move, and this is why.
  • Choosing the shallow alternative today makes every future audit, upgrade and scanner triage proportionally cheaper, forever. It is one of the few dependency decisions that keeps paying.
What the recommended approach costs
  • Choosing the shallow library often means choosing the less capable one, and the missing capability becomes code you write and maintain. That trade is only good when the missing capability really is small.
  • Auditing depth at add-time adds friction to a decision that is usually fine, and most of that friction is spent on packages that would never have hurt you.
  • Vendoring — copying a small dependency into your repository — removes the graph and takes on the maintenance and the loss of advisory notifications. It is right for tiny, stable code and a trap for anything else.

What can go wrong

Failure modes
  • A single-purpose micro-package deep in the tree is transferred to a new maintainer and ships a malicious patch release. Every build that resolved a floating range picks it up within hours.
  • The team responds to graph size by forbidding new dependencies, and engineers respond by copying library source into the repository — the same code, now with no version, no advisory feed and no upgrade path.
  • An override is applied to unblock a scanner, works, and is never removed; two years later the real dependency has moved on and the override silently pins a version that has its own advisory.
  • The inventory is generated and nobody reads it, so it satisfies an auditor and changes nothing — the mitigation succeeding at its ceremony and failing at its purpose.
Dependencies, and their direction
  • Your deploy depends on a release chain of strangers, in series. Latency is additive, and any one of them can stall it indefinitely.
  • Two direct dependencies that share a transitive child are now coupled to each other through it: upgrading one can force the other's version, which is coupling nobody declared (Shared-State Coupling).
  • Your image size, install time and cold start depend on the closure, not on what you use — a performance dependency on a decision you did not make (Designing for Cost).
Misreads
  • "Big graphs are inherently insecure." Depth is a proxy for attack surface, not a measurement of it. A shallow graph with one unmaintained package can easily be the riskier one (Premature Optimization, Reclaimed is the same mistake in a different costume: optimising a proxy).
  • "Just vendor everything." Vendored code has the same bugs with none of the notifications, and the copy diverges the first time someone patches it locally.
  • "The scanner's number is the risk." A count of advisories in the closure is not a measure of exposure, because most of it is unreachable and some of what is reachable is unreported.
  • "We should write our own." For a 40-line colour parser, sometimes yes. For anything with a specification behind it, your version is a dependency with a graph of one and a maintainer who is busy (Do We Need a Package for This?).

Testing it, and how it ages

What to test, and at which boundary
  • A build-time assertion that the lockfile is unchanged by a clean install — the cheapest possible check that your graph is what you think it is.
  • Test your own behaviour, not the closure's. You cannot test 312 packages; you can test that your colour parsing produces the right value, which is the only claim you are making (Testing as Design Feedback).
  • For an override, a test that pins the behaviour you were protecting, so removing the override later is a decision with evidence rather than a leap.
How this design ages
  • Graphs get deeper over time as maintainers reach for convenience packages, so a dependency's closure is a moving quantity and today's measurement expires.
  • Ecosystems reverse this in waves — a period of micro-packages, then a correction toward batteries-included libraries with vendored internals — and the sensible position tracks the ecosystem rather than the principle.
  • Runtimes increasingly ship what people used to install: built-in test runners, fetch, structured clone, date handling. Every one of those retires a slice of the graph, and it is worth re-checking whether a dependency is still earning its place (Revisit Triggers).

Where this applies

This domain's advice is contested more than most. These labels say what each claim is specific to — and where CONTESTED appears, the note gives the strongest form of the opposing view rather than a caricature.

  • LANGUAGE-SPECIFICThe npm ecosystem's culture of very small packages makes closures of hundreds routine for trivial functionality; Go's standard library and module conventions, and Rust's heavier crates, produce graphs an order of magnitude smaller for comparable work. In a Maven or Go project, "read the tree before adding" is a five-second habit; in npm it is a real decision with real friction, and advice written for one ecosystem systematically over- or under-states the effort in the other.
  • SIMPLIFIEDThe tree is treated here as a static set. Real resolvers deduplicate, hoist, apply peer constraints and can produce different graphs for the same manifest on different versions of the tool itself — which matters enormously for debugging an install and not at all for the design decision this lesson is about.
  • CONTESTEDA serious counter-position holds that transitive depth is close to irrelevant compared with the reputation and release discipline of the direct dependency: a well-run project with two hundred transitive packages is safer than a hobby project with none, because the failure mode that actually bites is an unmaintained package, not a deep one. This is a good argument, and the two criteria are better used together than ranked.

Where the depth lives

This domain teaches the codebase-level structure and hands the rest off.

Domains that do not exist yet
  • Programming Languages & Runtime Internals — whether a dependency can be isolated at all is decided by the runtime's linking and permission model, which is why the same advice has different force in a language with module-level capabilities than in one where any imported code can read the environment.