paulserban.eu

Writing Edition

Paul Serban

Writing, snippets & book notes

pnpm: From Package Management to Monorepo Architecture

A practical deep-dive into pnpm's content-addressable store, symlink isolation, workspace protocol, and the engineering decisions that make it the default for serious JavaScript projects

Introduction

Most developers adopt pnpm because they heard it's faster or uses less disk space. Those things are true, but they're not the reasons it matters. pnpm matters because it enforces a stricter, more honest model of what a dependency graph actually is - and because that strictness compounds into real engineering benefits over the lifetime of a codebase. Speed and disk efficiency are byproducts of a better architecture, not the architecture itself.

This guide covers pnpm from first install through production monorepo configuration. It is written for engineers who want to understand why pnpm works the way it does, not just which commands to run. Each section builds on the previous: you'll understand the content-addressable store before you configure workspaces, and you'll understand workspace isolation before you configure CI caching. The result is a mental model you can reason from, rather than a configuration you're copying from a README.

By the end, you'll be able to set up a full monorepo with isolated packages, shared dependency catalogs, filtered task execution, and a CI pipeline that restores the global store between runs. More importantly, you'll understand what each configuration decision is actually doing.

Why npm's Model Eventually Breaks Down

To understand pnpm's design, you need to feel the problem it's solving. The problem isn't slow installs. The problem is that npm's flat hoisting model makes the dependency graph implicit and fragile in ways that don't announce themselves until they fail in production.

When npm installs your project, it builds a dependency tree and then "hoists" packages as high as possible - placing them near the root of node_modules to avoid duplication. This is efficient in terms of deduplication, but it has a side effect: packages that are not in your package.json become require()-able from your code, because they've been hoisted to the root by some transitive dependency that needs them. If your code does const _ = require('lodash') without lodash in your package.json, that code works - until the transitive dependency that brought lodash along updates its own dependencies and removes lodash, or pins a different major version. The failure is non-local, hard to trace, and often only surfaces in CI or production environments.

This is the phantom dependency problem, and it's endemic in JavaScript codebases that have grown without discipline. The subtler cousin is the "doppelganger" problem: if two packages depend on the same dependency at different, incompatible versions, npm must nest one copy inside the other's node_modules. Now two instances of the same package exist in the same process, and any state they maintain - singletons, caches, prototype chains - is no longer shared. React, in particular, famously fails with a cryptic error when two copies end up in the same bundle. Both failure modes stem from the same root cause: the node_modules structure is a physical accident of the hoisting algorithm, not an intentional representation of the dependency contract.

pnpm's response is not to make hoisting smarter. It is to replace hoisting with a structure that physically enforces the dependency graph: each package can only see what it explicitly declared as a dependency.

The Content-Addressable Store: One Copy Per Version, Forever

The foundation of pnpm's efficiency is a global content-addressable store, typically located at ~/.local/share/pnpm/store on Linux/macOS or %LOCALAPPDATA%\pnpm\store on Windows. Understanding how this store works explains why pnpm is faster, uses less disk, and is safer than the alternatives.

When pnpm downloads a package, it unpacks the tarball and writes each file to the store indexed by a content hash. The key insight is that this is a content-addressable store, meaning the storage key is derived from the content of the file, not its name or version. A file that is identical across multiple packages - and many are, particularly common JavaScript utility files - is stored exactly once. When the same package version is installed into a second project, pnpm creates hard links from that project's node_modules to the files already in the store. A hard link is a directory entry pointing to the same inode as another entry: there is no data duplication. The OS filesystem sees two names for the same bytes.

The practical consequences are significant. On a developer machine with fifteen Node.js projects, TypeScript, ESLint, and their plugins are stored once in the global store rather than fifteen times in fifteen node_modules folders. pnpm's documentation reports that the store can reduce disk usage by 60% or more on machines with many projects - and the more version overlap between projects, the greater the saving. Beyond disk, the hard-link approach means that installing a package that's already in the store is a pure filesystem operation: no network request, no tarball extraction, just inode reference creation. This is why pnpm is fast on warm caches, not because of any clever networking.

# Inspect the global store
pnpm store path
# -> /home/user/.local/share/pnpm/store/v3

# List files in the store (content-addressed)
ls $(pnpm store path)/files/

# Prune packages no longer referenced by any project
pnpm store prune

# Verify store integrity (check that hard links are intact)
pnpm store verify

