Steve Kinney

Exercise 4: Monorepo Setup

The workspace has four packages (analytics, users, ui, shared) and a dashboard app, but there is no build orchestration. Running pnpm -r build rebuilds everything every time, even unchanged packages. You’re going to create a turbo.json configuration and wire up Turborepo so builds are cached, dependency-aware, and fast.

Why It Matters

Without a build orchestrator, every CI run and every local build starts from scratch. In a monorepo with 5 packages that takes a few seconds; in a monorepo with 50 packages it takes minutes. Turborepo’s content-aware hashing means a build that has already succeeded with the same inputs is never repeated. The second build is free. Changing one leaf package only rebuilds its dependents, not the entire graph. This is the tool that makes monorepos viable at scale.

At this point the workspace has packages/users — a feature package for user management. It follows the same pattern as packages/analytics: its own package.json, tsconfig.json, and src/index.ts with an explicit public API.

Feel the Problem

Run the build without Turborepo to understand what you’re fixing.

Run the build across all workspace packages:

pnpm -r build

Note the output — every package builds, sequentially. Note how long it takes.

Now run it again immediately, without changing anything:

pnpm -r build

Same time. Same output. Every package rebuilt even though nothing changed. pnpm’s --recursive flag has no caching — it just runs the build script in every package that has one.

Why pnpm alone isn’t enough

pnpm workspaces handle package resolution and dependency linking — they ensure that @pulse/analytics can import @pulse/ui through the workspace protocol (workspace:*). But pnpm has no opinion on task orchestration. It doesn’t know that @pulse/analytics depends on @pulse/ui and therefore @pulse/ui must build first. It doesn’t cache outputs. It doesn’t skip unchanged packages. That’s what a build orchestrator like Turborepo adds on top.

Checkpoint

You’ve run pnpm -r build twice and both runs took roughly the same time. There is no caching, no dependency ordering, no parallelization.

How Turborepo Orchestrates Builds

Turborepo reads the dependency graph from your package.json files and uses the ^ prefix in dependsOn to determine build order. Packages at the same depth build in parallel. The cache stores outputs keyed by a hash of each package’s inputs.

graph TD
    Shared["@pulse/shared"]
    UI["@pulse/ui"]
    Analytics["@pulse/analytics"]
    Users["@pulse/users"]
    Dashboard["@pulse/dashboard"]

    Dashboard --> Analytics
    Dashboard --> Users
    Dashboard --> UI
    Dashboard --> Shared
    Analytics --> UI
    Analytics --> Shared
    Users --> UI
    Users --> Shared
    UI --> Shared
flowchart LR
    subgraph Inputs
        Src["Source Files"]
        Deps["Dependency Hashes"]
        Env["Environment Variables"]
        Lock["Lockfile Entries"]
        Config["turbo.json Task Config"]
    end

    Hash["Content Hash"]
    Cache{{"Cache Lookup"}}
    Hit["Cache Hit → Restore dist/ + replay logs"]
    Miss["Cache Miss → Execute build → Store in cache"]

    Src --> Hash
    Deps --> Hash
    Env --> Hash
    Lock --> Hash
    Config --> Hash
    Hash --> Cache
    Cache -->|"match"| Hit
    Cache -->|"no match"| Miss

Install Turborepo and Create turbo.json

Install Turborepo as a dev dependency at the workspace root:

pnpm add -Dw turbo

Create turbo.json at the root of the repository:

turbo.json
{
  "$schema": "https://turbo.build/schema.json",
  "tasks": {
    "build": {
      "dependsOn": ["^build"],
      "outputs": ["dist/**"]
    },
Note The ^ prefix builds dependencies first. "outputs" tells Turborepo what to cache.    "typecheck": {
      "dependsOn": ["^typecheck"]
    },
    "lint": {},
    "test": {
      "dependsOn": ["^build"]
    },
    "dev": {
      "cache": false,
      "persistent": true
    }
Note Long-running dev servers should never be cached.  }
}

