跪拜 Guibai
← All articles
Node.js · NPM · Maven

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

By peach ·
Read original on juejin.cn ↗ Google Translate ↗ Alt translation

The position-versus-name distinction drives storage duplication, reproducibility, and dependency-conflict costs. It's the reason JavaScript projects carry per-project node_modules, Java shares a global .m2, and pnpm's hardlink approach is becoming the default in Python tooling.

Summary

The divergence between npm and Maven comes down to whether module resolution uses implicit position or explicit declaration. Node's require() climbs from the current file looking for node_modules, never checking package.json dependencies, so the runtime only sees physically present files. That makes location the interface, forcing npm to copy dependencies into every project to satisfy the resolver. Maven instead reads groupId, artifactId, version from pom.xml, fetches from a repository, and places them in ~/.m2; the location is derived from coordinates, so it can be global and shared.

npm's history shows the consequences. v2 nested every dependency, causing duplication and path length issues. v3 flattened to reduce depth, but introduced phantom dependencies, unstable trees, and lockfiles. pnpm solves this without changing Node's rules: it uses a content-addressed store, hardlinks files into .pnpm, and symlinks declared packages, making location relocatable rather than unique. Java, Go, and Rust treat location as output, so global caches are free; Go and Rust even resolve at build time, producing static binaries. Python's venv and uv are moving toward the same pnpm pattern.

The underlying question is whether a module identifier is a name or a position. package.json exports, import maps, and the imports field are attempts to turn positions back into names. But node_modules will flatten, not disappear, unless resolution moves entirely to build time.

Takeaways
— Node's require() climbs the directory tree to find node_modules and never reads the dependencies field.
— npm v2 nested dependencies, causing duplication and Windows MAX_PATH failures.
— npm v3 flattened dependencies, creating phantom dependencies and unstable trees that required package-lock.json.
— pnpm uses a content-addressed global store, hardlinks files into .pnpm, and symlinks declared packages.
— pnpm does not modify Node's resolution rules; it changes where files live.
— Go resolves at build time and produces static binaries; Rust does similarly.
— Java's classpath is explicit, so .m2 can be global and its location is configurable.
— Python's venv isolates site-packages per project but duplicates; uv uses hardlinks from a global cache.
— package.json exports, import maps, and the imports field are attempts to turn positions into names.
— node_modules may flatten but won't disappear unless resolution is fully moved to build time.
Conclusions

Calling module resolution 'position as a contract' turns historical quirks like phantom dependencies and lockfiles into a single design constraint, making pnpm's hardlink-and-symlink scheme an optimization rather than a semantic change.

The trade-off between runtime flexibility and build-time determinism is not zero-sum: Node's flexibility costs duplication, while Java's explicit classpath requires complex conflict resolution and reproducibility discipline.

The push toward build-time bundling in frontend tooling suggests node_modules may eventually become a build artifact rather than a runtime dependency, which would end the duplication problem without needing a new resolver.

Concepts & terms
Directory climbing
Node's resolution algorithm where require() starts from the current file's directory and moves upward, checking each level for node_modules matching the requested package. It never consults package.json dependency declarations.
Phantom dependencies
Packages that are reachable via require() because they were hoisted to the root node_modules, but are not listed in the project's package.json. They appear to work, but break when the install order changes or on another machine.
Classpath
An explicit list of JARs and directories passed to the JVM at startup. Maven constructs it from coordinates, so the physical location of dependencies is irrelevant to the runtime and can be shared globally.
Content-addressed store
A cache that stores files by a hash of their content, ensuring each unique data blob is kept only once. Used by pnpm's store, npm's cache, and uv's wheel cache to avoid duplicate downloads.
Hardlink
A file system link that makes multiple names point to the same underlying disk data. pnpm uses hardlinks to give each project a directory entry for a dependency without consuming extra disk space beyond the global store.
Source: juejin.cn ↗ Google Translate ↗ Backup ↗