The store is versioned (v3 in current pnpm releases), and pnpm handles store migrations automatically when you upgrade pnpm versions. The store is safe to share across projects, users, and even CI runners - because it's content-addressed, concurrent reads and writes are safe. If two CI jobs install the same package simultaneously, the worst case is that both write the same content to the same path, which is idempotent.

Symlink Isolation: How pnpm Enforces the Dependency Contract

The store handles deduplication. The symlink layer handles isolation. This is pnpm's most important architectural decision, and it's worth understanding in detail.

When pnpm installs a project, it creates a node_modules/.pnpm directory - the "virtual store". Inside it, every package appears at a path of the form .pnpm/<name>@<version>/node_modules/<name>/. The files in this directory are hard links to the global store. Each package's own declared dependencies appear as subdirectories alongside it, also as hard links. This means that when package-a depends on lodash, lodash appears at .pnpm/package-a@1.0.0/node_modules/lodash/, not at the root.

The root node_modules directory only contains entries for packages declared directly in the project's package.json. These entries are symlinks to the corresponding location inside .pnpm. So node_modules/express -> .pnpm/express@4.18.2/node_modules/express. When Node.js resolves a require('express'), it follows the symlink into .pnpm, and then when express tries to resolve its own dependencies (like body-parser), Node.js looks in .pnpm/express@4.18.2/node_modules/ - where body-parser is present as a hard link. It does not look in the project root's node_modules, because symlink traversal means Node.js starts looking from the real path, not the symlink path.

This is the mechanism that makes phantom dependencies impossible. Your code only has access to what's declared in your package.json, because only those packages have symlinks in the root node_modules. Any require() for an undeclared package fails with MODULE_NOT_FOUND, regardless of what transitive dependencies brought that package into the tree.

node_modules/
├── express -> .pnpm/express@4.18.2/node_modules/express
├── typescript -> .pnpm/typescript@5.3.3/node_modules/typescript
└── .pnpm/
    ├── express@4.18.2/
    │   └── node_modules/
    │       ├── express/          ← hard links to global store
    │       ├── body-parser/      ← express's declared dep, also hard links
    │       └── accepts/
    └── typescript@5.3.3/
        └── node_modules/
            └── typescript/       ← hard links to global store

One important compatibility note: because Node.js resolves symlinks to their real paths, some tooling that uses __dirname or import.meta.url and then tries to navigate relative to it can get confused by the .pnpm directory structure. pnpm provides --shamefully-hoist as an escape hatch that restores flat hoisting behavior for tools that genuinely cannot handle symlinks. This flag should be treated as temporary - a diagnostic and migration tool, not a permanent setting.

Getting Started: Installation and Project Setup

With the architecture understood, the practical setup is straightforward. pnpm can be installed via npm, but the recommended approach is via Corepack, the Node.js built-in tool for managing package manager versions.

# Option 1: Via Corepack (recommended, ships with Node.js >= 16.9)
corepack enable
corepack prepare pnpm@latest --activate

# Option 2: Via npm (works without Corepack)
npm install -g pnpm

# Verify installation
pnpm --version

Once installed, initializing a new project looks familiar:

pnpm init                          # creates package.json
pnpm add express                   # install a dependency
pnpm add -D typescript @types/node # install dev dependencies
pnpm add -g vercel                 # install a global tool
pnpm install                       # install from package.json + lockfile
pnpm install --frozen-lockfile     # CI-safe: fail if lockfile is out of sync

The packageManager field in package.json, combined with Corepack, is the single most impactful configuration change for team consistency. Add it immediately when setting up a project:

{
  "name": "my-app",
  "version": "1.0.0",
  "packageManager": "pnpm@9.1.0",
  "scripts": {
    "dev": "node src/index.js",
    "build": "tsc",
    "test": "vitest"
  }
}

With corepack enable in your team setup documentation, running npm install or yarn install in this repository will produce a clear error directing developers to use pnpm. The version is pinned to the patch level, preventing subtle behavioral differences from package manager version drift across team members and CI runners.

The .npmrc file controls pnpm-specific behavior at the project level. The most important settings for a new project:

# .npmrc
strict-peer-dependencies=false   # warn on peer dep conflicts, don't fail
auto-install-peers=true          # install peer deps automatically (pnpm v8+)
shamefully-hoist=false           # keep strict isolation (default, explicit here for clarity)

