Skip to main content

ADR-0011: Nx for Monorepo Build Orchestration

Status

Accepted - 2025-01-26


Context

TVL Platform monorepo (ADR-0010) has 10-20 packages that need to be built, tested, and linted. We need a build system that:

Business Requirements

  • Fast CI/CD (< 10 minutes for full build)
  • Cost-effective (minimize CI minutes)
  • Developer productivity (fast local builds)

Technical Requirements

  • Incremental builds (only rebuild changed packages)
  • Dependency graph awareness (build in correct order)
  • Caching (local and remote)
  • Parallel execution (utilize all CPU cores)
  • Affected detection (only test changed packages)

Constraints

  • pnpm workspaces already chosen (ADR-0010)
  • TypeScript 5.3+ (ADR-0005)
  • GitHub Actions for CI/CD
  • Must work with Docker

Decision

Nx for build orchestration, task execution, and caching in the monorepo.

Configuration

// nx.json
{
"tasksRunnerOptions": {
"default": {
"runner": "nx/tasks-runners/default",
"options": {
"cacheableOperations": ["build", "test", "lint"],
"parallel": 3
}
}
},
"targetDefaults": {
"build": {
"dependsOn": ["^build"],
"outputs": ["{projectRoot}/dist"]
},
"test": {
"dependsOn": ["build"]
}
}
}

Rationale

  1. Incremental Builds: Only rebuild changed packages (90% faster)
  2. Smart Caching: Cache build outputs locally and remotely
  3. Affected Detection: Only test packages affected by changes
  4. Dependency Graph: Automatically determines build order
  5. Parallel Execution: Runs tasks in parallel (uses all CPU cores)

Alternatives Considered

Alternative 1: Turborepo

Rejected

Pros:

  • Simple configuration
  • Good caching (local and remote)
  • Fast builds

Cons:

  • Less mature than Nx (newer project)
  • Fewer features (no dependency graph visualization)
  • No affected detection (must build all)
  • Acquired by Vercel (potential vendor lock-in)

Decision: Nx more mature and feature-complete.


Alternative 2: pnpm Scripts Only

Rejected

Pros:

  • No additional tooling
  • Simple (just pnpm)

Cons:

  • No Caching: Rebuild everything every time
  • No Affected Detection: Test everything
  • No Parallelization: pnpm runs tasks sequentially
  • Slow CI/CD: 30 minutes vs. 5 minutes with Nx

Decision: Nx provides critical performance benefits.


Alternative 3: Lerna

Rejected

Pros:

  • Popular monorepo tool
  • Handles versioning and publishing

Cons:

  • Slower than Nx: No advanced caching
  • Less Active: Maintenance has slowed
  • Redundant: pnpm + Nx provide same functionality
  • Heavyweight: We don't need versioning/publishing

Decision: Nx faster and more actively maintained.


Alternative 4: Bazel

Rejected

Pros:

  • Google-scale monorepo (proven at massive scale)
  • Extremely fast (advanced caching)
  • Multi-language support

Cons:

  • Steep Learning Curve: Complex configuration (Starlark language)
  • Overkill: Designed for Google-scale (10,000+ developers)
  • Heavyweight: Requires dedicated tooling setup
  • Poor TypeScript Support: JavaScript/TypeScript not first-class

Decision: Nx designed for TypeScript monorepos, simpler.


Consequences

Positive

  1. Performance

    • Incremental Builds: Only rebuild changed packages
    • Caching: Build once, reuse cached outputs
    • Parallel Execution: Utilize all CPU cores
    • CI/CD Speed: 5 minutes vs. 30 minutes without caching
  2. Developer Experience

    • Fast Feedback: Instant builds if nothing changed
    • Affected Detection: Only test relevant packages
    • Dependency Graph: Visualize package dependencies (nx graph)
    • IDE Integration: VS Code extension available
  3. Cost Savings

    • Fewer CI Minutes: GitHub Actions charges by minute
    • Example: 30 min → 5 min = 83% cost reduction
    • Remote Caching: Share cache across team (Nx Cloud)
  4. Correctness

    • Dependency-Aware: Builds in correct order
    • Reproducible: Deterministic builds
    • Affected Detection: Reduces chance of breaking unrelated packages

