Monorepo Engineering in Practice: PNPM Workspaces, Turborepo Pipelines, and Scaling to 200 Packages

前端工程

The Week We Merged 15 Repositories Into One

Monday: 15 independent repos, each with its own CI pipeline, dependency versions, lint rules, and release process. A cross-cutting shared type update meant 15 PRs, 15 CI runs, and 3 days of coordination. Friday: One Monorepo. One pnpm install. One turbo run build. Changes spanning 8 packages committed together, tested together, shipped together.

The migration took 6 weeks and was our best infrastructure investment that year. But we made mistakes — CI ran for 45 minutes before we added caching, node_modules consumed 12GB of disk before we configured PNPM correctly, and circular dependencies between packages took a week to untangle. Here's everything we learned.


Monorepo vs Multi-Repo: Decision Framework

Factor Multi-Repo Monorepo
Cross-package changes Multiple PRs, coordinated merges Single PR, atomic change
Dependency versioning Version drift across repos Single source of truth (catalog)
CI speed Fast per repo, slow cross-repo Slow without cache, fast with cache
Local dev setup Clone one repo, npm install Clone one repo, pnpm install
Code sharing npm packages, version mismatch risk Direct imports, always in sync
Access control Per-repo permissions (simple) CODEOWNERS approach (more complex)
Learning curve None Non-trivial for large teams

When NOT to Use a Monorepo

  1. Teams don't touch each other's code: Go backend team and React frontend team never share types or logic — not worth it.
  2. Strict access control requirements: Some packages must be invisible to certain teams — Multi-Repo is simpler.
  3. Very large repos (>10GB): Git operations become slow. Use partial clones or split where necessary.

PNPM Workspace Deep Dive

PNPM is the de-facto standard for Monorepos in 2026. Its strict dependency resolution prevents the most common Monorepo mistake: accidentally importing packages not declared as dependencies.

Workspace Configuration

# pnpm-workspace.yaml
packages:
  - "apps/*"
  - "packages/*"
  - "tooling/*"
// package.json (root)
{
  "private": true,
  "scripts": {
    "dev": "turbo dev",
    "build": "turbo build",
    "lint": "turbo lint",
    "test": "turbo test"
  },
  "devDependencies": {
    "turbo": "^2.0.0",
    "typescript": "^5.5.0"
  },
  "packageManager": "pnpm@9.1.0",
  "engines": { "node": ">=20", "pnpm": ">=9" }
}

Workspace Protocol (workspace:*)

// packages/ui/package.json
{
  "name": "@myorg/ui",
  "dependencies": {
    "@myorg/utils": "workspace:*",    // Always use local latest
    "@myorg/types": "workspace:^",    // Compatible semver range
    "react": "catalog:"              // Pull shared version from catalog
  }
}

The workspace:* protocol ensures packages always reference local versions during development. PNPM replaces it with the actual version when publishing.

PNPM Catalog for Unified Versions

# pnpm-workspace.yaml
catalog:
  react: ^19.0.0
  react-dom: ^19.0.0
  next: ^15.0.0
  typescript: ^5.5.0
  zod: ^3.23.0

Update React in one place, 200 packages follow. Eliminates version drift.

Critical .npmrc Configuration

# .npmrc
shamefully-hoist=false           # Strict mode, prevent accidental access
strict-peer-dependencies=true    # Error on missing peers
node-linker=isolated             # PNPM 9+ default: per-package symlinks

Package Structure: Organizing 200 Packages

monorepo/
├── apps/
│   ├── web/              # Next.js frontend
│   ├── docs/             # Documentation site
│   └── admin/            # Admin dashboard
├── packages/
│   ├── ui/               # Shared React components
│   ├── utils/            # Pure utility functions
│   ├── types/            # Shared TypeScript types
│   ├── config-eslint/    # Shared ESLint config
│   ├── config-tailwind/  # Shared Tailwind preset
│   └── config-ts/        # Shared tsconfig base
├── tooling/
│   ├── scripts/          # Build/deploy scripts
│   └── generators/       # Code generators
├── pnpm-workspace.yaml
├── turbo.json
└── package.json

