首页
/ Reactive Resume 的 Monorepo 工作区边界:以领域为中心的目录结构与 Turbo / Biome / GritQL 三层强制执行机制

Reactive Resume 的 Monorepo 工作区边界:以领域为中心的目录结构与 Turbo / Biome / GritQL 三层强制执行机制

2026-09-05 11:13:25作者:郁楠烈Hubert

本文基于 Reactive Resume 仓库中的 ADR 0001(docs/adr/0001-workspace-boundaries.md),完整解读该项目如何把代码组织方式从“按技术分层”切换为“按领域组织”,并逐层剖析其边界约束的落地方式:根目录 turbo.json 中的 boundaries 标签规则、biome.jsonnoRestrictedImports 禁止项,以及 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/resumeruntime: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/webapps/server pnpm-workspace.yaml 中 workspace 范围即 apps/*packages/*tooling
运行时与领域能力放入职责单一的内部包 packages/resumepackages/schemapackages/pdfpackages/auth 等,各自在 turbo.json 中声明角色标签
内部包以源码方式被消费(source-consume),走 package export map packages/api/package.jsonexports 字段直接指向 ./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.jsonlinter.rules.style.noRestrictedImportsplugins 字段

三层强制执行机制

ADR 特意强调:此前的问题“不是缺少意图,而是缺少可执行的强制手段(lack of executable enforcement)”,这也是其 Rejected Alternatives 中否掉“仅靠文档约定”的原因。当前仓库实际构建了三层互补的防线。

第一层:Turbo boundaries —— 包级别的依赖方向约束

turbo.jsonboundaries 配置分两部分:

全局禁令

"dependencies": {
    "deny": ["web", "server"]
}

即任何包都不允许把 webserver 这两个应用声明为依赖——从源头切断“包引用应用”的反向依赖。

基于标签(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:webruntime:browser 服务端应用不能碰 Web 应用与浏览器运行时包
runtime:server app:webruntime:browser 服务端运行时代码不能引用浏览器运行时
runtime:browser app:serverruntime:server 浏览器运行时不能反向依赖服务端
runtime:universal app:webapp:serverruntime:server 通用包不能依赖任何应用或服务端层
role:domain app:webapp:serverrole:adapterrole:infra 领域包(如 resume、schema)不得依赖适配层与基础设施层
role:ui app:webapp:serverruntime:serverrole:infra UI 包不得依赖应用与服务端/基础设施

这套规则正好对应 ADR Context 中提到的“服务端运行时 import Web 源码树”与“浏览器 PDF 预览混在 React PDF 生成代码旁”两类历史回归:runtime:serverruntime: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.gritengine biome(1.0) 声明,针对三种语法形态(import ... from $sourceexport ... from $sourceimport($source))分别登记诊断,匹配三类正则:

  1. @reactive-resume/<pkg>/src —— 通过命名空间包名直指私有源码;
  2. (apps|packages)/<workspace>/src —— 通过仓库路径直指私有源码;
  3. 两次及以上 ../ 后接 (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 部分对代价与例外做了诚实的记录,值得在落地类似方案时参考:

  1. “代码先要有 owner,才能拿到文件夹”。新增代码需要先确定归属包,前期多一步决策成本,换来调试路径的可预测性。
  2. 功能拥有的 API 模块之间仍可共享代码,但共享代码必须有命名的能力(named capability)和刻意的包导出。这解释了为什么 packages/resumepackages/schema 这类领域包存在——它们是“有名字的能力”,而 packages/api 中各 features/* 通过 export map 精确暴露。
  3. 根目录共享的 Vitest 配置是一个“刻意保留的边界例外”。ADR 明确写道:root shared Vitest config 暂时作为有意忽略的边界边(intentionally ignored boundary edge),把它收进某个包的导出是单独的测试基础设施清理工作,不阻塞本 ADR 落地。
  4. 标签粗粒度是阶段性选择,允许在包角色稳定后追加更细的规则,这为后续演进留了口子而不是把规则写死。

被否决的替代方案

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.jsonbiome.jsontooling/grit/workspace-boundaries.grit 中,任何人 clone 仓库后运行相应检查即可获得同样的边界约束——这正是“executable boundaries”的含义。

登录后查看全文
热门项目推荐
相关项目推荐

项目优选

收起
kernelkernel
deepin linux kernel
C
33
18
ops-transformerops-transformer
本项目是CANN提供的transformer类大模型算子库,实现网络在NPU上加速计算。
C++
1.12 K
2.72 K
kernelkernel
openEuler内核是openEuler操作系统的核心,既是系统性能与稳定性的基石,也是连接处理器、设备与服务的桥梁。
C
528
588
ops-nnops-nn
本项目是CANN提供的神经网络类计算算子库,实现网络在NPU上加速计算。
C++
906
1.82 K
pytorchpytorch
作为 Ascend for PyTorch 社区的核心组件,TorchNPU 是昇腾专为 PyTorch 打造的深度学习适配插件,使 PyTorch 框架能够直接调用昇腾 NPU,为开发者提供昇腾 AI 处理器的超强算力。
Python
854
1.34 K
docsdocs
暂无描述
Markdown
891
5.78 K
jiuwenswarmjiuwenswarm
JiuwenSwarm 是一款基于openJiuwen开发的智能AI Agent,它能够将大语言模型的强大能力,通过你日常使用的各类通讯应用,直接延伸至你的指尖。
Python
3.53 K
1.01 K
ops-mathops-math
本项目是CANN提供的数学类基础计算算子库,实现网络在NPU上加速计算。
C++
1.34 K
1.45 K
cann-learning-hubcann-learning-hub
CANN 学习中心仓,支持在线互动运行、边学边练,提供教程、示例与优化方案,一站式助力昇腾开发者快速上手。
Jupyter Notebook
987
504
AscendNPU-IRAscendNPU-IR
AscendNPU-IR是基于MLIR(Multi-Level Intermediate Representation)构建的,面向昇腾亲和算子编译时使用的中间表示,提供昇腾完备表达能力,通过编译优化提升昇腾AI处理器计算效率,支持通过生态框架使能昇腾AI处理器与深度调优
C++
540
384