# Designing a Type-Safe Monorepo with pnpm + Turborepo: Kill Drift with catalog, Make the Shared Domain Type the Single Source of Truth

> An implementation guide to designing a production TypeScript monorepo with pnpm workspaces + Turborepo. Explained with real code: pinning dependency versions with catalog, turbo.json's task pipeline and caching, making the Zod shared-domain package the single source of truth, CI-enforcing type coverage, and detecting schema drift.

- Published: 2026-06-24
- Author: 友田 陽大
- Tags: モノレポ, TypeScript, アーキテクチャ設計, CI/CD, 型安全
- URL: https://tomodahinata.com/en/blog/pnpm-turborepo-monorepo-architecture-type-coverage-guide
- Category: Type safety & validation
- Pillar guide: https://tomodahinata.com/en/blog/typescript-type-safety-discipline-zod-nevererror-no-any

## Key points

- The value of a monorepo lies in multiple apps sharing the same domain vocabulary and being able to type-check in bulk when the vocabulary changes
- With pnpm catalog, consolidate the versions of shared external dependencies like zod and react in one place, structurally killing silent drift
- In Turborepo's tasks, declare the dependency order with dependsOn, and always write outputs (without them, it isn't cached)
- Consolidate Zod schemas in packages/domain, derive types with z.infer, parse at the boundary, and enforce exhaustiveness with NeverError without using as internally
- Make a type-coverage threshold and schema-drift detection CI gates, mechanically enforcing discipline with a red light rather than human goodwill

---

"The apps became 2" — this is usually where you start considering a monorepo. The learner-facing front end, the operator's admin screen, the common domain definitions, the DB types, the Edge Function. When each scatters into a separate repository, **the same concepts of "user," "course," and "subscription" come to be defined with slightly-divergent types per repository**. You fix one and forget to fix the other. The versions drift apart before you know it. And in production, "the types passed but it breaks" happens.

A monorepo is the tool that solves this. But **a disorderly monorepo is more hellish than polyrepo**. Everything is crammed into one giant dependency graph, the build is slow, circular dependencies tangle, and no one grasps which package, when fixed, breaks what.

This article is an implementation guide for designing "a monorepo that grows type-safely" with **pnpm workspaces + Turborepo + a Zod shared-domain package**. As a subject, I weave in the design judgments that actually worked in the [financial-literacy education subscription learning platform](/case-studies/subscription-learning-platform) I built (a monorepo of a learner app, operator admin, and 14 shared packages), and in a separate project's real-time sports-scoring app. While showing config faithful to the official documentation, I dig into **"why do it that way."**

> **The rule of this article**: the specs of config keys / protocols are based on the **pnpm official documentation / Turborepo official documentation (as of June 2026)**. Config key names change by version (Turborepo has the precedent of renaming the old `pipeline` → the current `tasks`). Always confirm the latest official version before shipping to production. The code is shaped to be usable in real operation, but secrets presuppose environment variables (hardcoding strictly forbidden).

---

## 0. The Mental Model: A Monorepo's Value Is "Shared Vocabulary × Bulk Type-Check"

First, let me fix just one way of thinking that runs through this article.

**A monorepo's value = "multiple apps share the same domain vocabulary, and you can type-check in bulk when that vocabulary changes."**

Add `paused` to `Subscription`'s state, and **all switch statements in the learner app, the admin screen, the DB types, and the Edge Function become compile errors, telling you "fix here too"** — this is the experience you want to get from a monorepo. Conversely, a monorepo that doesn't deliver this experience is just "folders bundled together," dropping most of the value.

There are 4 keys to making this experience hold. This article proceeds in this order.

