# ADR-006: 垂直切片 + 单一契约源(zod + zod-openapi) ## Status: Accepted (2026-08-26) ## Background 「备考中枢」是**个人单用户、自用型**备考数据中枢,主体为对 8 个领域实体的 CRUD + 少量跨实体组合业务(抽题、判定、错题入本、增量聚合、备份还原)。数据量级小、无多租户、无高并发。产品希望「改一个接口」的落点尽量集中,避免在多个目录间来回跳动;同时类型契约、请求校验、对外 API 文档三处必须保持一致,不出现「同一模型多处定义漂移」。 前端(Vue3)与后端(Express)同用 TypeScript,且依赖同一批领域类型;数据来自本地 JSON 文件(不可信输入),必须做运行时校验。 ## Decision 采用**垂直切片**组织方式,并采用**单一契约源(zod + zod-openapi)**: 1. **垂直切片(3 层,依赖只向下)**: - **Handler 层(`routes/*.routes.ts`)**:路由 + zod 请求校验 + 调 service + 组装 `ApiResponse` 集中在一个 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` 解析数据路径;领域类型由 zod `z.infer` 产出。个人单机 JSON 项目不再拆分 repository/store 两层。 - 依赖方向:route → service → data;service 不 import req/res;跨资源协作走对方 service 接口。 2. **单一契约源(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)