Siddhant Deval
Siddhant DevalAuthor
Senior Full-Stack Engineer·Sep 15, 2026·16 min read

Monorepo at Scale: Task DAGs, Remote Caching & Architectural Boundary Enforcement

A monorepo only scales when build work is a deterministic function of its inputs. This article builds the mental model behind Directed Acyclic Graph task execution, remote computation caching, and how to enforce strict module boundaries as a CI blocker — not a team convention.

Monorepo at Scale: Task DAGs, Remote Caching & Architectural Boundary Enforcement

Architecture is not about drawing boxes on a whiteboard — it is about enforcing boundary contracts, deterministic caching, and secure data mediation across independent release units. In Part 1, we established the physical foundation: symlinks, package isolation, and why pnpm's content-addressable store eliminates phantom dependencies by design. That foundation is necessary but not sufficient.
The real scaling challenge is not how packages are installed. It is how they are built. A monorepo with 40 packages and no task orchestration rebuilds everything on every CI run. A monorepo with no boundary rules is a ball of mud — any package can import any other, and after eighteen months of team growth, every refactor requires touching twenty files across twelve packages.
This article builds the two layers that make a monorepo scale: a deterministic task execution graph that caches build work across machines, and an architectural boundary enforcement system that makes illegal imports a CI blocker.

1. Task Orchestration & the Dependency Graph

Every task in a monorepo — build, test, lint, type-check — has dependencies. You cannot type-check apps/checkout before compiling the types in packages/ui. You cannot run integration tests before the app is built. These dependencies form a Directed Acyclic Graph (DAG).

1.1 Why Order Matters: The Corrupt Build Problem

bash
# ❌ Running build in parallel with no dependency declaration
turbo run build --parallel

# If packages/ui hasn't finished compiling when apps/checkout starts,
# apps/checkout imports stale or missing type declarations.
# The build "succeeds" but produces broken output artifacts.
Without a declared dependency graph, build tools either run everything sequentially (safe but slow) or run everything in parallel (fast but incorrect). The correct answer is topological execution — run tasks in dependency order, parallelizing where the graph allows.

1.2 Declaring the Task Graph in Turborepo

turbo.json at the repository root declares the task pipeline:
json
{
  "$schema": "https://turbo.build/schema.json",
  "tasks": {
    "build": {
      "dependsOn": ["^build"],
      "inputs": ["src/**", "package.json", "tsconfig.json"],
      "outputs": ["dist/**", ".next/**"]
    },
    "test": {
      "dependsOn": ["build"],
      "inputs": ["src/**", "tests/**", "package.json"],
      "outputs": []
    },
    "lint": {
      "dependsOn": [],
      "inputs": ["src/**", ".eslintrc*", "package.json"],
      "outputs": []
    },
    "type-check": {
      "dependsOn": ["^build"],
      "inputs": ["src/**", "tsconfig.json"],
      "outputs": []
    }
  }
}
The ^ prefix in "^build" means "the build task of all upstream dependencies must complete first." Without ^, "build" means "this package's own build task must complete first."
dependsOn ValueMeaning
"^build"All upstream packages' build tasks must finish before this package's build starts
"build"This same package's build must finish before this task starts (e.g., test depends on build)
[]No dependencies — can run immediately in parallel

1.3 Declaring the Task Graph in Nx

json
// nx.json
{
  "targetDefaults": {
    "build": {
      "dependsOn": ["^build"],
      "inputs": ["production", "^production"],
      "outputs": ["{projectRoot}/dist"]
    },
    "test": {
      "dependsOn": ["build"],
      "inputs": ["default", "^production", "{workspaceRoot}/jest.config.ts"],
      "outputs": ["{projectRoot}/coverage"]
    }
  },
  "namedInputs": {
    "default": ["{projectRoot}/**/*", "sharedGlobals"],
    "production": ["default", "!{projectRoot}/**/*.spec.ts", "!{projectRoot}/jest.config.ts"],
    "sharedGlobals": ["{workspaceRoot}/.eslintrc.json"]
  }
}
Nx's namedInputs is more expressive: production excludes test files from the build cache key, meaning a change to a *.spec.ts file does not invalidate the compiled dist/ cache.
Crucial Requirement
The most common Turborepo misconfiguration is omitting "^build" from the build task dependsOn. Without it, Turborepo runs all package builds in parallel regardless of the dependency graph — producing corrupt artifacts in packages that import from upstreams that haven't finished building yet. This failure is silent and intermittent, making it extremely difficult to debug.