1. **Kill dependency drift** → pnpm **catalog** (pin shared dependency versions in one place)
2. **Make build/test fast with caching** → **Turborepo** (task pipeline + caching)
3. **Make the shared type the single source of truth** → the **domain package** (consolidate Zod schemas in one place)
4. **Mechanically enforce discipline in CI** → a type-coverage gate, schema-drift detection (don't rely on human will)

Only when these 4 are in place does a monorepo become "an asset you can grow." Miss even one and it crumbles the bigger it gets.

---

## 1. pnpm workspaces: Building the Foundation

### 1.1 `pnpm-workspace.yaml`: Which Are Packages

A pnpm workspace begins by declaring, with globs, **"which directories are packages"** in the `pnpm-workspace.yaml` directly under the repository. The official format is this.

```yaml
# pnpm-workspace.yaml
packages:
  - "apps/*"        # 受講者アプリ・管理画面など「最終成果物」
  - "packages/*"    # domain / ui / config など「共有部品」
  - "!**/test/**"   # テストディレクトリは除外
```

The official documentation shows the format of enumerating glob patterns (`packages/*`, `components/**`, the exclusion `!**/test/**`) in the `packages` field. The directory structure I make standard is this 2-layer one.

```
my-monorepo/
├── pnpm-workspace.yaml
├── turbo.json
├── package.json          # ルート（private: true、共通scriptのみ）
├── apps/
│   ├── learner/          # 受講者アプリ（Next.js）
│   └── admin/            # 運営管理（Next.js）
└── packages/
    ├── domain/           # Zod ドメインスキーマ（単一真実源）
    ├── ui/               # 共有UIコンポーネント
    ├── config-eslint/    # 共有ESLint設定
    └── config-ts/        # 共有tsconfig
```

Decide upfront the distinction that **`apps/` is "final deliverables that never become depended-upon," and `packages/` is "shared parts depended on by multiple apps."** This boundary becomes the premise for the later circular-dependency countermeasure (Chapter 6).

### 1.2 `workspace:*`: Make Local References Explicit

When an app uses a shared package, write the **workspace protocol** in the `package.json` dependency. This is the explicit instruction "use a package within this workspace, not from the npm registry."

```json
// apps/learner/package.json
{
  "name": "@app/learner",
  "private": true,
  "dependencies": {
    "@repo/domain": "workspace:*",
    "@repo/ui": "workspace:*"
  }
}
```

The official docs state that `workspace:` has multiple variants.

| Notation | Meaning |
| --- | --- |
| `workspace:*` | Matches any version within the workspace (this is the default within a monorepo) |
| `workspace:^` | Treated as a caret range |
| `workspace:~` | Treated as a tilde range |
| `workspace:2.0.0` | An exact version specification |

> **Auto-conversion on publishing**: per the official docs, **the moment you archive with `pnpm publish` / `pnpm pack`, `workspace:` is dynamically replaced with the actual version.** Like `workspace:*` → `1.5.0`, `workspace:~` → `~1.5.0`, `workspace:^` → `^1.5.0`. That is, **you develop with `workspace:*` internally, and it becomes the real version only on external publication** — you achieve both internal convenience and post-publish semver compatibility. For an internal-only package (`private: true`), since it isn't published at all, you needn't agonize over `workspace:*`.

Before `as` / `any`, let me put in one discipline here. In my projects, I make **all shared packages `private: true`** (a safety valve against accidentally publishing something not meant for external release). Not bringing publish-flow considerations into something with no plan to publish is YAGNI.

---

## 2. pnpm catalog: "Structurally" Kill Dependency Drift

This is the point that **fails most silently** in monorepo operation.

### 2.1 What's the Problem: Silent Version Drift

With `workspace:*`, the versions of **internal packages** are aligned. But **external dependencies like `zod`, `react`, `typescript`** are written individually by each `package.json`. The learner app is `zod@3.23`, the admin screen is `zod@3.22`, the domain package is `zod@3.24`… and they drift apart without anyone intending it. This is **silent drift**.

What happens? When `zod` changes type behavior between majors, **the type the domain package returns and the type the app's receiving `zod` interprets diverge.** "The domain type meant to be the single source of truth is interpreted as a different thing on the consuming side" — an accident where the monorepo's value itself crumbles. Multiple versions of `zod` get bundled together, bloating it.

### 2.2 catalog: Consolidate Versions in One Place

pnpm's **catalog** is a mechanism that consolidates these shared dependency versions in one place in `pnpm-workspace.yaml`. The official format is this.

```yaml
# pnpm-workspace.yaml
packages:
  - "apps/*"
  - "packages/*"

# デフォルトカタログ（単数形 catalog）
catalog:
  zod: ^3.24.1
  react: ^19.2.0
  react-dom: ^19.2.0
  typescript: ^5.6.3

# 名前付きカタログ（複数形 catalogs）— 移行期に複数バージョンを共存させたいとき
catalogs:
  react18:
    react: ^18.2.0
    react-dom: ^18.2.0
```

Each package's `package.json` references the version with the `catalog:` protocol **without writing it directly**.

```json
// packages/domain/package.json
{
  "name": "@repo/domain",
  "private": true,
  "dependencies": {
    "zod": "catalog:"
  }
}
```

```json
// apps/learner/package.json
{
  "dependencies": {
    "@repo/domain": "workspace:*",
    "react": "catalog:",
    "react-dom": "catalog:",
    "zod": "catalog:"
  }
}
```

When using a named catalog, write `catalog:<name>`.

```json
{
  "dependencies": {
    "react": "catalog:react18"
  }
}
```

Per the official docs, the `catalog:` protocol can be used in `dependencies`, `devDependencies`, `peerDependencies`, `optionalDependencies`, and `pnpm-workspace.yaml`'s `overrides`. Because it's **auto-replaced with the actual version range** on `pnpm publish` / `pnpm pack`, it can be used safely even in published packages.

### 2.3 What catalog Brings

The official docs express catalog's benefit as the **single source of truth**. Organizing it in my words, tied to design principles:

- **DRY (single source of truth)**: the "knowledge" of `zod`'s version exists in just one place within the repository. The same `^3.24.1` doesn't scatter across 3 `package.json` files.
- **One-shot upgrades**: when you want to bump `zod`, **just change one line** in `pnpm-workspace.yaml`. All packages follow. "I forgot to fix that app's package.json" **becomes structurally impossible**.
- **Reduced merge conflicts**: because `package.json` isn't rewritten on dependency updates, multiple people's dependency updates don't clash.

> **My real operation**: in a separate project's real-time sports-scoring app, I **centrally pinned the main dependencies like `zod` and `react` with pnpm catalog**, eradicating silent drift. catalog turns "preventable if you're careful" into "prevented even if you're not careful" — a textbook of **moving discipline from human attentiveness to the tool's structure**. This is the first thing to do in a monorepo.

---

## 3. Turborepo: The Task Pipeline and Caching

Once the workspace is set up and dependencies aligned, next it's time to run **build/test/lint fast and in the correct order**. Here you use Turborepo.

### 3.1 What Turborepo Is

The official docs define Turborepo as "**a high-performance build system for JavaScript / TypeScript codebases. Designed to scale monorepos.**" Against the problem a monorepo holds — "each workspace has its own test, lint, and build, resulting in thousands of tasks" — Turborepo **parallelizes tasks and optimizes execution.** In the official words, "**Turborepo schedules your tasks for maximum speed, parallelizing work across all available cores.**"

And the core is caching. "**Never do the same work twice.**"

### 3.2 `turbo.json`'s `tasks`: Declare the Dependency Order

The config file is `turbo.json`. **The task-config key in the current docs is `tasks`** (note: in earlier versions it was named `pipeline`. If you see an old article or an old turbo.json, read it accordingly. Version differences are easy to step on here).

Here's a practical `turbo.json` faithful to the official example.

```json
{
  "$schema": "https://turbo.build/schema.json",
  "tasks": {
    "build": {
      "dependsOn": ["^build"],
      "outputs": [".next/**", "!.next/cache/**", "dist/**"]
    },
    "test": {
      "dependsOn": ["build"]
    },
    "lint": {
      "dependsOn": ["^build"]
    },
    "type-check": {
      "dependsOn": ["^build"]
    },
    "dev": {
      "cache": false,
      "persistent": true
    }
  }
}
```

> **A note (confirm)**: the `$schema` value `https://turbo.build/schema.json` is a widely-used standard value, but due to the domain migration (turborepo.com → turborepo.dev), the schema URL may change by version. Treat the value of your generated `turbo.json` as authoritative.

Let me pin down the meaning of each key, per the official definitions.

- **`dependsOn`**: tasks that must complete before that task.
- **`^build`**'s **`^` (caret)**: per the official docs, "**`^` is an instruction to first run that task in your direct dependency packages.**" `"dependsOn": ["^build"]` is "**before building myself, first build all packages I depend on.**" Build `@repo/domain`, then build `@app/learner` — this **topological order** is automatically derived from the dependency graph.
- **`"dependsOn": ["build"]` without `^`**: a dependency on another task within the same package. `test` means "after the same package's `build`."
- **The `pkg#task` form** (e.g. `"dependsOn": ["utils#build"]`): name a specific task in a specific package.

### 3.3 `outputs` and `inputs`: Define the Cache Boundary

The correctness of the cache is decided by these 2.

- **`outputs`**: the files/directories a task generates and should cache. The official docs warn clearly — "**if you don't declare a task's file outputs, Turborepo won't cache it.**" For Next.js, excluding `!.next/cache/**` from `.next/**` is the standard.
- **`inputs`**: the files included in the task's hash. When these change, it's a "cache miss = re-run." For example, to re-run spell-check only when Markdown changes:

```json
{
  "tasks": {
    "spell-check": {
      "inputs": ["**/*.md", "**/*.mdx"]
    }
  }
}
```

To keep the default inputs while excluding some, combine with `$TURBO_DEFAULT$`.

```json
{
  "tasks": {
    "build": {
      "inputs": ["$TURBO_DEFAULT$", "!README.md"]
    }
  }
}
```

Changing `README.md` doesn't invalidate the build cache — a cost optimization of "don't throw away the cache on a change that doesn't substantially affect the build result."

### 3.4 Side-Effect Tasks Are `cache: false`

A task with **side effects** like deployment must not be cached (a cache hit causes the accident of "you meant to deploy but nothing happened"). Per the official docs, attach `cache: false`. A resident task like `dev` is `persistent: true`.

```json
{
  "tasks": {
    "deploy": { "cache": false },
    "dev": { "cache": false, "persistent": true }
  }
}
```

---

## 4. How Caching Works, and Making CI Dramatically Faster

### 4.1 Local Cache: Don't Do the Same Work Twice

Per the official docs, Turborepo **creates a fingerprint (hash) from the task inputs on the first run**, and on a re-run with the same input, restores the result from `.turbo/cache`. Terminal output (logs) is always captured too and replayed from the cache.

The hash is composed of 2 layers (the official classification).

- **Global hash**: changes to the root/package `turbo.json`, the root `package.json` and lockfile, the files of `globalDependencies`, the variables of `globalEnv`, and behavior-changing flags like `--cache-dir`.
- **Package hash**: changes to that package's `turbo.json`, the dependencies of `package.json`, and the source-controlled files set by `inputs`.

Here Chapter 2's catalog pays off. Because **changes to the lockfile are included in the global hash**, consolidating dependencies with catalog reduces the noise of "the cache invalidated by an unrelated dependency drift," **raising the cache hit rate.** catalog works not only for type safety but for cache efficiency.

### 4.2 Remote Cache: Share the Cache with CI and the Team

The local cache stops at "don't do the same work twice on your machine." The real forte is the **remote cache**. In the official words, "**Remote Cache stores the results of all your tasks, eliminating the need for CI to do the same work twice.**" Enabling it is 2 commands.

```bash
npx turbo login
npx turbo link
```

With this, task results are automatically uploaded to the remote cache, and another authenticated machine (= CI or another member) gets an **immediate cache hit.** In the official words, "**share a cache across your entire team and CI.**"

The effect is concrete. If a PR touched only `packages/ui`, `@repo/domain`'s build and test **reuse the previous result as-is**, and CI computes only the diff. **Not rebuilding what wasn't touched** — this is the true nature of turning a monorepo's CI from "redo everything" into "just the diff," directly connected to **cost efficiency (CI time = billed time).**

> **A design implication**: if you want fast CI, first correctly declare `outputs`. A task without `outputs` declared isn't cached, dropping the entire benefit of the remote cache. A good number of "CI is slow" cases are actually a forgotten `outputs`.

---

## 5. The Shared Domain Package: Make Zod the Single Source of Truth

This is the **heart** of monorepo design. Even if you align versions with catalog and go fast with Turborepo, if the crucial **types are defined separately per app**, most of the point of making it a monorepo is lost.

### 5.1 Why "Zod Schemas" and Not "Type Definitions"

There are 2 options.

| Approach | Content | Problem |
| --- | --- | --- |
| ① Share only TypeScript `type`/`interface` | Compile-time types only | **Can't validate at runtime.** No guarantee that API responses, DB rows, and form input match the types |
| ② **Share Zod schemas** and derive types from them | Get both a "runtime validator" and a "static type" from one schema | (this is the mainstay) |

**Boundaries (API, DB, Edge Function, user input) are all "unvalidated data from outside."** Casting them through with `as Course` is the act of lying to the type system, and it will surely break in production. With Zod, you **get a runtime validation and a static type simultaneously from one schema**, and can make the discipline of believing the type only after `parse`-ing at the boundary. This is my projects' iron rule, "**boundaries are Zod-validated.**"

### 5.2 `packages/domain`: The Implementation

```ts
// packages/domain/src/subscription.ts
import { z } from "zod";

/**
 * サブスクリプションの状態。
 * ここが全スタックの単一真実源。値を増やすと、消費側の網羅スイッチが
 * すべてコンパイルエラーになって「ここも直せ」と教えてくれる（後述の NeverError）。
 */
export const SubscriptionStatus = z.enum([
  "trialing",
  "active",
  "past_due",
  "canceled",
]);
export type SubscriptionStatus = z.infer<typeof SubscriptionStatus>;

export const Subscription = z.object({
  id: z.string().uuid(),
  userId: z.string().uuid(),
  status: SubscriptionStatus,
  currentPeriodEnd: z.coerce.date(),
});
// スキーマから静的型を「導出」する。型を別途手書きしない（DRY）。
export type Subscription = z.infer<typeof Subscription>;
```

```json
// packages/domain/package.json
{
  "name": "@repo/domain",
  "private": true,
  "type": "module",
  "exports": { ".": "./src/index.ts" },
  "dependencies": { "zod": "catalog:" }
}
```

The point is to **derive the type with `z.infer`.** If you hand-write the schema and the type separately, you fix one and forget to fix the other — the very drift you want to avoid recurs inside the package. **Make one schema the source of truth, and derive the type mechanically from it** (DRY = single source of truth).

### 5.3 Use It from Each App: `parse` at the Boundary, Believe the Type Inside

```ts
// apps/learner/src/api/subscription.ts
import { Subscription, type Subscription as TSubscription } from "@repo/domain";

/**
 * 外部（API/DB）からの未検証データは、必ず境界で parse する。
 * parse を通った後の値だけが TSubscription として信頼できる。
 * ここで as は使わない——型システムへの嘘は本番で破綻する。
 */
export async function fetchSubscription(userId: string): Promise<TSubscription> {
  const res = await fetch(`/api/subscriptions/${userId}`);
  const json: unknown = await res.json();      // 入口は unknown
  return Subscription.parse(json);             // 境界で検証 → ここから型を信じる
}
```

Because `@repo/domain` is referenced with `workspace:*`, **the moment you change the domain's schema, all consumption points in the learner app, admin screen, and Edge Function receive the same type change.** This is the true intent of a monorepo.

### 5.4 Mechanically Enforce Exhaustiveness with `NeverError`

When you add `paused` to `SubscriptionStatus`, you want all switches branching on the state to become compile errors of "you forgot to handle `paused`." What makes this hold is my projects' discipline, "**ban `enum`, ban non-null, ban `as`/`any` + `NeverError`.**"

```ts
// packages/domain/src/never-error.ts
/** 網羅し損ねた case を「コンパイル時に」検出する番人。 */
export class NeverError extends Error {
  constructor(value: never) {
    super(`Unreachable: unexpected value ${JSON.stringify(value)}`);
  }
}
```

```ts
// 消費側：status の分岐
import { NeverError } from "@repo/domain";

function label(status: SubscriptionStatus): string {
  switch (status) {
    case "trialing":  return "トライアル中";
    case "active":    return "有効";
    case "past_due":  return "支払い遅延";
    case "canceled":  return "解約済み";
    // paused を domain に足すと、ここで `never` ではなくなり型エラー → 直し漏れゼロ
    default:          throw new NeverError(status);
  }
}
```

> **Why ban TS's `enum`**: TS's `enum` generates a superfluous object at runtime, `const enum` is incompatible with isolated compilation and bundlers, and numeric enums have holes in type safety. With a **string-literal union `z.infer`-ed from Zod's `z.enum([...])`**, you get both runtime validation (Zod) and static exhaustiveness (`never`), without adding superfluous runtime. Not "use it because it's a language feature" but "does it serve the single source of truth and exhaustiveness" — this is the core of the type-safety discipline.

---

## 6. Mechanically Enforce Discipline in CI: Type Coverage and Schema-Drift Detection

The design so far is **meaningless unless humans uphold it.** And humans will surely forget to uphold it. So **enforce it mechanically in CI.** "Be careful in review" is not discipline.

### 6.1 tsconfig: Tighten Beyond strict

In the shared tsconfig (`packages/config-ts`), put in 2 more clamps on top of `strict`. This is my standard.

```json
// packages/config-ts/base.json
{
  "compilerOptions": {
    "strict": true,
    "noUncheckedIndexedAccess": true,    // arr[i] を T ではなく T | undefined に
    "exactOptionalPropertyStrict": true,  // ※キー名は版確認。下の注を参照
    "noImplicitOverride": true,
    "noFallthroughCasesInSwitch": true
  }
}
```

> **A note (confirm)**: the official key of the option "don't allow explicit `undefined` assignment to an optional property" is **`exactOptionalPropertyTypes`** (the above `exactOptionalPropertyStrict` is placed as an example easy to mistype — be sure to use `exactOptionalPropertyTypes`). Together with `noUncheckedIndexedAccess`, the aim is to **surface in the type the "seemingly-present-but-actually-absent" of array access and optionals.**

- **`noUncheckedIndexedAccess`**: treats `array[0]` as `T | undefined`. Reflects in the type the reality that "index access can miss," cutting the habit of crushing it with `!` (non-null).
- **`exactOptionalPropertyTypes`**: makes `{ name?: string }` **unable to be explicitly assigned `undefined`.** Distinguishes "unspecified" from "specified `undefined`," erasing boundary ambiguity.

### 6.2 Make Type Coverage a CI Gate

Even with `strict`, `any` seeps in (the returns of external libraries, `JSON.parse`, sloppy casts). What quantifies this is **type coverage** — "the proportion of the codebase that is typed." Measure it with a tool like `type-coverage`, and **fail CI if it falls below a threshold.**

```jsonc
// 各パッケージの package.json（domain は最も厳しく）
{
  "scripts": {
    "type-coverage": "type-coverage --detail --strict --at-least 99.5"
  }
}
```

> **My real operation**: in a separate project, I **CI-enforced TypeScript strict + type coverage**, enabled `noUncheckedIndexedAccess` / `exactOptionalPropertyTypes`, and **set the type-coverage threshold per package at 96.7%–100%.** A source-of-truth package like `domain` is 100%, the UI's edges a bit looser — being able to **vary the "type strictness" per package** is the advantage of monorepo + shared config. Because the threshold is a committed number, when someone adds one `any`, **CI turns red and it becomes visible.**

### 6.3 Schema-Drift Detection: Catch "Merged but Not Deployed"

Finally, a discipline that tends to be overlooked. **The code is merged, but the corresponding schema (DB types, API schema) isn't reflected in the deployment environment** — this divergence (drift) is the hotbed of bugs that occur only in production.

I assemble a mechanism to **detect schema drift with GitHub Actions.** The idea is simple — **regenerate "the types/schema that should be generated from the source of truth (domain / migrations)" and diff against the committed ones.** If a diff appears, fail CI as "the generated artifact is stale = drift."

```yaml
# .github/workflows/schema-drift.yml（要点のみ）
name: schema-drift
on: [pull_request]
jobs:
  drift:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: pnpm/action-setup@v4
      - run: pnpm install --frozen-lockfile
      # domain スキーマ / DB 型を「あるべき姿」に再生成
      - run: pnpm --filter @repo/domain generate
      # 生成結果がコミット済みと一致するか。ズレていれば drift → 失敗
      - run: git diff --exit-code -- packages/domain/generated
```

> **My real operation**: in a separate project, I automated type checking, lint, type coverage, and **schema-drift detection** with **11 GitHub Actions.** The aim is to **have machines detect the divergence of state that human eyes can't track**, like "merged but not deployed." What's common to all of Chapter 6 is the philosophy of — **guaranteeing discipline not with human goodwill but with CI's red light.** catalog (dependency drift), type coverage (type drift), and schema-drift (schema drift) are all the manifestation of one principle: "let machines kill drift."

### 6.4 Dependency Boundaries: Prevent Circular Dependencies

The biggest cause of a monorepo rotting is **circular dependencies.** Form a loop like `apps/learner → packages/ui → apps/learner`, and the build order can't be decided, and Turborepo's topological cache breaks too.

The discipline is simple — **flow dependencies in one direction.** Chapter 1's separation of `apps/` and `packages/` works here.

- `apps/*` may depend on `packages/*`. **The reverse is forbidden** (a shared part must not know an app).
- Dependencies among `packages/*` also **keep a hierarchy**: `ui → domain` is OK, `domain → ui` is not. **`domain` is the lowest layer that depends on nothing** (other than Zod).

Build it into CI with ESLint's `import/no-cycle` rule, or a tool that declaratively constrains dependency direction, and **fail the PR the moment a cycle is born.** "Fix it later" never gets fixed, so **stopping it at the entrance** is the only solution.

---

## 7. Monorepo vs. Polyrepo: When Does a Monorepo Win

Read this far and it looks like "monorepo is the best," but **it's not always the right answer.** Choose by decision axes.

| Decision axis | Monorepo is advantageous | Polyrepo is advantageous |
| --- | --- | --- |
| **Shared domain** | Multiple apps **densely share the same types/vocabulary** (like this time's learner + admin) | Each service's domain is **mostly independent** |
| **Cross-cutting change** | You want to **type-check one change across all apps in bulk** | Changes are often confined to one service |
| **Team structure** | Small to mid-scale, can **see the whole** | Many independent teams move on **separate release cycles** |
| **Release** | **Synchronized releases** (want to ship together) are common | You want **independent deploys** per service |
| **Tech stack** | Centered on TS, can **unify the toolchain** | Languages/runtimes are **mixed** (Go, Rust, Node together) |
| **CI** | Can **speed up with Turborepo's diff cache** | You want to **avoid monorepo CI's complexity** |

**Why this time's subscription learning platform suited a monorepo** is clear. The learner app and admin screen **share the same `Course`, `Subscription`, `User`**, and I wanted to type-check both in bulk when the domain changed. The stack was unified in TS, and being small I could see the whole. **When the 3 of "shared domain × cross-cutting type-check × unified stack" align, a monorepo is strong.** Conversely, if these don't align, there's no reason to discard polyrepo's simplicity.

### What to Carve Out into a Shared Package

Even if you make a monorepo, **slicing everything into packages is a mistake** (over-splitting needlessly complicates the build graph = YAGNI). The criterion is this.

| Carve out | Don't carve out |
| --- | --- |
| Domain types that **2 or more apps actually share** (`domain`) | Logic only one app uses (place it in that app) |
| Cross-cutting config (`config-eslint`, `config-ts`) | A speculative part of "maybe share it someday" |
| A shared-UI part that appeared **3 or more times** | A part that appeared only twice (it may be a coincidence) |

Per the DRY principle — "**twice is coincidence, three times is a pattern.**" Panic-package at 1–2 duplications and you're bound to a wrong abstraction. **Carve it out after actual sharing is observed** (ETC = to keep change easy, delay the abstraction). `domain` alone is the exception, carved out from the start. Because that's **the very reason a monorepo exists.**

---

## 8. Summary: A Type-Safe Monorepo Cheat Sheet

A quick reference for when you're lost.

- **Foundation**: separate `apps/*` and `packages/*` in `pnpm-workspace.yaml`. Internal references are `workspace:*`, shared parts are `private: true`.
- **Eradicate drift**: consolidate shared external dependencies (`zod`, `react`, `typescript`) in **catalog**, and each `package.json` references with `catalog:`. A version update is **one line**.
- **Speed**: in `turbo.json`'s **`tasks`** (old `pipeline`), declare the topological order with `dependsOn: ["^build"]`. **Always** write `outputs` (without them, no caching). Side-effect tasks are `cache: false`.
- **CI speedup**: remote cache with `turbo login` + `turbo link`. **Don't rebuild what wasn't touched** = CI billed time drops.
- **Single source of truth**: consolidate **Zod schemas** in `packages/domain`, derive types with `z.infer`. Always `parse` at the boundary, don't use `as` inside. Mechanically enforce exhaustiveness with `NeverError`.
- **Mechanical enforcement of discipline**: `strict` + `noUncheckedIndexedAccess` + `exactOptionalPropertyTypes`. Make the **type-coverage threshold** a CI gate. Catch "merged but not deployed" with **schema-drift detection.** Stop **circular dependencies** at the entrance.
- **When to quit**: if the shared domain is thin and teams want to release independently, don't force a monorepo.

A monorepo is not "the technique of bundling folders" but **the design of "multiple apps sharing the same domain vocabulary, type-checking changes in bulk, and mechanically enforcing discipline in CI."** Kill drift with catalog, go fast with Turborepo, make types the single source of truth with Zod, and protect it in CI — only when these 4 pillars align does a monorepo withstand scale and grow.

I have actually operated a monorepo of a **learner app, operator admin, and 14 shared packages**, with a **type-safety discipline that banned `as`/`any`/`enum`/non-null** and **multiple CI gates including type coverage and schema-drift detection.** Using generative AI (Claude Code) to accelerate implementation, while **guaranteeing quality with the mechanical verification gates of types and CI** — this is my way of safely spinning a multi-surface monorepo even solo.

**"The apps have grown and the types are starting to drift," "I want to make it a monorepo but not make it hell" — from that design, through building catalog, Turborepo, the shared-domain package, and the CI gates, I can accompany you all the way through.** Feel free to consult me even from the requirements-organizing stage.

---

### References (Official Documentation)

- [pnpm Workspaces](https://pnpm.io/workspaces) — `pnpm-workspace.yaml`, the `workspace:*` protocol and conversion on publishing
- [pnpm Catalogs](https://pnpm.io/catalogs) — making dependency versions the single source of truth with `catalog:` / `catalogs:`
- [pnpm pnpm-workspace.yaml](https://pnpm.io/pnpm-workspace_yaml) — the `packages` glob and catalog notation
- [Turborepo Docs (Overview)](https://turborepo.dev/docs) — the whole picture of the high-performance build system, parallelization, caching
- [Turborepo Caching](https://turborepo.dev/docs/core-concepts/caching) — local/remote cache, hashing, `turbo login` / `turbo link`
- [Turborepo Configuring Tasks](https://turborepo.dev/docs/crafting-your-repository/configuring-tasks) — `tasks`, `dependsOn`, `^build`, `outputs`, `inputs`, `cache`, `persistent`
