4.7 KiB
4.7 KiB
ADR-006: 垂直切片 + 单一契约源(zod + zod-openapi)
Status: Accepted (2026-08-26)
Background
「备考中枢」是个人单用户、自用型备考数据中枢,主体为对 8 个领域实体的 CRUD + 少量跨实体组合业务(抽题、判定、错题入本、增量聚合、备份还原)。数据量级小、无多租户、无高并发。产品希望「改一个接口」的落点尽量集中,避免在多个目录间来回跳动;同时类型契约、请求校验、对外 API 文档三处必须保持一致,不出现「同一模型多处定义漂移」。
前端(Vue3)与后端(Express)同用 TypeScript,且依赖同一批领域类型;数据来自本地 JSON 文件(不可信输入),必须做运行时校验。
Decision
采用垂直切片组织方式,并采用单一契约源(zod + zod-openapi):
-
垂直切片(3 层,依赖只向下):
- Handler 层(
routes/*.routes.ts):路由 + zod 请求校验 + 调 service + 组装ApiResponse<T>集中在一个 route 文件。请求校验的 zod schema 从packages/shared引入。 - Service 层(
services/*.service.ts):承载跨实体复杂业务(抽题、判定、错题入本、增量聚合、备份还原)。这一层独立保留,是因为这些组合操作跨多个实体、有规则与编排逻辑,不能塞进 handler;同时它不 import HTTP 对象、不返回 HTTP 响应,只返回业务结果或抛业务异常。 - 数据访问 + 领域类型层(
data/+packages/shared/src/schemas/):data/store.ts单文件存取(读 JSON + zod 校验 + 临时文件原子 rename + 写队列串行 + 惰性缓存),data/file-map.ts解析数据路径;领域类型由 zodz.infer产出。个人单机 JSON 项目不再拆分 repository/store 两层。 - 依赖方向:route → service → data;service 不 import req/res;跨资源协作走对方 service 接口。
- Handler 层(
-
单一契约源(zod + zod-openapi):
- 领域类型由
packages/shared/src/schemas/*.schema.ts的 zod schema 经z.infer产出,不手写 interface。 - OpenAPI 契约由
@asteasolutions/zod-to-openapi从同一批 zod schema 自动反推生成(OpenAPIRegistry.register/registerPath+OpenApiGeneratorV3.generateDocument),不手写维护 openapi.yaml。 - 请求校验用同一 zod schema
schema.parse(raw)。 - 三者同源于 zod schema:改一个字段只需改 schema 一处,类型、契约、校验自动跟随。
- 领域类型由
为何采用该架构
| 维度 | 说明 |
|---|---|
| 改接口落点少 | 一个资源 = 一个 route 文件 + 一个 service 文件;增删改一个端点只落在一个 route handler 内(校验 + 编排 + 响应组装同文件),跨实体逻辑只落在对应 service。 |
| 契约不漂移 | 类型、openapi 契约、请求校验三处同源于 zod schema;改一处全链跟随,无需维护多份定义,杜绝「手写 interface 与 zod 不一致」「契约过期」两类漂移。 |
| 契合个人单机项目 | JSON 数据层无需 repository/store 两层抽象;单文件存取(data/store.ts)+ 原子写 + 写队列足够覆盖单用户量级,剥离掉纯仪式性分层,减少文件跳转。 |
| service 层承载跨实体业务 | 抽题、判定、错题入本、增量聚合、备份还原是跨多个实体的组合业务,有真实规则与编排逻辑,独立成层以保持 handler 只做 HTTP 编排、数据层只做存取。 |
Consequences
- 正面:改一个接口的落点集中(多在一个 route 文件 + 一个 service 文件);类型/契约/校验三处永不漂移;剥离出与个人 JSON 项目不匹配的过度分层,文件组织更贴近实际改动路径;
z.infer产出具名类型,全链路 0 any。 - 负面:route handler 内同时承担校验、编排、响应组装,需以「单文件 ≤ 300 行 + 单一职责」约束防止 handler 膨胀;跨实体业务若被拆分过细仍可能跨文件,属正常,核心在「改单接口」这条主路径的落点集中;须在入口只调用一次
extendZodWithOpenApi(z),并让packages/shared保持无 app 依赖、纯净可复用。 - 权衡:垂直切片用「路由此纵切」替代「按层横堆」,换取更贴近单接口改动路径的文件组织;单一契约源用「zod 一处定义」替代「手写契约 + 手写类型」的多份维护,换取契约一致性。对个人单机项目,二者的复杂度增量(zod-openapi 引入)远低于收益(契约稳定 + 改动落点少)。
Related ADRs
- ADR-001(Express 5)/ ADR-002(Vue3 + Vite)/ ADR-003(本地 JSON 数据层)/ ADR-004(全 TS strict + zod)/ ADR-005(monorepo)