What Each Field Means

  • "dependsOn": ["^build"]: The ^ prefix means “run this task in my dependencies first.” Before @pulse/analytics builds, Turborepo ensures @pulse/ui and @pulse/shared have already built. Without the caret, dependsOn: ["build"] would mean “run build in this package first” (a self-dependency, usually not what you want).

  • `“outputs”: [“dist/”]`**: Tells Turborepo which files to cache. After a successful build, it hashes the inputs (source files, dependencies, env vars) and stores the outputs. On the next run with the same inputs, it replays the cached outputs instead of running the build again.

  • "lint": {}: No dependsOn, no outputs. Linting each package is independent — you don’t need to lint dependencies first, and there’s nothing to cache beyond the pass/fail result.

  • "cache": false: The dev task should never be cached. It’s a long-running process (a dev server), not a one-shot command.

  • "persistent": true: Tells Turborepo that dev is a long-running process that doesn’t exit. Without this, Turborepo would wait for it to finish before running dependent tasks (which would hang forever).

The ^ prefix is the most important concept in Turborepo

It encodes the dependency graph into your task pipeline. When you run turbo build, Turborepo reads every package’s package.json to construct the dependency graph, then uses ^build to determine execution order. @pulse/shared has no dependencies, so it builds first. @pulse/ui depends on @pulse/shared, so it builds second. @pulse/analytics depends on both, so it builds third. The dashboard depends on everything, so it builds last. Turborepo runs packages at the same depth in parallel — @pulse/ui and @pulse/users can build at the same time because neither depends on the other.

Update Root package.json Scripts

Open the root package.json and replace the existing scripts section. The old scripts ran pnpm commands directly—the new ones delegate to Turborepo:

package.json
{
  "scripts": {
    "build": "turbo build",
    "typecheck": "turbo typecheck",
    "lint": "turbo lint",
    "test": "turbo test",
    "dev": "turbo dev",
  },
}Note All scripts now delegate to Turborepo instead of running pnpm -r directly.

Now pnpm build delegates to Turborepo instead of running scripts directly.

You can still use pnpm filters alongside Turborepo. Running pnpm turbo build --filter=@pulse/analytics builds only @pulse/analytics and its dependencies. Running pnpm turbo build --filter=@pulse/analytics... (with the ... suffix) builds @pulse/analytics and everything that depends on it. These filters are how CI pipelines build only what a pull request changed.

Run Turborepo for the First Time

Run the build through Turborepo:

pnpm turbo build

Watch the output. Turborepo prints each task as it runs, showing the dependency ordering:

@pulse/shared:build: cache miss, executing
@pulse/legacy:build: cache miss, executing
@pulse/ui:build: cache miss, executing
@pulse/analytics:build: cache miss, executing
@pulse/users:build: cache miss, executing
@pulse/dashboard:build: cache miss, executing

Every task says “cache miss” because this is the first run — there is no cache yet.

Run it again immediately:

pnpm turbo build

Now the output is different:

@pulse/shared:build: cache hit, replaying logs
@pulse/legacy:build: cache hit, replaying logs
@pulse/ui:build: cache hit, replaying logs
@pulse/analytics:build: cache hit, replaying logs
@pulse/users:build: cache hit, replaying logs
@pulse/dashboard:build: cache hit, replaying logs

 Tasks:    6 successful, 6 total
Cached:    6 cached, 6 total
  Time:    103ms >>> FULL TURBO

Every task shows “cache hit.” The total time drops to under a second. FULL TURBO means every single task was served from cache.

How Turborepo’s cache works

For each task, Turborepo computes a hash from:

  1. The source files in the package
  2. The hashes of all dependency packages
  3. The task definition in turbo.json
  4. Relevant environment variables
  5. The lockfile entries for external dependencies

If the hash matches a previous run, Turborepo restores the cached outputs (the dist/ directory) and replays the logged stdout/stderr instead of executing the command. The cache is local by default (stored in node_modules/.cache/turbo), but can be shared across CI runners with remote caching.

Checkpoint

pnpm turbo build on a clean cache builds everything. The second run shows FULL TURBO and completes in under a second.

First turbo build showing cache misses for all packages Second turbo build showing FULL TURBO with all cache hits

Observe Partial Rebuilds

Now change something and watch Turborepo rebuild only what’s necessary.

Open packages/ui/src/button.tsx and make a small change — add a comment at the top of the file:

// Updated button styling

Save the file and run the build again:

pnpm turbo build

Look at the output carefully:

@pulse/shared:build: cache hit, replaying logs
@pulse/legacy:build: cache hit, replaying logs
@pulse/ui:build: cache miss, executing
@pulse/analytics:build: cache miss, executing
@pulse/users:build: cache miss, executing
@pulse/dashboard:build: cache miss, executing

@pulse/shared and @pulse/legacy are cache hits — they didn’t change and have no dependency on @pulse/ui. But @pulse/ui is a cache miss (you changed it), and everything that depends on @pulse/ui also rebuilds: @pulse/analytics, @pulse/users, and @pulse/dashboard.

Turborepo rebuilds dependents, not just the changed package

This is because the hash includes dependency hashes. When @pulse/ui changes, its hash changes. @pulse/analytics depends on @pulse/ui, so its input hash includes @pulse/ui’s hash — which changed. The cascade continues up the graph. This is correct: a change in @pulse/ui could affect the build output of any package that imports it. The only safe optimization is to skip packages that provably cannot be affected — packages with no dependency path to the changed package.

Revert your change to packages/ui/src/button.tsx (remove the comment) and run pnpm turbo build again. It should be FULL TURBO — the files are back to the cached state.

Checkpoint

After changing packages/ui, only @pulse/shared shows a cache hit. Everything downstream of @pulse/ui rebuilds. After reverting, the cache is restored.

Examine the Dependency Graph

Turborepo can visualize the task graph it constructs.

Generate the graph:

pnpm turbo build --graph=graph.html

This generates an HTML file with a visualization of the dependency graph. Open graph.html in your browser. You should see:

  • @pulse/shared at the bottom (no dependencies)
  • @pulse/ui one level up (depends on @pulse/shared)
  • @pulse/analytics and @pulse/users at the next level (both depend on @pulse/ui and @pulse/shared)
  • @pulse/dashboard at the top (depends on everything)

The graph is the architecture

This visualization is not just a debugging tool — it’s a map of your system’s coupling. If you see a package at the bottom of the graph with edges going to every other package, that’s your most critical shared dependency. If you see two packages with no edges between them, they can build and test in parallel. The graph tells you where parallelism is possible, where bottlenecks are, and what the blast radius of a change will be.

Now try the filter with the graph:

pnpm turbo build --filter=@pulse/analytics... --graph=graph-analytics.html

This generates a graph showing only the subgraph relevant to @pulse/analytics and its dependencies.

Checkpoint

You can visualize the dependency graph and identify which packages are upstream and downstream of any given package.

graph TD
    Dashboard["@pulse/dashboard#build"]
    Users["@pulse/users#build"]
    Analytics["@pulse/analytics#build"]
    UI["@pulse/ui#build"]
    Shared["@pulse/shared#build"]
    Legacy["@pulse/legacy#build"]
    Mocks["@pulse/mocks#build"]
    Codemods["@pulse/codemods#build"]
    Root(("Root"))

    Dashboard --> Users
    Dashboard --> Analytics
    Dashboard --> UI
    Dashboard --> Shared
    Users --> UI
    Users --> Shared
    Analytics --> UI
    Analytics --> Shared
    UI --> Shared
    Legacy --> Mocks
    Mocks --> Shared
    Codemods --> Shared
    Codemods --> Root
    Shared --> Root

Solution

If you need to catch up, the completed state for this exercise is available on the 04-typescript-start branch:

git checkout 04-typescript-start
pnpm install

Stretch Goals

  • Filter builds: Run pnpm turbo build --filter=@pulse/analytics... to build only analytics and its dependencies. Compare the task count to a full build.
  • Dry run: Run pnpm turbo build --dry-run=json to see exactly what Turborepo would execute without running anything. Inspect the JSON output to understand the hash computation.
  • Environment variables: Add an environment variable to the build task’s env key in turbo.json and observe that changing the variable invalidates the cache.

What’s Next

You have cached, dependency-aware builds. But TypeScript still checks each package independently — there’s no incremental type checking across package boundaries. Changing a type in @pulse/shared forces a full recheck of every package. In the next exercise, you’ll add composite: true and project references so TypeScript can do incremental cross-package type checking.

Last modified on .