2. Computation Caching Mechanics

Once the task graph is declared, caching is the multiplier. A cached task is one that was already computed with identical inputs — its output artifacts and logs are replayed instantly without re-executing.

2.1 The Cache Key Equation

Cache Key = SHA(
  source files (matched by inputs glob),
  package.json dependencies (resolved versions),
  global environment variables (declared in globalEnv),
  task name + CLI flags
)
If any input changes, the hash changes, the cache misses, and the task re-executes. If nothing changes, the cache hits and the task's output artifacts are restored from disk (or from the remote cache).

2.2 Configuring globalEnv — The Most Missed Setting

json
// turbo.json
{
  "globalEnv": ["NODE_ENV", "CI", "DATABASE_URL"],
  "tasks": {
    "build": {
      "env": ["NEXT_PUBLIC_API_URL", "NEXT_PUBLIC_SENTRY_DSN"]
    }
  }
}
json
// nx.json
{
  "namedInputs": {
    "sharedGlobals": [
      { "env": "NODE_ENV" },
      { "env": "CI" }
    ]
  }
}
Performance / Safety Warning
If your build embeds an environment variable (e.g., NEXT_PUBLIC_API_URL) and you do not declare it in globalEnv or env, Turborepo will not include it in the cache key. A staging build and a production build with different NEXT_PUBLIC_API_URL values will collide on the same cache key — and you will serve staging API endpoints in production. This is a production incident waiting to happen. Declare every build-affecting environment variable explicitly.

2.3 What Gets Cached