Package Boundary Rules

// ✅ Correct: apps/web imports packages/ui
import { Button } from "@myorg/ui";

// ❌ Wrong: apps/web imports apps/admin
import { AdminPanel } from "@myorg/admin"; // Apps shouldn't depend on other apps

// ❌ Wrong: packages/ui imports apps/web
import { router } from "@myorg/web"; // Packages shouldn't depend on apps

Turborepo Pipeline Configuration

// turbo.json
{
  "$schema": "https://turbo.build/schema.json",
  "globalDependencies": ["**/.env.*local"],
  "globalEnv": ["NODE_ENV"],
  "tasks": {
    "build": {
      "dependsOn": ["^build"],
      "outputs": [".next/**", "dist/**", "build/**"],
      "inputs": ["src/**", "tsconfig.json", "package.json"]
    },
    "dev": {
      "cache": false,
      "persistent": true,
      "dependsOn": ["^build"]
    },
    "lint": {
      "dependsOn": ["^build"],
      "inputs": ["src/**", "eslint.config.mjs"]
    },
    "test": {
      "dependsOn": ["build"],
      "outputs": ["coverage/**"],
      "inputs": ["src/**", "test/**", "vitest.config.ts"]
    },
    "check-types": {
      "dependsOn": ["^build"],
      "inputs": ["src/**", "tsconfig.json"]
    }
  }
}

Turborepo Key Concepts

Config Purpose Why It Matters
dependsOn: ["^build"] Build dependencies first (^ = upstream) Ensures deps are built before consumers
outputs Cacheable output directories Skips re-execution if outputs exist
inputs Files that affect cache validity Changing non-input files doesn't invalidate cache
cache: false Never cache this task For long-running processes like dev servers
persistent: true Long-running process Dev server doesn't exit

Remote Caching

npx turbo login
npx turbo link

Remote caching shares build results across CI runs and team members. The second developer building the same branch gets cache hits instantly.


Monorepo Dependency Management

Preventing Circular Dependencies

// packages/utils/src/format.ts
import { Button } from "@myorg/ui"; // ❌ Circular!

// packages/ui/src/Button.tsx
import { formatDate } from "@myorg/utils"; // This creates the cycle

Fix: Extract shared concern to a third package:

// packages/dates/src/format.ts — New package, depends on nothing from ui or utils
export function formatDate(date: Date): string { ... }

// packages/utils/src/format.ts — imports from dates
import { formatDate } from "@myorg/dates";

// packages/ui/src/Button.tsx — imports from dates
import { formatDate } from "@myorg/dates";

// Dependency graph: dates ← utils, dates ← ui (no cycle!)

Detect circular dependencies:

npx madge --circular --extensions ts,tsx packages/

Shared Configurations

TypeScript Project References

// tsconfig.json (root)
{
  "compilerOptions": {
    "target": "ES2022",
    "module": "ESNext",
    "moduleResolution": "bundler",
    "strict": true,
    "declaration": true
  },
  "references": [
    { "path": "packages/types" },
    { "path": "packages/utils" },
    { "path": "packages/ui" },
    { "path": "apps/web" }
  ]
}

Shared ESLint Config

// tooling/config-eslint/base.js
import js from "@eslint/js";
import tseslint from "typescript-eslint";
import prettier from "eslint-config-prettier";

export default tseslint.config(
  js.configs.recommended,
  ...tseslint.configs.recommended,
  prettier,
  {
    rules: {
      "@typescript-eslint/no-unused-vars": ["error", { argsIgnorePattern: "^_" }],
      "@typescript-eslint/consistent-type-imports": "error",
    },
  }
);
// apps/web/eslint.config.mjs
import baseConfig from "@myorg/config-eslint/base.js";
export default [...baseConfig, { rules: { "no-console": "warn" } }];