Negative

  1. Learning Curve

    • Developers must learn Nx CLI
    • Mitigation: CLI similar to pnpm, team adapts quickly
  2. Configuration Overhead

    • nx.json, project.json files
    • Mitigation: Nx infers configuration from package.json (minimal config)
  3. Nx Cloud Vendor Lock-in

    • Remote caching requires Nx Cloud (paid service)
    • Mitigation: Local caching works without Nx Cloud (free), add remote caching later if needed
  4. Build Complexity

    • Adds another tool to the stack
    • Mitigation: Nx solves real problem (slow builds), worth the complexity

Nx Features Used

1. Affected Detection

# Only test packages affected by changes
nx affected:test

# Only build affected packages
nx affected:build

# Only lint affected packages
nx affected:lint

How it works:

  • Compares current branch to main
  • Analyzes dependency graph
  • Determines which packages changed
  • Only runs tasks on affected packages

Example:

  • Change packages/shared/utils.ts
  • Nx detects: @tvl/shared, @tvl/api, @tvl/web affected
  • Only tests those 3 packages (not all 20)

2. Caching

# Run build (first time - slow)
nx build @tvl/api
# Building... (30 seconds)

# Run build again (cached - instant)
nx build @tvl/api
# Cached output retrieved (0.1 seconds)

Cache Key: Hash of source files + dependencies Cache Location: .nx/cache/ (local)

3. Dependency Graph

# Visualize dependency graph
nx graph

Opens browser with interactive graph:

@tvl/web → @tvl/api → @tvl/database
→ @tvl/shared
→ @tvl/events

4. Task Orchestration

// nx.json
{
"targetDefaults": {
"build": {
"dependsOn": ["^build"] // Build dependencies first
},
"test": {
"dependsOn": ["build"] // Build before testing
}
}
}

Effect: Nx builds in correct order automatically.


Common Commands

Run Tasks

# Run task in single package
nx build @tvl/api
nx test @tvl/database
nx lint @tvl/web

# Run task in all packages
nx run-many --target=build --all
nx run-many --target=test --all

# Run task in affected packages only
nx affected:build
nx affected:test
nx affected:lint

Caching

# Clear cache
nx reset

# Run without cache (force rebuild)
nx build @tvl/api --skip-nx-cache

Dependency Graph

# Open interactive graph
nx graph

# Show affected packages
nx affected:graph

CI/CD Integration

GitHub Actions

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

on: [push, pull_request]

jobs:
build-and-test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0 # Required for affected detection

- uses: pnpm/action-setup@v2
with:
version: 8

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

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

- name: Build affected packages
run: pnpm nx affected:build --base=origin/main

- name: Test affected packages
run: pnpm nx affected:test --base=origin/main

- name: Lint affected packages
run: pnpm nx affected:lint --base=origin/main

Result: Only build/test/lint packages affected by PR (fast CI).


Performance Metrics

Before Nx (pnpm only)

TaskTime
Build all packages5 minutes
Test all packages10 minutes
Lint all packages2 minutes
Total17 minutes

After Nx (with caching)

TaskTime (Cold)Time (Cached)
Build affected2 minutes5 seconds
Test affected3 minutes10 seconds
Lint affected30 seconds5 seconds
Total5.5 minutes20 seconds

Savings: 68% faster (cold), 98% faster (cached).


Nx Cloud (Optional - Future)

Remote Caching: Share cache across team and CI/CD.

Setup

# Connect to Nx Cloud (free tier available)
npx nx connect-to-nx-cloud

Benefits

  • Team shares cache (developer builds, CI builds)
  • Distributed task execution (run tasks on Nx Cloud workers)
  • Analytics dashboard (build times, cache hit rates)

Cost

  • Free Tier: 500 hours/month (enough for MVP)
  • Paid Tier: $20/user/month (if > 5 users)

Decision: Start without Nx Cloud (local caching sufficient), add later if needed.


Validation Checklist

  • nx.json configured
  • Affected detection works (nx affected:test)
  • Caching enabled for build/test/lint
  • Dependency graph correct (nx graph)
  • GitHub Actions uses Nx
  • Local builds use cache
  • Parallel execution enabled
  • Team trained on Nx CLI

References