The Lockfile: Reading and Reasoning About pnpm-lock.yaml

The lockfile is the authoritative record of the resolved dependency graph. pnpm's pnpm-lock.yaml is deliberately human-readable, in contrast to npm's JSON lockfile. Understanding its structure helps you reason about dependency updates and review lockfile changes in pull requests.

# pnpm-lock.yaml (simplified)
lockfileVersion: '6.0'

settings:
  autoInstallPeers: true
  excludeLinksFromLockfile: false

dependencies:
  express:
    specifier: ^4.18.0
    version: 4.18.2

devDependencies:
  typescript:
    specifier: ^5.3.0
    version: 5.3.3

packages:

  /express@4.18.2:
    resolution: {integrity: sha512-...}
    engines: {node: '>= 0.10.0'}
    dependencies:
      body-parser: 1.20.2
      accepts: 1.3.8
    dev: false

  /body-parser@1.20.2:
    resolution: {integrity: sha512-...}
    dependencies:
      bytes: 3.1.2
      iconv-lite: 0.4.24
    dev: false

Each entry in the packages section represents one resolved package version. The resolution.integrity field is the SHA-512 hash of the package tarball, which pnpm verifies on install. This means that even if the npm registry were compromised and a package tarball were replaced with a malicious version at the same version number, pnpm would reject it. This is the correct security posture for supply chain integrity.

When reviewing lockfile changes in a pull request, pay attention to two things: new packages appearing (are they expected transitive additions, or something unexpected?), and integrity hash changes on existing packages (which should never happen for a given version, and indicate a serious supply chain problem if they do). The YAML format makes both patterns visible without requiring tooling - a meaningful advantage over npm's JSON lockfile in practice.

Monorepo Architecture with pnpm Workspaces

Workspaces are pnpm's mechanism for managing multiple related packages in a single repository. The workspace protocol and filtering system together form a coherent architecture for monorepos that scales from small teams to large organizations.

Declaring the Workspace Structure

A pnpm monorepo starts with two files at the repository root: package.json and pnpm-workspace.yaml.

# pnpm-workspace.yaml
packages:
  - 'apps/*'       # application packages
  - 'packages/*'   # shared library packages
  - 'tools/*'      # internal tooling
// Root package.json - this is the workspace root, not a publishable package
{
  "name": "my-monorepo",
  "private": true,
  "packageManager": "pnpm@9.1.0",
  "scripts": {
    "build": "pnpm -r build",
    "test": "pnpm -r test",
    "lint": "pnpm -r lint"
  }
}

The private: true field on the root package prevents accidental publishing of the workspace root. The -r flag on scripts runs them recursively across all workspace packages in topological order - packages whose dependencies must be built first are built first.

The workspace: Protocol

Within a monorepo, packages reference each other using the workspace: protocol rather than a version range. This tells pnpm to resolve the dependency to the local package, not the registry.

// apps/web/package.json
{
  "name": "@myorg/web",
  "dependencies": {
    "@myorg/ui": "workspace:*",
    "@myorg/utils": "workspace:^"
  }
}

// packages/ui/package.json
{
  "name": "@myorg/ui",
  "version": "1.2.0",
  "main": "./dist/index.js",
  "types": "./dist/index.d.ts"
}

The workspace:* specifier means "use the current local version, whatever it is". The workspace:^ specifier means "use the current local version, and when publishing, replace this with a ^ semver range based on the actual version". This distinction matters when you publish packages: pnpm's publish command rewrites workspace: specifiers to their resolved semver forms before publishing, so consumers of the published package see standard version ranges.

The local resolution means that changes to @myorg/ui are immediately available to @myorg/web without any publish/install cycle. This is the fundamental ergonomic advantage of monorepos, and pnpm's workspace protocol makes it clean and explicit.

Filtering: Running Commands on Subsets of the Graph

pnpm's --filter flag is one of its most powerful features for large monorepos. It allows you to run commands on a subset of packages, selected by name, directory glob, or dependency relationship.

# Run build in a specific package
pnpm --filter @myorg/ui build

# Run build in all packages under apps/
pnpm --filter './apps/**' build

# Run build in all packages that depend on @myorg/ui (direct and transitive)
pnpm --filter '...*@myorg/ui' build

# Run build in @myorg/ui and all packages it depends on
pnpm --filter '@myorg/ui...' build