Monorepo CI/CD

GitHub Actions + Turbo

# .github/workflows/ci.yml
name: CI
on:
  push:
    branches: [main]
  pull_request:
    branches: [main]

jobs:
  build:
    runs-on: ubuntu-latest
    timeout-minutes: 30
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0  # Turbo needs this for change detection
      - uses: pnpm/action-setup@v4
        with:
          version: 9
      - uses: actions/setup-node@v4
        with:
          node-version: 20
          cache: "pnpm"
      - run: pnpm install --frozen-lockfile
      - run: pnpm turbo lint check-types test build
        env:
          TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
          TURBO_TEAM: ${{ secrets.TURBO_TEAM }}

Change-Based Testing

# Run tasks only for packages affected by changes
pnpm turbo run build test lint --filter=[origin/main...HEAD]

Docker Build + Turbo Prune

FROM node:20-alpine AS pruner
WORKDIR /app
RUN npm install -g turbo@latest
COPY . .
RUN turbo prune --scope=@myorg/web --docker

FROM node:20-alpine AS builder
WORKDIR /app
COPY --from=pruner /app/out/json/ .
COPY --from=pruner /app/out/pnpm-lock.yaml .
RUN corepack enable && pnpm install --frozen-lockfile --prod=false
COPY --from=pruner /app/out/full/ .
RUN pnpm turbo build --filter=@myorg/web

FROM node:20-alpine AS runner
WORKDIR /app
COPY --from=builder /app/apps/web/.next/standalone ./
COPY --from=builder /app/apps/web/.next/static ./apps/web/.next/static
EXPOSE 3000
CMD ["node", "apps/web/server.js"]

turbo prune creates a minimal subset containing only the target app and its dependencies. No need to copy all 200 packages into Docker.


Migration: 15 Repos → 1 Monorepo

Phase 1: Audit Dependencies (Week 1)

Found 4 different versions of date-fns, 3 of lodash, 2 of zod. Unified all into catalog.

Phase 2: Create Skeleton (Week 2)

Established apps/, packages/, tooling/ directory structure.

Phase 3: Extract Shared Packages (Week 3-4)

Bottom-up extraction: typesutilsui → config packages.

Phase 4: Migrate Apps (Week 5)

Migrate apps one by one, verify builds and deployments work correctly.

Phase 5: Optimize (Week 6)

  • Enable Turborepo caching
  • Configure remote cache
  • Optimize CI pipeline

Performance Data

Metric Before (15 repos) After (Monorepo) Change
pnpm install time (cold) 15 × 45s = 11m15s 1 × 28s = 28s -95.8%
Full CI build 15 × 3.2min = 48min 3.8min (cached: 12s) -92.1%
Cross-package change PRs 6-8 1 -85%
Disk usage (node_modules) 15 × 850MB = 12.75GB 2.1GB -83.5%
New developer onboarding ~2 hours 8 minutes -93.3%
Dependency version conflicts 4-6 per sprint 0 -100%

PNPM's content-addressable store and hard links to a single global store eliminate duplicate node_modules. Turborepo caching makes CI incremental builds near-instant.


Takeaway: Monorepo isn't about cramming everything into one folder — it's about making cross-package changes cheap and safe. Three pillars: PNPM workspaces for strict dependency isolation, Turborepo for build orchestration and caching, and shared configs for cross-package consistency. Start by extracting shared types and utilities. Add caching before adding people. Measure everything. The numbers don't lie: after migration, our team's cross-package change velocity improved by 5x, and the infrastructure cost increase was offset within the first month by eliminated CI duplication.


Online Tools

  • JSON Formatter — Inspect package.json and turbo.json configs during Monorepo setup
  • Hash Calculator — Verify cross-package file integrity for build caching

Try these browser-local tools — no sign-up required →

#Monorepo#PNPM#Turborepo#工程化#前端工程#依赖管理#CI/CD#2026