Reactive Resume 的 Monorepo 工作区边界:以领域为中心的目录结构与 Turbo / Biome / GritQL 三层强制执行机制
本文基于 Reactive Resume 仓库中的 ADR 0001(docs/adr/0001-workspace-boundaries.md),完整解读该项目如何把代码组织方式从“按技术分层”切换为“按领域组织”,并逐层剖析其边界约束的落地方式:根目录 turbo.json 中的 boundaries 标签规则、biome.json 的 noRestrictedImports 禁止项,以及 GritQL 插件 tooling/grit/workspace-boundaries.grit。读完本文,你既能理解一份 Accepted 状态 ADR 是如何把“目录约定”变成“不可违反的机器规则”,也能在自己的 monorepo 中复刻同样的三层防御。
背景问题:技术分层为什么让调试变难
ADR 的 Context 一节记录了重构前的代码布局方式及其痛点,这是整份决策的出发点:
- 服务端运行时代码在 import Web 应用源码树的文件。从当前仓库结构看,服务端入口在 apps/server/src 下(含
http/、mcp/、openapi/、rpc/、static/等目录),若它直接引用apps/web/src下的组件或逻辑,就会把浏览器运行时依赖泄漏进 Node 进程。 - 简历(resume)领域的行为散落在通用工具包里。如今这类行为已被收敛到 packages/resume(
runtime:universal+role:domain标签),与 packages/utils 等纯工具包分家。 - API 实现被拆散在包根部的 routers / services / helpers 中。ADR 的 Decision 明确要求改用
packages/api/src/features/*组织 API 功能,当前 packages/api 的目录与导出确实遵循了这一约定。 - 浏览器 PDF 预览代码与 React PDF 生成代码混在一起。
ADR 给出的总结是:按技术分层会导致“同一个功能的路由、服务、领域行为、测试与运行时适配器散落在互不相关的文件夹里”,调试路径不可预测;同时包边界只是“隐性的”,跨应用源码互相 import 这类回归问题很容易被再次引入。
核心决策:领域优先 + 可执行边界
ADR 的 Decision 部分给出 8 条决策,每一条在当前仓库中都有对应落地:
| ADR 决策条目 | 仓库中的对应证据 |
|---|---|
可部署应用保留在 apps/web 与 apps/server |
pnpm-workspace.yaml 中 workspace 范围即 apps/*、packages/*、tooling |
| 运行时与领域能力放入职责单一的内部包 | packages/resume、packages/schema、packages/pdf、packages/auth 等,各自在 turbo.json 中声明角色标签 |
| 内部包以源码方式被消费(source-consume),走 package export map | packages/api/package.json 的 exports 字段直接指向 ./src/*.ts 源码文件 |
禁止跨 workspace 的私有 src import 与仓库路径 import |
Biome noRestrictedImports + GritQL 插件,见下文“三层强制” |
API 功能用 packages/api/src/features/* 而非根部技术分层目录 |
packages/api/package.json 导出 ./features/resume/export、./features/storage 等子路径 |
| 运行时专属代码使用显式的 browser/server 包子路径 | packages/pdf/package.json 导出 ./browser 与 ./server 两个独立入口 |
用 turbo boundaries 强制执行包方向 |
turbo.json 根配置的 boundaries.tags 规则 |
用 Biome noRestrictedImports 与本地 GritQL 插件强制源码路径规则 |
biome.json 的 linter.rules.style.noRestrictedImports 与 plugins 字段 |
三层强制执行机制
ADR 特意强调:此前的问题“不是缺少意图,而是缺少可执行的强制手段(lack of executable enforcement)”,这也是其 Rejected Alternatives 中否掉“仅靠文档约定”的原因。当前仓库实际构建了三层互补的防线。
第一层:Turbo boundaries —— 包级别的依赖方向约束
根 turbo.json 的 boundaries 配置分两部分:
全局禁令:
"dependencies": {
"deny": ["web", "server"]
}
即任何包都不允许把 web 和 server 这两个应用声明为依赖——从源头切断“包引用应用”的反向依赖。
基于标签(tag)的精细化方向规则。每个包在自己的 turbo.json 里声明标签,例如 apps/server/turbo.json 声明 "tags": ["app:server", "runtime:server", "role:adapter"],packages/resume/turbo.json 声明 "tags": ["runtime:universal", "role:domain"]。根配置中的 deny 矩阵如下:
| 标签 | 禁止依赖的标签 | 语义 |
|---|---|---|
app:server |
app:web、runtime:browser |
服务端应用不能碰 Web 应用与浏览器运行时包 |
runtime:server |
app:web、runtime:browser |
服务端运行时代码不能引用浏览器运行时 |
runtime:browser |
app:server、runtime:server |
浏览器运行时不能反向依赖服务端 |
runtime:universal |
app:web、app:server、runtime:server |
通用包不能依赖任何应用或服务端层 |
role:domain |
app:web、app:server、role:adapter、role:infra |
领域包(如 resume、schema)不得依赖适配层与基础设施层 |
role:ui |
app:web、app:server、runtime:server、role:infra |
UI 包不得依赖应用与服务端/基础设施 |
这套规则正好对应 ADR Context 中提到的“服务端运行时 import Web 源码树”与“浏览器 PDF 预览混在 React PDF 生成代码旁”两类历史回归:runtime:server 与 runtime:browser 互相 deny,app:server deny app:web,使得同类错误一旦写进依赖声明,在 turbo boundaries 检查阶段就会被拦下。
ADR 在 Consequences 中坦率说明:Turbo 标签集刻意保持粗粒度(coarse by design)——优先封住当前风险最高的三条边(包引用应用、服务端引用浏览器运行时、通用/领域包依赖服务端或应用层),更细的规则等包的职责定位稳定后再逐步补充。
第二层:Biome noRestrictedImports —— 导入语句级别的静态检查
linter 配置 中的 noRestrictedImports 以 error 级别拦截两类 import 写法:
"noRestrictedImports": {
"level": "error",
"options": {
"patterns": [
{
"group": ["@reactive-resume/*/src/**"],
"message": "Import through the package export map instead of another workspace's private src path."
},
{
"group": ["apps/**", "packages/**"],
"message": "Do not import another workspace by repository path; use an explicit package export."
}
]
}
}
- 第一条禁止
@reactive-resume/xxx/src/...这种绕过 export map、直接钻进别的包私有源码目录的写法; - 第二条禁止用
apps/...、packages/...这类仓库相对路径引用其他 workspace。
两者共同落实 ADR 的“只允许通过包的公开 export map 消费内部包”原则。
第三层:GritQL 插件 —— 覆盖 import / re-export / 动态 import 的 AST 级规则
biome.json 通过 plugins: ["./tooling/grit/workspace-boundaries.grit"] 注册了本地 GritQL 插件。规则文件 tooling/grit/workspace-boundaries.grit 用 engine biome(1.0) 声明,针对三种语法形态(import ... from $source、export ... from $source、import($source))分别登记诊断,匹配三类正则:
@reactive-resume/<pkg>/src—— 通过命名空间包名直指私有源码;(apps|packages)/<workspace>/src—— 通过仓库路径直指私有源码;- 两次及以上
../后接(apps|packages)/<workspace>/src—— 通过相对路径绕行。
诊断信息也写得很明确,例如静态 import 场景的提示是:"Do not import another workspace's src files directly. Use that package's public export map or a local feature alias." 这一层与 Biome 的 noRestrictedImports 形成纵深:前者按字符串模式匹配 import 分组,后者在 AST 层连 re-export 和动态 import() 一并覆盖(GritQL 文件中的第三分支专门处理动态 import)。
注:ADR 原文写的是
tooling/grit/no-cross-workspace-src-imports.grit,当前仓库中该规则文件实际位于 tooling/grit/workspace-boundaries.grit,可理解为规则文件在落地后重命名过;以当前仓库文件为准。
export map:源码消费如何保持边界清晰
“source-consume internal packages through package export map”是 Decision 中最容易被忽略但最实用的一条。以 packages/api/package.json 为例:
"exports": {
"./context": "./src/context.ts",
"./features/agent/runs": "./src/features/agent/runs.ts",
"./features/flags": "./src/features/flags/index.ts",
"./features/resume/export": "./src/features/resume/export.ts",
"./features/resume/public-pdf": "./src/features/resume/public-pdf.ts",
"./features/resume/social-meta": "./src/features/resume/social-meta.ts",
"./features/storage": "./src/features/storage/index.ts",
"./routers": "./src/routers/index.ts"
}
每个 exports 条目直接指向 src 下的源码文件,而非构建产物——这就是“源码消费”:消费者(如 apps/server/package.json 依赖 "@reactive-resume/api": "workspace:*")拿到的仍是 TS 源码,开发时无需先构建包。关键在于:包的“公开面”由 exports 字段显式列举,未列入 export map 的 src 文件在导入规则下不可达,私有实现与公开 API 的界线因此是机器可判定的,而非靠口头约定。
packages/pdf/package.json 则展示了 ADR 中“运行时专属代码使用显式 browser/server 子路径”的做法:
"exports": {
"./browser": "./src/browser.tsx",
"./document": "./src/document.tsx",
"./semantic": "./src/semantic/index.ts",
"./server": "./src/server.tsx",
...
}
浏览器端的 PDF 预览入口(./browser)与服务端渲染入口(./server)被拆成独立子路径,消费方必须显式选择运行时,而不是从一个聚合入口里“顺带”拿到另一运行时的实现。
结果、已知边界与例外
ADR 的 Consequences 部分对代价与例外做了诚实的记录,值得在落地类似方案时参考:
- “代码先要有 owner,才能拿到文件夹”。新增代码需要先确定归属包,前期多一步决策成本,换来调试路径的可预测性。
- 功能拥有的 API 模块之间仍可共享代码,但共享代码必须有命名的能力(named capability)和刻意的包导出。这解释了为什么 packages/resume、packages/schema 这类领域包存在——它们是“有名字的能力”,而 packages/api 中各
features/*通过 export map 精确暴露。 - 根目录共享的 Vitest 配置是一个“刻意保留的边界例外”。ADR 明确写道:root shared Vitest config 暂时作为有意忽略的边界边(intentionally ignored boundary edge),把它收进某个包的导出是单独的测试基础设施清理工作,不阻塞本 ADR 落地。
- 标签粗粒度是阶段性选择,允许在包角色稳定后追加更细的规则,这为后续演进留了口子而不是把规则写死。
被否决的替代方案
ADR 的 Rejected Alternatives 一节同样是有价值的工程判断,四条否决理由均与仓库现状吻合:
- 保留旧的按技术分层的 API 布局:否决,因为它让功能行为持续分裂在 routers、services、helpers 之间——也就是本 ADR 要解决的原始问题。
- 把所有 Web 功能都搬进 packages:否决,因为“由路由拥有的 UI”与“仅浏览器运行的行为”在 web 应用内部演进更顺手,直到它们真正可复用为止。当前 apps/web/src 仍保有
routes/、features/、components/等大体积 UI 代码,印证了这一取舍。 - 把 PDF.js 查看器代码放进
packages/pdf:否决,因为packages/pdf的职责是 React PDF 生成,而 PDF.js 的查看器/canvas 行为属于浏览器 UI,二者运行时语义不同。 - 仅靠文档约定的边界:否决,理由如前——问题从来不是缺少意图,而是缺少可执行的强制。
实践启示
从 Reactive Resume 的 ADR 0001 可以提炼出一条可复用的方法论:目录结构只表达意图,强制执行才产生约束。三层防线各司其职——Turbo boundaries 管“包与包之间的依赖方向”,Biome noRestrictedImports 管“import 语句的字符串模式”,GritQL 插件管“AST 层面的 import/re-export/动态 import 全形态”,而 export map 定义了每个包合法的公开面。四层规则全部集中在仓库根部的 turbo.json、biome.json 与 tooling/grit/workspace-boundaries.grit 中,任何人 clone 仓库后运行相应检查即可获得同样的边界约束——这正是“executable boundaries”的含义。
atomcodeClaude Code 的开源替代方案。连接任意大模型,编辑代码,运行命令,自动验证 — 全自动执行。用 Rust 构建,极致性能。 | An open-source alternative to Claude Code. Connect any LLM, edit code, run commands, and verify changes — autonomously. Built in Rust for speed. Get StartedRust0623
Hy4-previewHy4 preview 是由腾讯混元团队研发的新一代混合专家(MoE)旗舰模型。模型总参数量 770B,每个 token 激活 49B,主干共包含78层,第一层采用标准 FFN,其余 77 层均为 MoE 结构,每层包含 256 个路由专家与 1 个共享专家,每个 token 激活 top-8 路由专家及共享专家。主干之外原生内置 1 层 MTP(总参数量 10B,激活 0.7B)以支持投机解码。Python00
GLM-5.3GLM-5.3 与 GLM-5.2 使用相同的基座模型——所有提升均来自后训练。与 GLM-5.2 相比,它在复杂编程和长程任务上的表现显著提升。Jinja00
GLM-5.3-FlashGLM-5.3-Flash (320B-A18B),是GLM-5系列的首个原生多模态模型。320B总参数,能力超过GLM-5.2Jinja00
Spark-X2.5-4BSpark-X2.5-4B 旨在让强大的 AI 更实用、更高效、更易获得。在广泛日常任务中表现强劲,涵盖对话、写作、翻译、推理、编码、工具调用以及智能体工作流,并在同等规模的开源模型中取得领先成绩。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00