把 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. 排障
- 改了 types,web 没红。 web 没用到新字段,或吃了旧 dist。看
types#build是否在 web 之前;看exports是不是指到了src和dist中过期的那个。 - CI 命中缓存但线上 URL 错。 env 没进 key。补
env,作废旧缓存。 - 本地绿、CI 红。 本地没
--frozen-lockfile;或本地有没提交的 dist。CI 用pnpm install --frozen-lockfile和turbo的干净outputs。 - Prisma 引擎找不到。 outputs 漏 generated;或 os 不同共享了 native 缓存。
- 循环依赖。
ui→types→ 某文件再 importui。拆类型到 types,组件留 ui。 - Next 把 Prisma 打进客户端 bundle。 web import 了 db。ESLint 拦。
transpilePackages漏了,报 unexpected token。 ui 的 TSX 没被 Next 编译。补,或改吃 dist。- 远程缓存永远 miss。 时间戳写进了源码或
build.ts生成了不可复现文件。SOURCE_DATE_EPOCH;不要把Date.now()写进制品。 - 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,才算从「两个项目躺在同一个磁盘上」变成全栈工程。