Files
trade-message-center/.trellis/spec/project/architecture.md
T

17 KiB
Raw Blame History

项目级代码架构

本规范适用于仓库内所有 workspace 包和代码层。它规定代码按什么概念组织、何时提升共享层级,以及不同文件角色可以拥有什么。具体包的目录名称和运行时边界由包级规范补充。

1. Scope / Trigger

以下场景必须应用本规范:

  • 新增或重构一个业务功能;
  • 新增 entry.tsindex.tsmodel.tsutils.tsconstants.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 形状判断

共享领域概念应提升到“能够覆盖所有真实或已经确定消费者的最近共同父目录”,不能因为可能复用就直接提升到包级共享目录,更不能跨包相对引用另一个包的内部实现。

判断顺序固定为:

  1. 判断参数、返回值和行为是否包含领域语义;
  2. 找出真实或已经确定的消费者范围;
  3. 选择覆盖这些消费者的最近共同所有者;
  4. 检查提升后是否产生反向依赖或循环依赖。

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 等不支持注释的格式不适用:

  1. 文件第一行必须是职责注释;只有解释器要求的 shebang 可以位于它之前。
  2. 文件头注释正文必须为 10–30 个字符,注释定界符和首尾空白不计。
  3. 普通文件的文件头注释描述“这个文件负责什么”,不能只复述文件名。
  4. entry.*、构建入口及其它入口文件的文件头注释描述“这个入口所在目录整体负责什么”,不能只写“这是入口文件”。
  5. 具备运行、编排或分发职责的文件必须能识别唯一主函数;主函数必须紧邻一条职责注释,说明它组合或完成什么。
  6. 文件头注释和主函数注释是两个独立要求,不能用同一条注释同时充当两者。
  7. 只有类型、常量或独立原子函数集合而没有主函数的文件,不得为满足规则虚构空的 main

这里的“主函数”是文件中负责组织其它声明完成该文件主要职责的函数,通常是入口函数、编排函数或主要公开函数;不是按函数长度判断,也不是强制命名为 main

3.8 格式化器唯一来源与编辑器保存

仓库统一使用根目录 package.json 声明的 Oxfmt。缩进使用 4 个空格,不使用 Tab;具体格式规则由根目录 .oxfmtrc.json 维护,基础编辑器空白行为由 .editorconfig 对齐。

  1. pnpm format 是修改格式的唯一标准命令,pnpm format:check 是提交前的格式门禁。
  2. VSCode 保存格式化只能使用 oxc.oxc-vscode,并且必须读取仓库的 .oxfmtrc.json;工作区设置位于 .vscode/settings.json,推荐扩展位于 .vscode/extensions.json
  3. 未安装 Oxc 扩展时,不得让 VSCode 内置 TypeScript formatter、Prettier、Biome 或其它 formatter 接管保存格式化;应先安装 Oxc 扩展,或关闭 formatOnSave 后执行 pnpm format
  4. .editorconfig 只提供缩进、换行和文件末尾换行等基础编辑器行为,不能替代 Oxfmt,也不能成为第二套格式规则。
  5. 提交钩子、编辑器保存和 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 个字符的职责注释,入口文件是否描述目录职责?
  • 主函数是否有独立职责注释并位于所有依赖声明之后?
  • 入口函数是否是最后一个函数声明,其后是否只有启动调用或显式导出?
  • 结构调整后是否通过目标测试、严格类型检查和构建?