跪拜 Guibai
← Back to the summary

npm Builds node_modules, Maven Builds .m2: Position vs. Declaration in Module Resolution

Same installing dependencies, why does npm grow into node_modules while Maven grows into .m2

Look at two scenarios.

A Spring Boot project is cloned, mvn compile runs, and the project directory stays clean: dependencies all sit in ~/.m2/repository, arranged neatly by groupId/artifactId/version. Ten Java projects share the same Spring; only one copy exists on disk.

A React project is cloned, npm install runs, and the project gains a node_modules: thousands of directories nested layer upon layer. Ten Node projects mean ten copies.

Both are "installing dependencies," but one is a global repository, the other is a directory tree inside the project.

The most common explanation is "npm is poorly designed." That answer is cheap but doesn't hold up to questioning: npm has the entire community's decades of investment behind it. Is it really that stupid? Since pnpm can solve it, why doesn't npm just copy it?

The answer has little to do with the package manager's code quality, and a lot to do with something more fundamental: when modules are resolved, what does the runtime actually "see"?

I. The dividing line: is the input of resolution explicit or implicit?

First, look at how Node finds packages.

require('lodash') executes by starting from the directory of the current file, checking for node_modules/lodash; if not found, it climbs up one level, and so on until the root. This is the so-called "directory climbing."

In this process, there's an easily overlooked fact: Node's resolver never reads the dependencies field to decide whether a package "should be visible." It only looks at whether the file exists.

So "declaration" and "visibility" are two independent systems. Declaration is for humans and for package managers; visibility is for the resolver—and the resolver only recognizes the physical location on the file system.

Python is more direct: import iterates over sys.path; whatever paths are in sys.path, that's where it imports from.

The commonality between these ecosystems: location is the input of resolution, so location is the interface.

Now look at Maven.

Maven doesn't guess when looking for dependencies; it uses coordinates. In pom.xml you write groupId, artifactId, version. Maven takes these three fields to fetch from the repository, then places them into ~/.m2/repository. This path is uniquely determined by the coordinates, and has nothing to do with where your project is or what its directory is named.

Go and Rust go further: after building, the output is a statically linked binary (Go) or an artifact that contains all dependencies (Rust). Once it runs, the act of "finding modules" simply doesn't exist.

So we can draw a line:

Ecosystem Input of resolution When resolution happens Role of location
Node / Python current path + directory tree / sys.path runtime interface, must be exact
Java / JVM explicit classpath runtime startup parameter, can be arbitrary
Go / Rust coordinates in go.mod / Cargo.toml build time cache drop point

Note the second row. Java is a good litmus test: the JVM does need to find classes at runtime, but it does so based on the explicitly passed classpath, not by climbing up from the current directory. So although Java and Node both call it "runtime lookup," their fates are completely different—the former can throw all dependencies into a global .m2, the latter cannot.

This shows that the binary "runtime vs. build time" distinction isn't precise enough. A more accurate description is: is the input of resolution an implicit convention or an explicit declaration?

At this level, we can ask one more question: is a module's identifier a "name" or a "position"?

Java's org.springframework:spring-core:6.1.0 is a name. Name and position are two separate things—the same name can come from a central repository, a company mirror, a local .m2, or even a vendor directory; no matter how location changes, it doesn't affect "which package" it is.

Node's require('lodash') looks like a name but is actually a position: its meaning is "climb up from the current directory, and hit that node_modules/lodash." Name and position are the same thing—switch machines, switch projects, and that name must physically exist at a location allowed by the rules.

If name and position are two things, position is free; if name and position are one thing, position necessarily becomes a contract. Everything else—copy or share, flat or nested, lockfile or not—is just a corollary of this sentence.

II. Node's two compromises

Once you understand "position is a contract," npm's history reads clearly.

npm v2: nesting. Each package installs its own dependencies into its own node_modules. a depends on c@1, b depends on c@2—that's two copies, each in its own directory. This is perfect for isolation—every package sees only its own dependencies, no version conflicts. The cost is duplication and depth. The classic Windows MAX_PATH 260-character problem comes from that era.

npm v3: flattening. Dependencies are hoisted as much as possible to the project root node_modules, and only nested when version conflicts occur. Paths are shorter, duplication is less, but three new problems appear:

Phantom dependencies. After a dependency is hoisted to the root, a package you never wrote in package.json can still be required. Your code "happens to run," but it's not in the declaration. Switch machines or change install order, and it may break.

Unstable dependency trees. The hoisting result depends on the order of installation and the order packages arrive. Two fresh installs may produce different directory structures. package-lock.json was born to pin this down.

Duplication across projects persists. Flattening only solves duplication within a single project. Ten projects still mean ten copies. Because the source of this duplication is not the shape of the dependency tree; it's "position is a contract"—every project needs a physical copy of files located there.

At this point, npm's two paths are both exhausted: either clean but bloated, or lighter but dirty. As long as position is a contract, there is no third path.

III. pnpm: not a "global cache," but making position redirectable

First, clear up a widely circulated misunderstanding: "global cache" is not pnpm's unique weapon. npm has had a content-addressed cache (~/.npm/_cacache) since v5, and pnpm has its store. When it comes to "don't download again," the two are the same thing.

The difference is what happens after installation.

npm is cache + copy: the cache only prevents you from downloading again; installation still copies files into node_modules. Ten projects, ten copies.

pnpm is store + hardlink + symlink, and these three must be seen together:

  1. Content-addressed store. Each version of each package is stored once in a global store, addressed by content hash. The specific path differs by platform; pnpm store path can show it.
  2. Hardlink into the project. The files in node_modules/.pnpm/<pkg>@<version>/node_modules/<pkg>/ are hard links to those in the store—the same disk data, two names, no extra space.
  3. Symlink to compose the dependency tree. Under the project root node_modules, only packages you explicitly declared exist, each being a symlink to the corresponding version in .pnpm.

Here's a key mechanism that many articles don't explain: require resolves symlinks to real paths by default (unless you explicitly enable --preserve-symlinks). That means require('a') gets the real path .pnpm/[email protected]/node_modules/a. And next to that real path, only a's own declared dependencies are linked in.

Thus:

Phantom dependencies disappear that way: there's no path for them to be "hoisted to the root."

pnpm didn't change Node's lookup rules. It changed the fact of "where files are" itself. Position is still a contract, but no longer uniquely determined—the same physical file has different logical positions in different projects, redirected via links.

This is the best possible under the hard constraint that "the interface can't move": don't touch the runtime, only touch the file system.

IV. Counter-proof: once position is not a contract, the problem doesn't exist

If "position is a contract" is the root cause, then ecosystems that don't treat position as a contract should naturally not have this problem. Look at a few samples.

Go: the most thorough.

go.mod declares module path and version; go.sum records checksums. At build time, Go fetches source from GOMODCACHE (default $GOPATH/pkg/mod), compiles, and outputs a statically linked binary. Deployment is just throwing one file; whether the module directory exists is irrelevant.

Rust: the same idea.

Cargo.toml declares crate and version, Cargo.lock locks the whole tree, source is cached in ~/.cargo/registry, and the compiled artifact carries all dependencies.

Java: looks like runtime, actually not.

As mentioned, the classpath is explicit. The direct consequence is that all dependencies can be laid out flat in a global repository, and the build tool assembles a classpath in the startup script or manifest. That .m2 can be globally shared isn't because of some clever design; it's because location never participates in guessing from start to finish.

Here we answer a common follow-up: isn't ~/.m2 itself a fixed location?

Yes, but it's a default, not a contract. ~/.m2 can be changed with <localRepository> in settings.xml, or pointed elsewhere temporarily with -Dmaven.repo.local=D:/repo, without touching a single line of business code. The difference is what role location plays:

Input or output, that's the dividing line. Once location is input, it must be precisely satisfied, so it necessarily becomes a contract; if location is just an output, it's a string that can live anywhere.

The Java ecosystem does have location conventions, but elsewhere: ~/.m2/settings.xml is Maven's config file location, the groupId/artifactId/version/ layout inside the repository is a storage format convention, and Spring Boot's fat jar loads from BOOT-INF/lib/ fixed. All three "find things by location," but the finder is the build tool and the packaging format; the rules are written on the tool side and can be swapped anytime. The real contract is when the runtime finds by location and you have no right to change the rules. Same for Go and Cargo—GOMODCACHE, CARGO_HOME are all changeable defaults; no one requires build caches to live in ~/go or ~/.cargo.

