gwy-exam/deliverables/architecture/decisions/ADR-006-vertical-slice.md
2026-08-26 16:20:55 +08:00

4.7 KiB
Raw Blame History

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<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 解析数据路径;领域类型由 zod z.infer 产出。个人单机 JSON 项目不再拆分 repository/store 两层。
    • 依赖方向route → service → dataservice 不 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 引入)远低于收益(契约稳定 + 改动落点少)。
  • ADR-001Express 5/ ADR-002Vue3 + Vite/ ADR-003本地 JSON 数据层)/ ADR-004全 TS strict + zod/ ADR-005monorepo