把 Monorepo 理解成「前后端放同一个 Git 仓库」会在第三次 CI 和第一次类型漂移时立刻失效。文件夹在一起不产生契约。契约来自同一份类型被两边 import、同一张任务图决定谁先 build、同一份缓存 key 决定这次 CI 能不能跳过。Turborepo 做任务图和缓存,不做架构。Nest 和 Next 运行时分离:一个是 Node 进程里的 IoC,一个是 RSC/Action/浏览器。硬把 Nest 的 @Injectable() class 推进 Next 的 Server Component,或把 Next 的 'use client' 推进 Nest,编译器也许能骗过一会儿,运行时不会。

这篇文章只讲这张图:包怎么切、Turbo 的缓存 key 必须含什么、类型包怎么演进、CI 怎么既快又不撒谎。读完你应该能独立回答三个问题:为什么 apps/web import apps/api 是事故、远程缓存命中了错误环境的制品该怎么造 key、DTO 加可选字段时为什么要当 breaking 看。

1. Monorepo 不是搬家,是一张有向图

合法依赖方向:apps/* → packages/*,packages 之间单向。apps/web 和 apps/api 互不 import。需要共用的,下沉到 packages/types、packages/ui、packages/config、packages/db。app 之间说话走 HTTP / 队列,不走 TypeScript import。

apps/web          Next.js
apps/core         Node/Rust 服务端
packages/types    DTO、错误码、共享联合
packages/ui       React 组件,仅 web 用
packages/config   tsconfig / eslint
packages/db       Prisma schema 与客户端,仅 api 用

packages/ui 依赖 types 可以;types 依赖 ui 不行。db 不要被 web import——浏览器和 RSC 会把 Prisma 引擎拖进错误的运行时。web 要的是 types 里的 UserDto,不是 PrismaClient。

这张图要写进 ESLint import/no-restricted-paths 或 dependency-cruiser,不要写进 wiki。wiki 三个月后必破。CI 对非法 import 红,比 code review 认人稳。

「先放一起以后再拆」会变成永远的上帝包 packages/common。common 里既有日期函数又有 服务端装饰器又有 React hook,任何一边的构建都会被另一边拖下水。按运行时切包:isomorphic 纯函数一个包,React 一个,Nest 一个,Node-only 一个。

2. 任务图:Turbo 缓存的是哈希,不是目录

turbo.json 声明任务的依赖和输出。build 依赖 ^build 表示先建依赖包。outputs 告诉缓存什么算制品。没声明的输出等于没缓存,或更糟——缓存了不完整的目录,下次命中出一个半残的 .next。

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

!.next/cache/** 不要进制品缓存。那是 Next 自己的增量,跨机器复用会脏。dev 关缓存:开发服务器不是可复现制品。

缓存 key 默认含:任务名、包的文件哈希、依赖包的哈希、turbo.json、lockfile 相关输入。不含你没声明的环境变量。NEXT_PUBLIC_API_URL 变了,若没写进 env / globalEnv,Turbo 会把 staging 的前端制品交给 production 任务——命中是真的,URL 是错的。这比缓存 miss 危险,因为 CI 是绿的。

{
  "tasks": {
    "build": {
      "dependsOn": ["^build"],
      "outputs": [".next/**", "dist/**", "!.next/cache/**"],
      "env": ["NEXT_PUBLIC_API_URL", "NODE_ENV"]
    }
  },
  "globalEnv": ["CI"]
}

密钥不要进 key(也不要进客户端)。进 key 的是会影响制品内容的变量。DATABASE_URL 不影响 Next 的 JS bundle,不要写进 web 的 build.env;它影响 Nest 的 prisma generate 目标的话,写进 api 的任务。

远程缓存把这份 key 对上的 tar 传到共享存储。命中条件是 key 完全一致。Node 版本、packageManager 字段、操作系统有时也在影响链上。矩阵构建(linux + darwin)不要共享一份未声明 os 的缓存,除非你证明制品与 os 无关。Nest 的 dist 通常无关;带 native 的 Prisma 引擎有关。

3. workspace 协议和内部依赖

pnpm / yarn / bun 的 workspace:* 让内部包不走 registry 版本号。app 的 package.json:

{
  "name": "web",
  "dependencies": {
    "@acme/types": "workspace:*",
    "@acme/ui": "workspace:*"
  }
}

内部包导出要明确:exports 字段指向 dist 或源码。Next 能编译 workspace 源码(transpilePackages),Nest 的 tsc 通常要的是已构建的 dist。所以 types 包的 build 必须在 app build 之前——这就是 ^build。不要一边 Next 直接吃 src/index.ts,一边 Nest 吃过期的 dist,两边看到的类型会在同一天分叉。

统一:内部包始终产出 dist + .d.ts,两边都依赖构建后的类型。或始终两边都走源码,Nest 用 ts-node / SWC 跑源码。混用是第三种,也是最常见的事故源。选一种写进 packages/types/package.json 的 exports 和 CI。

{
  "name": "@acme/types",
  "exports": {
    ".": {
      "types": "./dist/index.d.ts",
      "import": "./dist/index.js",
      "require": "./dist/index.js"
    }
  }
}

不要 export * 把内部文件全部倒出。公开面越小,后端改一个私有 helper 越不会逼 web 重编。公开面就是契约,列在 index.ts。

4. 类型包:契约怎么演进

packages/types 放:请求/响应 DTO、错误码联合、分页形状、WebSocket 事件名。不放:React 组件、服务端装饰器、Prisma 生成物(生成物带运行时)。

export type ProductDto = {
  id: string;
  title: string;
  priceCents: number;
  currency: "CNY" | "USD";
};

export type ProductListQuery = {
  cursor?: string;
  limit: number;
};

export type ApiError = {
  code: "NOT_FOUND" | "VALIDATION" | "CONFLICT";
  message: string;
  fields?: Record<string, string>;
};

加可选字段看起来兼容,前端老客户端会忽略,可以。改字段类型、改联合成员语义、删字段,是 breaking。Monorepo 里 breaking 不会等 semver:同一 lockstep 提交里两边一起改。这是 Monorepo 相对多仓的好处。代价是:你不能只发后端。CI 必须两边编译。

Prisma 的 Product 类型不要直接当 ProductDto。库表有 passwordHash、有内部 flag。映射函数放 packages/db 或 api 层,输出 ProductDto。web 只认识 DTO。泄漏 Prisma 类型进 web,等于泄漏表结构进前端包,下一次 include 一加,bundle 和契约一起脏。

运行时校验:后端若用 class + class-validator 或用 zod。若用 zod,schema 放 packages/types,z.infer 当类型,Nest pipe 和 Next 表单共用。不要 TS 接口一份、zod 一份、class 一份。三份里一定有一份先漂。

5. 多应用与 Next.js 只共享什么

共享:类型、错误码、少量 isomorphic 纯函数(钱的格式化、slug 规则、时区转换——确认没有 window / fs)。不共享:后端独立模块、PrismaClient、React 组件、环境变量加载方式、日志对象。

鉴权:Next 的 Server Action 和 后端微服务的 Guard 用同一套 JWT 语义,但验证库各调各的,密钥来自各进程的 env。不要从 web import 后端服务的 AuthGuard。cookie 名、header 名放 types 当常量。

export const AUTH_COOKIE = "acme_session" as const;

通信:浏览器 → Next(RSC/BFF)→ 后端服务,或浏览器 → 后端服务。两种都行,不要两种对同一资源混用还字段不同。BFF 在 Next 的 Route Handler 里聚合,类型仍来自 types 包。不要在 BFF 里发明第四份 DTO。

静态导出的 Next 仍然可以和其他后端服务并肩:构建时不碰后端,运行时只靠 URL。types 包在构建期约束前端,不需要后端服务进程活着。CI 仍要两边 build,免得 DTO 只在一边编过。

6. 配置下沉,应用不复制

packages/config 出 tsconfig.base.json、ESLint 平面配置。app 的 tsconfig extends 它,再写 paths 和 jsx。Next 需要 "jsx": "preserve",Nest 不需要。不要强行一份 tsconfig 打天下,继承 + 覆盖。strict、noUncheckedIndexedAccess、exactOptionalPropertyTypes 在 base 打开,例外在 app 里写明理由。types 包最严,因为它是契约。

格式化:Dart 那套与这里无关;这边用仓库已有的 Prettier / ESLint。不要每个包一份 printWidth。冲突的配置等于没有配置。

Next 的 transpilePackages: ['@acme/ui'] 在吃源码时需要。若 ui 已 build 成 JS,可以不写。和第三节选的「源码 vs dist」一致。

7. packages/db 与生成物

Prisma schema 一份。prisma generate 产出客户端,挂在 db 包的 build 上。api 依赖 db。生成物进 git 还是 CI 生成,选一种。进 git 的好处是 web 即使误 import 也能在类型上立刻红;坏处是 diff 吵。CI 生成要保证 turbo 的 inputs 含 schema,schema 变了 db#build 失效。

{
  "tasks": {
    "build": {
      "dependsOn": ["^build"],
      "inputs": ["src/**", "prisma/schema.prisma", "package.json"],
      "outputs": ["dist/**", "node_modules/.prisma/**"]
    }
  }
}

outputs 漏了 generated client,缓存命中后 api 会在运行时找不到引擎。这是 Turbo 排障第一名:任务绿、运行红,因为缓存了不完整 outputs。

迁移:只在 api 的发布流程跑 prisma migrate。web 的 CI 不要连生产库。types 包更不能连库。

8. CI:分层,不要每次全宇宙

预提交:改到的包 turbo lint test --filter=...[HEAD^]。CI 主门禁:turbo build lint test 用远程缓存。Nightly:e2e、全 feature、镜像扫描。

--filter=web... 表示 web 及其依赖。--filter=...web 表示依赖 web 的(通常没有)。改 types 应让 web 和 api 都重建。Turbo 靠图做这件事,前提是 package.json 依赖写对了。漏写依赖、靠 tsconfig paths 偷偷 import,缓存会认为 web 没变。paths 不是依赖声明。 依赖必须写进 package.json。

远程缓存的权限:写缓存需要凭证。fork PR 不要用能写生产缓存的密钥,避免投毒。只读命中可以。缓存内容当制品看:里面有 NEXT_PUBLIC_*,没有服务端密钥——密钥进了 bundle 是 Next 的事故,不关 Turbo。

9. 取舍

选项买到代价
lockstep Monorepo类型同步、一次 PR 改完不能独立发前端「不管后端」
多仓 + 发 npm独立版本DTO 漂、协调成本
Turbo 远程缓存CI 墙钟key 设计、投毒面
内部包走 dist运行时清晰每次改 types 先 build
内部包走源码改了就看到多包编译器行为不一致
上帝 common短期快运行时互相污染

默认:Monorepo + 内部包 dist + Turbo + 非法 import 门禁。团队两人、项目三个月,可以先单仓单包;图开始慢、类型开始漂,再切,不要在第一天发明 12 个包。

10. 排障

  1. 改了 types,web 没红。 web 没用到新字段,或吃了旧 dist。看 types#build 是否在 web 之前;看 exports 是不是指到了 src 和 dist 中过期的那个。
  2. CI 命中缓存但线上 URL 错。 env 没进 key。补 env,作废旧缓存。
  3. 本地绿、CI 红。 本地没 --frozen-lockfile;或本地有没提交的 dist。CI 用 pnpm install --frozen-lockfile 和 turbo 的干净 outputs。
  4. Prisma 引擎找不到。 outputs 漏 generated;或 os 不同共享了 native 缓存。
  5. 循环依赖。 ui → types → 某文件再 import ui。拆类型到 types,组件留 ui。
  6. Next 把 Prisma 打进客户端 bundle。 web import 了 db。ESLint 拦。
  7. transpilePackages 漏了,报 unexpected token。 ui 的 TSX 没被 Next 编译。补,或改吃 dist。
  8. 远程缓存永远 miss。 时间戳写进了源码或 build.ts 生成了不可复现文件。SOURCE_DATE_EPOCH;不要把 Date.now() 写进制品。
  9. filter 没重建下游。 依赖只写在 tsconfig paths。补 package.json。

turbo run build --summarize / --dry-run=json 看任务是否被跳过、key 是什么。猜缓存不如把 dry-run 贴进 PR。

11. 和运行时分离怎么闭合

开发时 turbo run dev --parallel 起 web 和 api。端口、CORS、cookie domain 写进 packages/config 的文档和 .env.example,不要每人一份口口相传的 localhost。Next 的 rewrites 可以把 /api/* 转到 Nest,浏览器只看同一 origin,CORS 少一档。生产可以同一 origin 反代,也可以分开子域。types 不关心。

测试:契约测试放 types 旁,用样例 JSON 满足 DTO。api 的 e2e 打真实 HTTP。web 的组件测试 mock fetch,返回的对象必须 satisfies ProductDto。三层都过,漂移才难。只测 web 对 mock 的渲染,mock 已经漂了你看不见。

发布:api 镜像和 web 静态/Node 镜像分两条流水线,但同一 git sha。事故时「前端 3a1c、后端 9f0e」是 Monorepo 没管发布的结果。看板贴 sha。回滚一起回。lockstep 的含义在运行时仍成立,不是只在编译时。

Cache 那篇的 tag、Action 那篇的 DTO、状态那篇的 Query key,字段名都应来自 types 包。Monorepo 的价值是让这几篇文章里的字符串变成同一个标识符。否则你在四层缓存上点名 product-list,在 Nest 里叫 products,在 Query 里叫 ['items'],失效永远少打一个。

12. 小结

Monorepo 的原理是一份依赖图、一份类型契约、一份可哈希的任务。Turbo 加速图,不规定图。前后端应用共享 DTO 和纯函数,不共享运行时对象。缓存 key 必须含改变制品的环境;outputs 必须含运行真正需要的文件。非法 import 用机器拦。演进 DTO 时把删改当 breaking,在同一次提交里改完两边。图、契约、缓存,三件都写进 CI,才算从「两个项目躺在同一个磁盘上」变成全栈工程。