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

43 lines
4.7 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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 引入)远低于收益(契约稳定 + 改动落点少)。
## Related ADRs
- ADR-001Express 5/ ADR-002Vue3 + Vite/ ADR-003本地 JSON 数据层)/ ADR-004全 TS strict + zod/ ADR-005monorepo