ArtifactCached?Location
Task stdout / stderrReplayed on cache hit
outputs glob results (e.g., dist/**)Restored to filesystem
node_modules/Never cached — installed by package manager
.turbo/ local cacheLocal disk, per-machine
Remote cacheShared across machines and CI runners

2.4 Local Cache vs. Remote Cache

bash
# Local cache — default, stored in .turbo/ on your machine
turbo run build

# Remote cache — Turborepo Cloud (Vercel)
turbo run build --remote-only

# Self-hosted remote cache (S3/R2/GCS compatible)
# Set in turbo.json:
json
{
  "remoteCache": {
    "enabled": true,
    "apiUrl": "https://your-turbo-cache.example.com"
  }
}
On a team of 8 engineers, without remote caching, every developer's machine rebuilds every package independently. With remote caching, the first build by any team member populates the shared cache — every subsequent build by anyone hits the cache. A packages/ui build that takes 45 seconds locally takes 800ms on a cache hit.
Pro Tip & Optimization
Self-hosted remote caches using ducktapeeng/turborepo-remote-cache (Cloudflare R2 backend) or Nx Cloud are both viable alternatives to Turborepo Cloud if you have data residency requirements. The protocol is identical; only the endpoint differs.

3. Affected Task Execution in CI

Remote caching solves the "rebuild everything" problem for unchanged packages. Affected analysis solves it at the CI level — by computing which packages changed in a pull request and running tasks only for those packages and their downstream dependents.

3.1 The Affected Calculation

Both Turborepo and Nx use git diff against a base branch to identify changed packages:
bash
# Turborepo — run build only for packages changed since origin/main
turbo run build --filter=...[origin/main]

# Turborepo — run test only for packages that depend on changed packages (transitive)
turbo run test --filter=...[origin/main]...

# Nx — compute affected projects and run build
nx affected --target=build --base=origin/main --head=HEAD

# Nx — run test on all affected
nx affected --target=test --base=origin/main
The ... syntax in Turborepo means "and all dependents":
  • ...[origin/main] = packages changed + all packages that depend on them (rebuild downstream).
  • [origin/main]... = packages changed + all packages they depend on (rebuild upstream).

3.2 A Production CI Configuration

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

on:
  push:
    branches: [main]
  pull_request:
    branches: [main]

jobs:
  ci:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0  # Required for git history — affected analysis needs base branch

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

      - uses: actions/setup-node@v4
        with:
          node-version: 22
          cache: 'pnpm'

      - run: pnpm install --frozen-lockfile

      - name: Build & Test Affected
        run: pnpm turbo run build test lint --filter=...[origin/main]
        env:
          TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}  # Remote cache auth
          TURBO_TEAM: ${{ vars.TURBO_TEAM }}
Crucial Requirement
fetch-depth: 0 in actions/checkout is mandatory. Without full git history, git diff origin/main...HEAD cannot compute the merge base — affected analysis falls back to running every task. This is the most common CI misconfiguration in Turborepo/Nx setups and silently causes the "affected" optimization to do nothing.

3.3 Real-World CI Time Reduction

ScenarioWithout Affected AnalysisWith Affected Analysis
PR touching packages/utils (leaf package)Rebuild all 40 packages (~25 min)Rebuild packages/utils + dependents (~3 min)
PR touching packages/ui (shared library)Rebuild all 40 packages (~25 min)Rebuild packages/ui + 12 consuming apps (~8 min)
PR touching apps/checkout onlyRebuild all 40 packages (~25 min)Rebuild apps/checkout only (~90 sec)
Left-to-right flow trace of the Turborepo computation hash and remote cache pipeline. Five stages: Source files + package deps + env vars → SHA hash computation → Remote Cache query → Cache Hit path (restore dist/ artifacts and replay stdout, shown in green) vs Cache Miss path (execute task DAG, then store output in Remote Cache, shown in amber). The Cache Hit path is visually dominant, highlighted in cyan.
Left-to-right flow trace of the Turborepo computation hash and remote cache pipeline. Five stages: Source files + package deps + env vars → SHA hash computat…

4. Module Boundary Architecture

Task orchestration solves the build problem. Module boundaries solve the architectural decay problem. Without enforced boundaries, every team can import from any package — and after eighteen months, the dependency graph is a tangle that no one can refactor safely.

4.1 The Problem: Dependency Anarchy

typescript
// ❌ apps/checkout importing from apps/dashboard — a direct app-to-app import
import { UserSession } from '../../dashboard/src/auth/session'

// ❌ packages/ui importing from apps/checkout — a UI library depending on an app
import { CheckoutContext } from '../../apps/checkout/src/context'
These imports compile. TypeScript allows them if the paths resolve. But they create invisible coupling: apps/checkout now cannot be deployed without apps/dashboard, and packages/ui is no longer a shared library — it is a dependency of a specific app.

4.2 Enforcing Boundaries with Nx Module Boundaries

Nx enforces boundaries through project tags. Each package declares its tags in project.json, and the lint rule enforces which tag combinations are allowed to import each other.
json
// packages/ui/project.json
{
  "name": "@company/ui",
  "tags": ["scope:shared", "type:ui-library"]
}

// apps/checkout/project.json
{
  "name": "checkout",
  "tags": ["scope:checkout", "type:app"]
}

// packages/utils/project.json
{
  "name": "@company/utils",
  "tags": ["scope:shared", "type:utility"]
}
json
// .eslintrc.json (root)
{
  "rules": {
    "@nx/enforce-module-boundaries": [
      "error",
      {
        "enforceBuildableLibDependency": true,
        "allow": [],
        "depConstraints": [
          {
            "sourceTag": "type:app",
            "onlyDependOn": ["type:ui-library", "type:utility", "type:data-access"]
          },
          {
            "sourceTag": "type:ui-library",
            "onlyDependOn": ["type:utility"]
          },
          {
            "sourceTag": "scope:checkout",
            "notDependOn": ["scope:dashboard"]
          }
        ]
      }
    ]
  }
}
This configuration enforces:
  • Apps can only import from ui-library, utility, or data-access packages — never from other apps.
  • UI libraries can only import from utility packages — never from apps or data-access layers.
  • scope:checkout packages cannot import from scope:dashboard packages — team boundaries enforced in code.

4.3 Enforcing Boundaries with eslint-plugin-boundaries

For Turborepo projects without Nx, eslint-plugin-boundaries provides similar enforcement:
bash
pnpm add -D eslint-plugin-boundaries -w
json
// .eslintrc.json
{
  "plugins": ["boundaries"],
  "settings": {
    "boundaries/elements": [
      { "type": "app",     "pattern": "apps/*" },
      { "type": "library", "pattern": "packages/ui" },
      { "type": "utility", "pattern": "packages/utils" },
      { "type": "types",   "pattern": "packages/types" }
    ]
  },
  "rules": {
    "boundaries/element-types": [
      "error",
      {
        "default": "disallow",
        "rules": [
          { "from": "app",     "allow": ["library", "utility", "types"] },
          { "from": "library", "allow": ["utility", "types"] },
          { "from": "utility", "allow": ["types"] }
        ]
      }
    ]
  }
}

4.4 Making Boundary Violations a CI Blocker

yaml
# .github/workflows/ci.yml — add lint step
- name: Lint (includes boundary checks)
  run: pnpm turbo run lint --filter=...[origin/main]
When a developer writes import { UserSession } from '../../dashboard/src/auth/session' in apps/checkout:
ERROR  apps/checkout/src/OrderSummary.tsx
  Boundary violation: "app" cannot import from "app".
  Rule: sourceTag "type:app" cannot depend on "scope:dashboard".
  @nx/enforce-module-boundaries
The PR fails. The boundary holds.
Comparison matrix of four monorepo orchestrators — Package Manager Workspaces only, Turborepo, Nx, and Bazel — evaluated across five axes: Remote Caching, Boundary Enforcement, Configuration Complexity, Framework Plugins, and CI Affected Analysis. Turborepo scores high on caching and low on complexity. Nx scores high on caching, boundary enforcement, and plugins. Bazel scores highest on caching but highest on complexity. Plain workspaces score low on all optimization axes.
Comparison matrix of four monorepo orchestrators — Package Manager Workspaces only, Turborepo, Nx, and Bazel — evaluated across five axes: Remote Caching, Bo…

5. Tooling Decision Matrix (2026)

CriterionTurborepo 2.xNx 20+Bazel / Buck2
Remote caching✅ First-class (Vercel or self-hosted)✅ Nx Cloud or self-hosted✅ Hermetic caching
Affected analysis--filter=[origin/main]nx affected✅ Build query language
Task DAGdependsOntargetDefaults✅ Explicit BUILD files
Module boundary enforcement⚠️ Via eslint-plugin-boundaries✅ Native @nx/enforce-module-boundaries✅ Visibility rules
Code generation❌ Not native✅ Generators + plugins❌ Not native
Framework plugins⚠️ Community@nx/next, @nx/react, @nx/node❌ Custom rules required
Configuration overhead🟢 Low — turbo.json only🟡 Medium — nx.json + project.json per package🔴 High — BUILD files everywhere
Migration from zero🟢 npx create-turbo@latest🟡 npx create-nx-workspace@latest🔴 Manual — no scaffold
Best forNext.js / React / Node.js stacks with fast iterationLarge teams needing strict boundaries, code gen, and fine-grained project graphsPolyglot enterprise repos needing hermetic builds
Architectural Note
Bazel and Buck2 are not wrong choices — they are hyperscale choices. Google, Meta, and Uber run them successfully. But for a frontend team of 5–30 engineers working in React and Node.js, the BUILD file maintenance overhead and steep learning curve generate more friction than the caching and hermeticity benefits justify. Start with Turborepo; migrate to Nx if boundary enforcement and code generation become needs.

Summary

ConceptRule
Task DAG declarationUse "^build" in dependsOn to enforce upstream-first execution order
Cache key completenessDeclare every build-affecting env var in globalEnv or env — missing vars cause cache collisions
Remote cachingShare a remote cache across all team members and CI runners — the highest-ROI monorepo investment
Affected analysisUse --filter=...[origin/main] in CI with fetch-depth: 0 to run only what changed
Module boundariesEnforce with @nx/enforce-module-boundaries or eslint-plugin-boundaries as a hard CI error
Tooling choiceTurborepo for simplicity; Nx for boundary enforcement, code generation, and fine-grained project graphs

What's Next

In Part 3, we shift focus from build infrastructure to the shared design layer — examining the W3C Design Tokens Community Group specification, the Style Dictionary 4.x pipeline, and the 3-layer design system architecture that keeps tokens, accessible primitives, and domain components independently evolvable.
Research & Synthesis Note

This article was developed with AI-assisted deep search, specification cross-referencing, and technical research synthesis.

#Monorepo#Turborepo#Nx#Remote Caching#CI/CD#Module Boundaries#Frontend Architecture
Siddhant Deval

Written by Siddhant Deval

Senior Full-Stack Engineer building high-scale architectures, browser performance engineering systems, and SaaS platforms.