# Run tests only in packages changed since the main branch
pnpm --filter '[origin/main]' test

The ...*@myorg/ui pattern - "all packages that depend on this package" - is the key to incremental CI pipelines. When a pull request changes @myorg/ui, you need to rebuild and test not just @myorg/ui itself, but every package in the monorepo that consumes it. pnpm's filter computes this set from the workspace dependency graph and runs the command only on those packages, skipping everything unaffected by the change.

The [origin/main] filter uses git diff to determine which packages have changed files since the specified ref. Combined with ..., it expands to include all downstream dependents. This pattern, combined with a task runner like Turborepo or Nx, forms the basis of a correct and efficient monorepo CI pipeline.

# In CI: build and test everything affected by changes since main
pnpm --filter '...[origin/main]' build
pnpm --filter '...[origin/main]' test

Dependency Catalogs: Enforcing Version Consistency at Scale

Version drift - different packages in a monorepo using different versions of the same dependency - is one of the most common sources of subtle bugs in large monorepos. pnpm's catalog feature, introduced in pnpm v9, provides a first-class solution.

A catalog is a named set of pinned dependency versions defined at the workspace root. Workspace packages reference catalog entries instead of version strings, guaranteeing that every package in the monorepo uses exactly the same version of shared dependencies.

# pnpm-workspace.yaml with catalogs
packages:
  - 'apps/*'
  - 'packages/*'

catalog:
  # Default catalog: referenced as "catalog:"
  react: ^18.3.0
  react-dom: ^18.3.0
  typescript: ^5.4.0
  vitest: ^1.6.0

catalogs:
  # Named catalogs: referenced as "catalog:<name>"
  react18:
    react: ^18.3.0
    react-dom: ^18.3.0
  react19:
    react: ^19.0.0
    react-dom: ^19.0.0
// packages/ui/package.json
{
  "name": "@myorg/ui",
  "dependencies": {
    "react": "catalog:",
    "react-dom": "catalog:"
  },
  "devDependencies": {
    "typescript": "catalog:",
    "vitest": "catalog:"
  }
}

When pnpm resolves the dependency graph, catalog: entries are replaced with the version from the catalog definition. The resolved version appears in pnpm-lock.yaml as a concrete version, and the catalog definition is the single source of truth. Updating React across the entire monorepo is a one-line change in pnpm-workspace.yaml.

Named catalogs support gradual migration scenarios: if you're upgrading from React 18 to React 19, you can move packages one at a time by changing their catalog reference from catalog:react18 to catalog:react19, without touching the version strings in individual package.json files. This makes large dependency migrations reviewable, incremental, and reversible.

CI Configuration: Caching the Global Store

The global store is the key to fast CI installs, but only if it's properly cached between runs. An uncached pnpm install in CI is no faster than npm - every package must be downloaded from the registry. A properly cached install, where the store is restored from a previous run's artifact, is nearly instant: pnpm verifies the store contents and creates hard links without any network activity.

# .github/workflows/ci.yml
name: CI

on: [push, pull_request]

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - uses: pnpm/action-setup@v4
        with:
          version: 9

      - uses: actions/setup-node@v4
        with:
          node-version: '20'
          cache: 'pnpm'    # This caches ~/.local/share/pnpm/store automatically

      - name: Install dependencies
        run: pnpm install --frozen-lockfile

      - name: Build
        run: pnpm build

      - name: Test
        run: pnpm test

The cache: 'pnpm' option in actions/setup-node uses the output of pnpm store path as the cache directory and pnpm-lock.yaml as the cache key. If the lockfile hasn't changed since the last run, the store is restored from cache and pnpm install --frozen-lockfile runs in seconds. If the lockfile has changed, the cache misses and pnpm downloads only the new or changed packages - existing packages in the lockfile that haven't changed are still served from the partially-restored cache.

For monorepo CI pipelines where you're running filtered commands, the full install is still at the root (pnpm needs the complete workspace graph to resolve filters correctly), but the actual build and test steps are filtered to only affected packages:

      - name: Install all workspace dependencies
        run: pnpm install --frozen-lockfile

      - name: Build affected packages
        run: pnpm --filter '...[origin/main]' build

      - name: Test affected packages
        run: pnpm --filter '...[origin/main]' test

