17 KiB
项目级代码架构
本规范适用于仓库内所有 workspace 包和代码层。它规定代码按什么概念组织、何时提升共享层级,以及不同文件角色可以拥有什么。具体包的目录名称和运行时边界由包级规范补充。
1. Scope / Trigger
以下场景必须应用本规范:
- 新增或重构一个业务功能;
- 新增
entry.ts、index.ts、model.ts、utils.ts、constants.ts或外部适配器; - 把函数、类型、常量提升到父目录或共享目录;
- 判断一个能力属于基础设施、共享领域概念还是单一功能实现;
- 新增或修改可承载注释的手写代码文件;
- 评审文件职责、依赖方向和目录归属。
代码组织的基本单位是“变化原因”,不是文件长度。会因不同需求独立变化的代码必须拆开,会始终一起变化的代码应留在同一模块。不要只移动函数或增加文件名,而不改变职责边界。
2. Signatures / Roles
推荐的文件角色和函数签名如下;具体名称可以由包级规范调整,但职责不能混合:
// 运行时入口:只安装、注册和组合依赖。
export function installFeature(host: Host): void;
// 功能入口:只校验外层输入、识别输入种类并委派。
export function parseFeatureInput(data: unknown): DomainModel[];
// 分支:只处理一种已识别的输入形态。
export function parseVariantA(items: unknown[]): DomainModel[];
// 领域转换:维护输出契约和业务不变量。
export function toDomainModel(raw: Record<string, unknown>): DomainModel | null;
// 基础原语:只表达语言级控制流,不拥有业务错误语义。
export function fail(message: string): never;
| 文件角色 | 允许内容 | 禁止内容 |
|---|---|---|
运行时入口 entry.ts |
安装、注册、组合依赖 | payload 解析、业务分支、领域映射 |
| 外部适配器 | 浏览器 API、网络协议、数据库或外部系统接入及其关键连接配置 | 下游业务分支的具体字段解析 |
功能入口 feature/index.ts |
外层输入校验、类型识别、调用对应分支 | 内联实现任一分支、重复公共字段映射 |
| 分支模块 | 只理解一种输入形态或一个业务变体 | 判断并处理其它分支、拥有全局流程 |
领域模块 model.ts |
重要类型、业务不变量、公共校验、规范化和输出投影 | 被降级为无语义的工具函数集合 |
utils.ts |
无副作用、无流程决策、只由参数决定结果的辅助函数 | 业务编排、重要领域类型、业务常量、分支解析 |
constants.ts / 所有者文件 |
多处共享的稳定业务常量,或仅由该所有者使用的常量 | 为了“集中管理”而收纳无关常量 |
3. Contracts
3.1 按功能聚合
包内代码先按业务功能聚合,再在功能内部按角色拆文件。一个功能出现独立入口、两个以上业务分支或独立领域契约时,应建立功能子目录:
package/context/
├── entry.ts # 上下文入口,只负责组装和启动
├── transport-adapter.ts # 运行时或外部系统适配
├── model.ts # 上下文内多个功能共享的领域概念
├── utils.ts # 当前目录共享的纯辅助函数
└── feature/
├── index.ts # 功能公开入口,只校验外层输入并分发
├── variant-a.ts # 一种业务分支
├── variant-b.ts # 另一种业务分支
└── model.ts # 功能专属领域契约、校验和公共投影
不要把同一功能的分支文件平铺到上下文根目录,也不要按 components/、services/、parsers/ 这类技术类型把一个完整业务功能拆散到多个远距离目录。
3.2 共享层级
代码归属必须同时回答“它表达什么概念”和“哪些功能共享这个概念”:
| 概念范围 | 归属 | 示例 |
|---|---|---|
| 与具体业务无关的语言级基础原语 | 包内指定的共享基础目录,按主题命名文件 | 接收消息并抛出 Error、返回 never 的失败原语 |
| 一个业务上下文内多个功能共享的领域概念 | 对应上下文根目录的 model.ts |
同一渠道页面共享的账号 ID 类型及守卫 |
| 仅一个功能拥有的领域契约 | 该功能目录的 model.ts |
某种分页结果、消息模型和功能错误码 |
| 无领域语义、无副作用且只由参数决定的局部辅助操作 | 最近公共层级的 utils.ts |
记录类型守卫、字符串或 URL 形状判断 |
共享领域概念应提升到“能够覆盖所有真实或已经确定消费者的最近共同父目录”,不能因为可能复用就直接提升到包级共享目录,更不能跨包相对引用另一个包的内部实现。
判断顺序固定为:
- 判断参数、返回值和行为是否包含领域语义;
- 找出真实或已经确定的消费者范围;
- 选择覆盖这些消费者的最近共同所有者;
- 检查提升后是否产生反向依赖或循环依赖。
3.3 基础原语与业务语义分离
只表达语言级控制流或数据结构、不决定业务错误码、日志、重试和恢复策略的函数,可以成为包内统一基础原语。判断依据是函数契约,不是函数名字,也不是当前调用次数。
// 包内共享基础目录,例如 apps/<package>/src/lib/error.ts
export function fail(message: string): never {
throw new Error(message);
}
// 业务模块拥有错误码及失败条件
export const featureErrorCodes = {
invalidPage: "feature_invalid_page",
} as const;
fail(featureErrorCodes.invalidPage);
不要通过增加业务前缀,把没有新增行为的基础原语包装成领域函数。只有包装层真正增加了领域能力,例如构造结构化错误、附加安全上下文或执行明确的领域映射时,才应作为领域函数存在。
3.4 领域守卫放在领域模型
守卫函数即使是纯函数,只要它定义了“什么值属于某个业务概念”,就属于领域模型,不属于 utils.ts。多个兄弟功能共享时,放到它们最近共同父目录的 model.ts。
// 具体路径由包级规范确定;此例位于 OneTalk main-page 领域根。
export type OneTalkAccountId = string | number;
export function isAccountId(value: unknown): value is OneTalkAccountId {
return (
(typeof value === "string" && value.length > 0) ||
(typeof value === "number" && Number.isFinite(value))
);
}
3.5 函数职责
- 一个函数只处于一个层级:编排、分发、分支解析、领域转换或原子辅助,不能跨层。
- 分发函数只回答“交给谁处理”,不能继续提取某个分支的内部字段。
- 分支解析函数只接受一种已判定的输入形态,并把公共字段转换交给领域函数。
- 领域转换函数集中维护输出契约和业务不变量,不得在多个分支中复制。
- 工具函数必须可仅凭函数名、参数和返回值理解;如果必须了解完整业务流程才能解释它,它就不是工具函数。
- 重要业务概念应拥有具名函数和明确所有者;不要因为实现短就藏进入口或
utils.ts。 - 重要业务常量必须靠近所有者:只有一个模块使用时放在模块顶部;同一功能内多个模块共享时放在功能目录的
constants.ts。
3.6 拆分时机
满足任一条件时应拆文件或建立功能目录:
- 一个文件包含两个会被不同需求独立修改的业务分支;
- 入口或分发函数开始读取某个分支的内部字段;
- 同一领域契约或公共转换被两个分支使用;
- 某个重要业务概念已经可以被准确命名,却仍藏在
utils.ts或入口文件中; - 同一功能的文件开始挤占父目录,使调用链和归属无法从目录结构看出。
不要仅因为函数较长、文件数量少或预期未来可能扩展就提前拆分;拆分必须对应已经存在的职责边界。
3.7 自底向上的文件组织与注释
手写代码文件按自底向上(bottom-up)的方式组织:被依赖的低层声明在前,负责组合它们的主函数在后。主函数后置(main-last),入口函数必须是文件内最后一个函数声明;它之后只允许保留直接启动调用或显式导出,不得再声明辅助函数、类型或常量。
推荐顺序如下:
文件头职责注释
imports
types / constants
原子辅助函数
领域转换或业务分支
主函数职责注释
主函数 / 入口函数
可选的直接启动调用或显式导出
以下注释规则适用于能够合法承载注释的手写源代码和测试代码;生成文件、第三方文件、锁文件及 JSON 等不支持注释的格式不适用:
- 文件第一行必须是职责注释;只有解释器要求的 shebang 可以位于它之前。
- 文件头注释正文必须为 10–30 个字符,注释定界符和首尾空白不计。
- 普通文件的文件头注释描述“这个文件负责什么”,不能只复述文件名。
entry.*、构建入口及其它入口文件的文件头注释描述“这个入口所在目录整体负责什么”,不能只写“这是入口文件”。- 具备运行、编排或分发职责的文件必须能识别唯一主函数;主函数必须紧邻一条职责注释,说明它组合或完成什么。
- 文件头注释和主函数注释是两个独立要求,不能用同一条注释同时充当两者。
- 只有类型、常量或独立原子函数集合而没有主函数的文件,不得为满足规则虚构空的
main。
这里的“主函数”是文件中负责组织其它声明完成该文件主要职责的函数,通常是入口函数、编排函数或主要公开函数;不是按函数长度判断,也不是强制命名为 main。
3.8 格式化器唯一来源与编辑器保存
仓库统一使用根目录 package.json 声明的 Oxfmt。缩进使用 4 个空格,不使用 Tab;具体格式规则由根目录 .oxfmtrc.json 维护,基础编辑器空白行为由 .editorconfig 对齐。
pnpm format是修改格式的唯一标准命令,pnpm format:check是提交前的格式门禁。- VSCode 保存格式化只能使用
oxc.oxc-vscode,并且必须读取仓库的.oxfmtrc.json;工作区设置位于.vscode/settings.json,推荐扩展位于.vscode/extensions.json。 - 未安装 Oxc 扩展时,不得让 VSCode 内置 TypeScript formatter、Prettier、Biome 或其它 formatter 接管保存格式化;应先安装 Oxc 扩展,或关闭
formatOnSave后执行pnpm format。 .editorconfig只提供缩进、换行和文件末尾换行等基础编辑器行为,不能替代 Oxfmt,也不能成为第二套格式规则。- 提交钩子、编辑器保存和 CI 检查必须产生同一份 Oxfmt 结果;若保存后再次运行
pnpm format仍产生差异,视为格式化配置冲突,必须先修复配置。
4. Validation & Error Matrix
| 发现的代码形态 | 处理 |
|---|---|
| 基础函数的参数、返回值或行为包含业务语义 | 留在对应领域,不得提升到共享基础目录 |
| 领域概念只被一个功能使用 | 留在功能目录的 model.ts |
| 领域概念被多个兄弟功能共享 | 提升到最近共同父目录的 model.ts |
| 纯函数决定业务有效性或业务不变量 | 视为领域守卫,不得放入 utils.ts |
| 包装函数只改名并原样调用基础原语 | 删除包装,直接组合基础原语与业务错误码 |
| 提升共享代码产生反向依赖或循环依赖 | 停止提升,重新选择所有者或拆分契约 |
| 文件同时承担入口、分支解析和领域映射 | 按真实变化原因拆分 |
| 主函数之后仍声明辅助函数、类型或常量 | 将依赖声明前移,保持主函数后置 |
| 入口函数不是文件内最后一个函数声明 | 将入口依赖前移,只在入口之后保留启动调用或显式导出 |
| 文件头缺少职责注释,或正文少于 10 / 多于 30 个字符 | 补充或改写为 10–30 个字符的职责描述 |
| 入口文件头只描述“这是入口” | 改为描述入口所在目录的整体职责 |
| 有主函数但主函数前没有独立职责注释 | 在主函数声明正上方补充职责说明 |
| 编辑器保存后与 Oxfmt 结果不同 | 将保存 formatter 切换为 Oxc,或关闭保存格式化后运行 pnpm format |
| 代码使用 2 空格或 Tab,与项目约定不一致 | 按 .oxfmtrc.json 和 .editorconfig 统一为 4 个空格 |
5. Good / Base / Bad Cases
- Good:两个兄弟功能共享同一账号 ID 概念,类型和守卫位于父级
model.ts,两个功能都向父级依赖。 - Base:某个守卫仅服务一个功能,继续留在该功能的
model.ts,不为猜测的未来复用提前提升。 - Good:文件从原子辅助函数逐层组织到带注释的主函数,入口文件头描述目录整体职责。
- Base:纯类型或常量文件保留合格的文件头注释,但不虚构主函数。
- Bad:把业务守卫塞进
utils.ts,把基础行为包装成业务名称,或在主函数之后继续声明它依赖的辅助函数。
6. Tests Required
- 结构调整后运行目标包测试、严格类型检查和构建。
- 领域守卫必须覆盖接受值、边界值和拒绝值,并断言类型收窄依赖的运行时条件。
- 基础原语如果只代理语言内建行为,只需由使用方错误路径覆盖;不要为一行代理复制无价值测试。
- 移动共享概念后检查导入方向,确认不存在跨包内部引用、反向依赖和循环依赖。
- 入口、分发和分支重构应保留原有行为测试,证明只改变职责归属,没有改变输出契约。
- 新增或修改手写代码时,检查文件第一行职责注释的正文长度为 10–30 个字符;入口文件还要检查注释描述的是目录职责。
- 检查主函数前存在独立职责注释,且主函数是最后一个函数声明;允许其后出现直接启动调用或显式导出。
- 检查手工保存后的文件通过
pnpm format:check,且 VSCode 使用oxc.oxc-vscode读取根.oxfmtrc.json。 - 检查代码缩进为 4 个空格,未混入 Tab、Prettier 或其它 formatter 的结果。
7. Wrong vs Correct
Wrong
// 历史同步
export function syncHistory(): void {
loadPage();
}
function loadPage(): void {
// ...
}
// utils.ts 拥有业务有效性规则。
export function isAccountId(value: unknown): boolean {
// ...
}
// 只通过业务化命名包装基础行为,没有新增领域能力。
export function historySyncFailure(code: HistoryErrorCode): never {
throw new Error(code);
}
// 分发器同时实现具体分支。
export function parseFeatureInput(data: unknown): DomainModel[] {
if (isVariantA(data)) {
// 继续读取和转换 Variant A 内部字段……
}
return [];
}
Correct
// 片段:父级 model.ts 拥有兄弟功能共享的领域概念。
export function isAccountId(value: unknown): value is OneTalkAccountId {
// ...
}
// 基础原语与业务错误码直接组合。
fail(historyErrorCodes.invalidMessagePage);
// 分发器只识别并委派。
export function parseFeatureInput(data: unknown): DomainModel[] {
if (isVariantA(data)) return parseVariantA(data);
if (isVariantB(data)) return parseVariantB(data);
return [];
}
// 正确:VSCode 保存与提交钩子使用同一 Oxfmt 配置。
{
"oxc.fmt.configPath": "${workspaceFolder}/.oxfmtrc.json",
"[typescript]": {
"editor.defaultFormatter": "oxc.oxc-vscode",
"editor.formatOnSave": true
}
}
// 错误:保存时使用未声明的其它 formatter。
{
"[typescript]": {
"editor.defaultFormatter": "some.other-formatter",
"editor.formatOnSave": true
}
}
// 组装并启动当前目录的历史同步能力
import { fetchPage } from "./sdk.ts";
function loadPage(): void {
fetchPage();
}
/** 执行当前会话的完整历史同步流程。 */
export function syncHistory(): void {
loadPage();
}
评审检查
- 能否从目录结构直接看出入口、功能和业务分支?
- 每个文件是否只有一个主要变化原因?
- 入口和分发器是否只组合、判断和委派?
- 分支是否各自在独立模块中处理自己的输入形态?
- 公共领域契约和转换是否只有一个所有者?
- 跨功能共享的领域概念是否位于真实消费者的最近共同父目录?
utils.ts是否仍然不含业务流程、重要类型和业务常量?- 基础原语是否保持无业务语义,而不是通过业务化命名制造无行为差异的包装?
- 重要常量是否位于使用它的所有者或功能级
constants.ts? - 编辑器保存是否与 Oxfmt 结果一致,并且缩进是否统一为 4 个空格?
- 文件第一行是否有 10–30 个字符的职责注释,入口文件是否描述目录职责?
- 主函数是否有独立职责注释并位于所有依赖声明之后?
- 入口函数是否是最后一个函数声明,其后是否只有启动调用或显式导出?
- 结构调整后是否通过目标测试、严格类型检查和构建?