But explicit input has its own bill.

Conflict handling becomes harder. The classpath is flat; only one class with the same name can exist. When two libraries depend on different versions of the same library, Maven uses "nearest wins" to pick one, and a wrong pick leads to a runtime NoSuchMethodError. Dependency conflicts are the norm in the Java ecosystem, not the exception.

By contrast, Node's nested structure is more tolerant here—different versions can sit in their own directories. Go uses minimal version selection (MVS) to pick one, Rust allows different major versions of the same crate to coexist. The convenience of sharing comes at the cost of moving conflict decisions to build time, and someone must get them right.

"Location independence" retreats in controlled environments. Go has go mod vendor, Rust has cargo vendor, and on the Node side there's npm ci with cache for offline installs. What vendor does is copy dependencies into the repository, so the build doesn't depend on the network or the current state of a global cache. For reproducibility and auditability, they voluntarily give up "location independence."

This shows: global caching is not a free lunch. It moves the question of "where dependencies come from" from "inside the project" to "on the machine." Once the machine isn't controlled—CI, offline, code audit—you have to re-fix the location.

Python: an intermediate state, converging toward pnpm.

venv's idea: since position is an interface, give each project its own isolated site-packages. That's paying off the "position is a contract" debt with the file system—isolation yes, duplication remains.

uv goes a step further: a global wheel cache, installs with hardlinks when possible, falls back to copy or reflink. Same idea as pnpm, just a few years later. Across languages, "global content-addressed + hardlink" is becoming the default answer.

V. End point: turning "position" back into "name"

If the root cause is "the identifier is actually a position," then the JS ecosystem's moves these years have a very clear direction: turning them back into names.

The exports field in package.json. A package itself declares which entry points it exposes; whether import 'pkg/foo' works is decided by that declaration, not by "you happen to be able to climb to that file." This step turns deep path imports from a file system accident into a public contract of the package.

Import Maps in browsers. <script type="importmap"> directly provides a mapping of "module name → URL," and the browser no longer needs directory climbing.

Node's imports field. Declare internal aliases with # prefix:

{
  "imports": {
    "#utils/*": "./src/utils/*.js"
  }
}

import x from '#utils/date' maps directly to a concrete file, no reliance on relative path levels.

Bundlers. Webpack, Rollup, esbuild statically analyze import at build time, build the whole graph, and inline everything into the artifact; runtime no longer resolves anything.

But here we should be restrained. Many articles rush to conclude that "node_modules will disappear," which is too fast.

Import Maps' mapping lives in HTML; imports field mapping lives in the nearest package.json—you still need a "conventioned position" to read that table. Node still climbs up to find the nearest package.json. So what's dissolved is never the "directory" but the "directory layout as a contract."

In one sentence: node_modules can degrade from "a shaped dependency tree" to "a flat, unnested artifact directory." But it won't disappear altogether unless the entire resolution process is moved into build time—that is, bundling.

VI. So this isn't "who's better or worse"

Back to the opening question.

npm grows into node_modules not because npm is dumb, but because it chooses to keep uncertainty at runtime—to make require simple and predictable enough. The cost is that position must be exact, so copying is unavoidable.

Maven grows into .m2 not because Java is more advanced, but because it moves uncertainty to build time and explicit declarations. The cost is that you have to handle dependency conflicts yourself, and reproducibility must be assured on your own.

pnpm's position is subtle: under the hard constraint that "the runtime interface can't move," it uses the file system to do a substitution, downgrading "unique position" to "redirectable position." It doesn't overturn the rules; it just makes the rules cheaper.

Whether name and position are the same thing determines what an ecosystem's dependency management looks like; whether to separate them is a question of "where you put the uncertainty." Put it at runtime, accept copying; put it at build time, accept more complex conflict and reproducibility costs. Every ecosystem makes trade-offs within its own constraints; there is no free third option.

As for "whether Node projects should bundle"—that's the extension of this line: once resolution is moved entirely into build time, how does the delivery shape change? This topic is long enough for a separate article.