One important CI configuration detail: --frozen-lockfile (the pnpm equivalent of npm ci) should be mandatory in all CI environments. It fails the job if package.json and pnpm-lock.yaml are out of sync, which catches the common mistake of updating package.json without running pnpm install to update the lockfile. Lockfile drift in CI is a reliability problem and a security risk - an out-of-sync lockfile means the resolved versions may differ from what was reviewed and merged.

Common Pitfalls and Migration Notes

Understanding pnpm's architecture makes the common pitfalls predictable. Most of them are symptoms of code that was relying on phantom dependencies or a specific node_modules structure.

Phantom dependency failures are the most common issue when migrating an existing npm project to pnpm. The symptom is a MODULE_NOT_FOUND error for a package your code uses but hasn't declared. The fix is always to add the missing package to your package.json. Use pnpm why <package> to confirm that the package is still available transitively (so you know it'll be there) and then add an explicit version constraint to your direct dependencies.

# Diagnose why a package is in node_modules
pnpm why lodash
# Shows the dependency chain: your-project > some-lib > lodash

# After confirming it's a phantom dep, add it explicitly
pnpm add lodash

Peer dependency warnings are more visible with pnpm than with npm, because pnpm reports unmet peer dependencies as warnings (and with strict-peer-dependencies=true, as errors). This is correct behavior - peer dependencies express a real compatibility contract - but it can be alarming on first install. Read each warning: most indicate that a plugin you're using declares a peer dependency on a version range of its host tool, and you need to ensure your installed version falls within that range.

Tools that hardcode node_modules paths occasionally appear in build tooling, particularly in older versions of Webpack loaders, Jest transformers, and native Node.js addons. These tools assume that if a package is installed, it's at node_modules/<name>. With pnpm's symlink structure, the real path is node_modules/.pnpm/<name>@<version>/node_modules/<name>, and the root entry is a symlink. Most tools follow symlinks correctly, but some use filesystem APIs that don't. If you encounter this, shamefully-hoist=true in .npmrc is the diagnostic tool - if enabling it fixes the problem, the issue is symlink traversal. You can then file an issue with the offending tool or use public-hoist-pattern to hoist only the specific package that needs it:

# .npmrc - hoist specific packages that need to be at root node_modules
public-hoist-pattern[]=*webpack*
public-hoist-pattern[]=*babel*

Monorepo circular dependencies are prevented by pnpm's workspace resolution, but the error messages can be cryptic. If two workspace packages depend on each other, pnpm will detect the cycle and report it. The fix is always architectural: extract the shared code into a third package that both can depend on without creating a cycle.

Best Practices

A few principles that separate a well-maintained pnpm project from a fragile one.

Treat phantom dependency errors as bugs to fix, not inconveniences to work around. When pnpm surfaces a MODULE_NOT_FOUND error for a transitively available package, the temptation is to enable shamefully-hoist and move on. Resist this. The error is telling you that your dependency declarations are lying - your code uses a package you haven't committed to depending on. Add it explicitly, pin a version constraint, and add it to your catalog if you're in a monorepo. The short-term fix is quick; the long-term benefit is a dependency graph that accurately reflects what your code actually needs.

Use pnpm update --interactive for controlled dependency updates. Rather than running a blanket update or manually editing package.json, the interactive flag presents a list of available updates grouped by semver significance (patch, minor, major) and lets you select which to apply. This is a habit that keeps your dependency graph current without surprising changes.

# Review available updates interactively
pnpm update --interactive

# Update a specific package to latest, regardless of semver range
pnpm update typescript@latest

# Check for outdated packages without updating
pnpm outdated

In monorepos, establish a clear policy on whether workspace packages are versioned independently or together. Independent versioning (each package has its own version, updated when it changes) is correct for published libraries. Synchronized versioning (all packages share the same version, updated together on every release) is simpler for applications and internal-only packages. Tools like Changesets work well with pnpm workspaces for independent versioning with automated changelog generation.

Make pnpm store prune part of your regular maintenance cadence. The global store grows as you install packages across projects, and pruned package versions accumulate over time. pnpm store prune removes packages no longer referenced by any project on the machine. Run it monthly or after major dependency updates to keep the store clean without losing the caching benefits that make pnpm fast.

Analogies and Mental Models

The post office sorting center analogy helps explain the global store and hard links. Imagine every package version as a piece of mail that arrives at a central sorting center. The center stores one copy of each unique piece. When a project "receives" a package, it doesn't get a physical copy - it gets a reference number pointing to the original in the center. If ten projects need the same package, the center holds one copy and hands out ten reference numbers. Hard links are those reference numbers: they all point to the same physical storage, and the data is only deleted when the last reference number is revoked.

The hotel key card analogy explains symlink isolation. In pnpm's model, each package has a key card that opens only the rooms (packages) it's entitled to access - those it declared as dependencies. The root of node_modules is the lobby: only the guests listed at check-in (your direct dependencies) have lobby key cards. A package that hitched a ride as a transitive dependency sits in a room in the .pnpm corridor, but it has no lobby key card. Your application code can't require() it from the root, because the lobby doesn't know it exists.

The dependency graph as a contract model is the most useful for long-term thinking. package.json is not a wish list - it's a contract stating "this code works when, and only when, these packages at these versions are available". pnpm enforces that contract at the filesystem level. npm treats it as a suggestion. The discipline pnpm imposes is the discipline of writing accurate contracts, which is a skill that transfers to API design, service interfaces, and every other boundary in software engineering.

Key Takeaways

Five actions you can take immediately to improve how your project uses pnpm:

  1. Add "packageManager": "pnpm@<version>" to package.json and run corepack enable in your team setup docs. This pins the exact pnpm version for all team members and CI runners, eliminating a class of reproducibility bugs. Use pnpm --version to get the current version to pin.

  2. Replace pnpm install with pnpm install --frozen-lockfile in all CI pipelines. This turns lockfile drift from a silent inconsistency into a visible build failure. Every CI system should treat the lockfile as authoritative.

  3. Run pnpm why <package> for any MODULE_NOT_FOUND error before reaching for shamefully-hoist. If the package is available transitively, add it to your package.json explicitly. If it's not, understand why before adding it blindly.

  4. If you have a monorepo, move shared dependency versions into a catalog: in pnpm-workspace.yaml. Start with the highest-impact packages: React, TypeScript, your test runner, and any shared UI library. One version string in one file, consistent across all workspace packages.

  5. Add pnpm store prune to your monthly maintenance checklist. The store grows silently over time. Pruning it has no downside for current projects and keeps your disk usage honest.

80/20 Insight

Most of pnpm's value comes from two things. First, the global content-addressable store with hard links - understand this and you understand why pnpm is faster and more disk-efficient than alternatives without any configuration. Second, symlink isolation - understand this and you understand why phantom dependencies are impossible, why NODE_NOT_FOUND errors are features rather than bugs, and what the shamefully-hoist flag is actually doing.

Everything else - workspaces, catalogs, filtering, CI caching - is built on top of these two primitives. The workspace protocol uses the same store and symlink structure as single-package installs, just across multiple package.json manifests. The catalog is a layer on top of workspace resolution. Filtering is a query language over the workspace dependency graph. If you understand the store and the symlink model, you can reason about every other pnpm feature from first principles.

The single configuration change with the highest return is --frozen-lockfile in CI. It costs one flag and provides continuous verification that your dependency declarations and your lockfile are in sync. Everything else is optimization. Start there.

Conclusion

pnpm is not a drop-in replacement for npm that happens to be faster. It is a different model of what package management should be: one where the dependency graph is physically enforced rather than logically suggested, where disk usage is proportional to the number of unique package versions rather than the number of projects, and where correctness failures are surfaced immediately rather than deferred to production.

The migration cost is real but front-loaded. The phantom dependencies you discover when first running pnpm install in an existing project represent latent correctness problems that already existed - pnpm just makes them visible. Fixing them improves the codebase for every subsequent change. Once the migration is complete, the day-to-day experience is largely familiar: the CLI commands are similar, the lockfile is committed to git, and pnpm install runs on checkout.

For new projects, there is no meaningful cost. Start with pnpm, use the packageManager field from day one, commit the lockfile, and use --frozen-lockfile in CI. Add workspaces when the project grows to need them. Add catalogs when version drift between packages becomes a maintenance burden. The architecture scales from a single-package hobby project to a large organizational monorepo without requiring a different tool or a different mental model.

The global store, the symlink isolation, the workspace protocol, and the lockfile - these are not independent features. They are a coherent system designed around the idea that your dependency declarations should be honest, your installs should be reproducible, and your disk should not accumulate copies of the same files indefinitely. Once you've worked in a well-configured pnpm project, the alternative feels like the accidental architecture it always was.

References