ADR-0022: 后端领域 lib 分层与组合根形态
Date: 2026-10-31 Status: Accepted
Context
apps/server 是 NestJS CLI 内部 monorepo(ADR 0007)。 起点只有 9 个基建 lib(config / const / context / db / decorators / exceptions / pipes / types / utils),业务代码全部在 apps/api/src/modules/**,模块之间直接互相 import。
2026-10 的「libs 解耦批 0-10」按依赖方向把业务模块逐层搬进 libs/:批 0 先立注册一致性门禁与 libs 边界 lint,批 1-9 依次下沉基建、技术设施、共享能力、身份内核与各领域域,批 10 收官时 共 34 个内部 lib,app 剩组合根、wire DTO 与依赖 app 侧能力(guard/wire DTO)的 controller。批次 0-9 回答「搬哪些、搬到哪」;批 10 回答 三个形态问题:
- 分层怎么变成机械可检查的规则,而不是只写在文档里的意图;
- app 与「可部署入口」是什么关系(worker / cli 是不是新服务);
- lib 需要 app 侧能力时如何反转依赖,而不 import app。
Decision 1: 34 个内部 lib 按六层组织,依赖只能单向
分层以依赖方向为判据(下层不知道上层),不以目录名或业务重要度为判据:
| 层 | lib | 说明 |
|---|---|---|
| 基建(9) | config const context db decorators exceptions pipes types utils | 不依赖任何领域概念 |
| 技术(19) | als blpath cookie crypto delay lock queue sse cache throttle mailer sms rsa ip repository list crud scope-resolver token | 技术设施(缓存 / 队列 / 加密 / 邮件短信 / IP)+ 跨域装配内核(仓储基座、列表、CRUD、数据范围、token) |
| identity 内核(1) | identity | 用户身份栈(用户 / 设备 / MFA / OAuth / 偏好),被 system / auth / security 共同消费 |
| 领域域(3) | system auth security | 各自的 service / repository / entity 与 shared 能力 |
| 装配(1) | guards | guard / decorator / interceptor 装配层 —— import identity / system / auth / security 来装配安全链;app-domain 再依赖 guards |
| app-domain(1) | app-domain | app / shared 域的数据面(demo / error / key / logger / monitor / setting / area / ali / mask) |
五条机械守卫(守的是 lib ↔ app 边界、lib 之间的层间方向与注册完备性;层间方向自本轮起由 ESLint 分层规则机械守,不再只是约定与评审):
- ESLint
libs-boundary(apps/server/eslint.config.ts):libs/**禁止@/*(那是apps/api/src)、禁止相对路径逃逸进apps/、禁止 import 前端包;需要 app 能力时 用注入 token 反转依赖(见 Decision 3)。因此「lib 内零@/导入」是编译前就被拦下的, 不靠 review。 - ESLint 分层规则(同一文件,按层逐条配置):依赖方向固定为 基建 → 技术 → identity → system → auth → security → guards → app-domain(箭头指向被依赖方:上层 import 下层,反向即违规)。每层用
filesglob 圈定libs/<name>/**/*.ts,并以no-restricted-importspatterns 点名禁用上层的@walnut-server/<lib>(根路径与/*子路径都禁)。唯一例外是exceptions允许 importblpath(skip 名单所需,blpath 零出边),由一条独立的infra-exceptions配置 精确放行;未登记进任何分层的 lib 走 fail-closed 配置,连@walnut-server/*一律禁止。exceptions单独成一条infra-exceptions配置(libs 数组只含它自己),因此每个 lib 恰好 登记进一个 layer。ambient 声明文件(.d.ts,无运行时 import)豁免分层规则。已知盲区:.d.ts里的 type-only 跨层依赖不在机器守卫内(tsc 只校验可解析、不校验方向),靠评审把关; 当前.d.ts的内部 import 全部向下合法。 - ESLint libs 守卫(同一文件的
libs/**段):相对路径跨 lib 逃逸 ——import '../../system/src/...'与export ... from '../../identity/src'—— 由本地规则local/no-cross-lib-relative-import拦截(上面的分层规则只认识@walnut-server/*别名, 相对路径是它的盲区);动态import()零豁免:source 可以是模板串 / 拼接 / 变量,静态 分层门禁对它们全部失效,而 libs 下当前没有任何运行时动态 import,确需按需加载时在评审中显式 豁免。类型位的import('x')(typeof import('x'))是 TSImportType 节点、不产生运行时依赖边, 不在该 selector 内。 lib-registration.drift.spec.ts:以磁盘libs/<name>为基准,五处注册面全等 —— ①tsconfig.jsonpaths、② 三份infra/nest/*.json的 filenames / projects、 ③ 三份infra/swc/*.swcrcpaths、④apps/api/tsconfig.app.jsoninclude、 ⑤eslint.config.ts的分层名单(INFRA_LIBS/TECH_LIBS/DOMAIN_LIBS/TOP_LIBS/EXCEPTION_LIBS)与layer(name, libs, banned)的接线(lib 归属 + banned 集合对账, 防「名单对了、接线错了」); application(api / cli / worker)不参与对账。dto-entity.drift.spec.ts与eslint-data-scope-entities.drift.spec.ts: DTO ↔ Entity 字段形状、数据范围实体清单 ↔ ESLint 规则文本,各自单向对账。
Alternatives considered
- 保持
apps/api/src/modules/**单目录,不抽内部 lib —— 跨模块 import 没有边界可查, 「谁能依赖谁」只能靠约定;批 0-9 的拆分正是为了把这条约定变成 lint 与 drift spec。 - 把内部 lib 提升为 pnpm workspace 包 —— 已在 ADR 0007 否决:CJS +
emitDecoratorMetadata+ SWC,不适合作 ESM workspace 包。 - 每个领域域拆成独立服务(微服务) —— 部署单元会从「一个后端镜像」变成 N 个,事务、鉴权、 配置共享都需重做;本仓的部署前提仍是单容器单体(见 Decision 2),微服务不在本轮范围。
Decision 2: app 是组合根(部署单元),worker / cli 是可选组合入口
apps/api 仍是唯一默认部署单元:AppModule 按顺序装配 middleware / config / context / i18n / db / cron / 各域 HTTP 组合根 / guard 与 interceptor。apps/worker 与 apps/cli 不是新服务, 而是同一 workspace 内的其他组合入口:
apps/worker:可选第二进程,NestFactory.createApplicationContext(WorkerModule)(无 HTTP 监听)enableShutdownHooks,承接 cron 与 Bull 消费;默认部署不启动它,api 单进程全功能不变。
apps/cli:纯工具入口(data-source.cli.ts供 migration CLI,export-openapi.ts供离线导出 OpenAPI),零运行时影响。
前提是单容器单体:不做「每个 app 一个容器」的编排。worker 与 api 共用同一镜像、同一份 env-local、同一套业务模块;需要独立伸缩时再讨论拆进程/拆服务,不在本决策内预支。
两条实现约束:
- worker / cli 作为 application 项目注册在三份
infra/nest/{dev,prod,stage}.json;由于当前 Nest CLI 的 SWC builder 把filenames当全局配置(项目级builder.options不生效),apps/cli/src/apps/worker/src进入全局 filenames。api 构建只用exclude挡掉 cli (纯工具、不进镜像);worker 入口随 api 构建一起产出dist/apps/worker,这样默认镜像里 就带着「可选第二进程」的入口,Dockerfile 的 CMD 无需变化。 - worker 通过相对路径
../../api/src/app/app.module复用AppModule(根 tsconfig 的rootDir覆盖整个apps/server/);不为 worker 新增@/*之外的第二套别名。
Decision 3: 跨层依赖用注入 token 反转,绑定跟随实现所在层
方向固定为「实现方提供实现,需求方声明端口」。lib 内只 import 自己声明的 token 类型;绑定位置 跟随依赖方向:被绑定的实现在哪个层,绑定就放在那个层的 @Global 模块里;仅当实现依赖 app 专属 能力(socket、组合根装配)时,绑定才落在 app。本轮解耦引入的 19 个 WALNUT_ADMIN_* 注入 token (此前已存在的 OTP strategy 等端口不在此列)当前 3 个绑定在 app、16 个绑定在各 lib:
- identity:
WALNUT_ADMIN_IDENTITY_USER_NOTIFIER·WALNUT_ADMIN_IDENTITY_DEVICE_SERVICE·WALNUT_ADMIN_IDENTITY_OTP_SERVICE·WALNUT_ADMIN_IDENTITY_MFA_POST_VERIFICATION·WALNUT_ADMIN_IDENTITY_SIGNOUT_SERVICE·WALNUT_ADMIN_IDENTITY_MONITOR_REPOSITORY·WALNUT_ADMIN_IDENTITY_DATA_SCOPE_INVALIDATOR - auth / security:
WALNUT_ADMIN_AUTH_RISK_SERVICE·WALNUT_ADMIN_AUTH_MONITOR_SERVICE - system:
WALNUT_ADMIN_SYSTEM_FAILED_LOGIN_RISK_COLLECTOR·WALNUT_ADMIN_SYSTEM_MONITOR_ACTIVE_USER_READER - 共享能力:
WALNUT_ADMIN_BLACKLIST_REPOSITORY·WALNUT_ADMIN_DATA_SCOPE_POLICY·WALNUT_ADMIN_ERROR_REPORTER·WALNUT_ADMIN_IP_LOCATION_RESOLVER·WALNUT_ADMIN_KEY_STORE·WALNUT_ADMIN_OPERATE_LOG_WRITER·WALNUT_ADMIN_SOFT_DELETE_STRATEGY·WALNUT_ADMIN_TOKEN_TTL
新增一个反转点时,token 与端口接口一起放进需要它的 lib(而不是 app);绑定放在实现所在层 的 @Global 模块(多数时候就是同一 lib 或更低层 lib),app 侧只保留实现依赖 app 专属能力时的 { provide: TOKEN, useClass/useExisting/useFactory }。
Decision 4: 两处顺序敏感的东西留在 app
下沉只搬「数据面」,以下两类故意留在 apps/api/src:
- wire DTO:
SysUserDTOSafe及其虚拟读模型(WalnutAdminVirtualUserDTO等)参与 swagger schema 的注册顺序;schema 键序决定openapi.json的字节形态,而openapi:check的git diff --exit-code是前端类型生成的真源。放进 lib 会同时造成 lib → app 反向依赖与 schema 顺序漂移。 - HTTP controller 跟随依赖落位:依赖全 lib 的(app-domain 9 个)随数据面进 lib; system 域的 logs 数据面进 lib、controller 留 app;依赖 app guard / wire DTO / passport mixin 的(38 个:身份栈、auth、security 的 HTTP 面与 monitor/user)留 app 的各 HTTP 组合模块声明。
由此产生两处交错点(改动时必须保持):
AppMonitorUserHttpModule由SocketModule首个 import,/app/monitor/user/*在 openapi 里 排在/app/demo之前;SysDeleted的 controller 排在AppDemoModule之后(AppAppModule紧随 lib 的AppDemoModule引入),system 各子模块的 controller 声明顺序与解耦前一致。
app 保留件清单
下列件故意留在 apps/api/src,逐件理由:
app/(app.module.ts/schedule.composition.ts/AppController/AppService):AppModule的 imports 顺序就是 middleware → config → context → i18n → db → cron → 各域 HTTP 组合根的装配顺序;schedule.composition.ts用WORKER_STANDALONE做条件装配 (cron 归属在 api / worker 间切换),属于组合根语义;AppController/AppService是根路由, 挂在组合根上而不是任何域里。modules/app/:AppAppModule的组合顺序(含「两处交错点」)与 monitor/user 的 HTTP 组合 —— 后者的 wire DTO 引SysUserDTOSafe,必须与 wire DTO 同层,否则 lib → app 反向依赖。- 各域
modules/{auth,security,system}/的 HTTP 组合根:controller + wire DTO + passport mixin guard 是 app 侧装配面;数据面已在libs/{auth,security,system,identity}。modules/shared/只剩SharedModule聚合根(lib barrel 的组装,无 controller/DTO)。 modules/techniques/四件:event:EventEmitterModule.forRoot()是进程级注册;订阅方是 app 级适配器 —— 它监听 libs/crud 发出的删除事件并回写SysDeleted仓库,而 libs 只发事件常量,不反向依赖 app。health:Terminus 探针(network / PostgreSQL / heap / disk)是运行时可观测面, 不是业务域能力(compose healthcheck 已探/health/ready(W2-B 起),探针经 raw route 逃逸全局增强器)。logger:WalnutAdminLogger必须在 Nest DI 建立之前可用(引导期日志), Winston 传输 / 轮转是进程级配置。task:@Cron与onModuleInit横跨四域(app-domain / auth / identity / system),且 cron 归属由AppScheduleModule在 api / worker 间切换 —— 搬进 lib 等于让 lib 对「由谁组合、 以什么进程形态运行」表态。
- wire DTO:
SysUserDTOSafe及其虚拟读模型参与 swagger schema 的注册顺序;schema 键序 决定openapi.json的字节形态,openapi:check是裁判(见 Decision 4 正文)。 common/repository/的 data-scope 注册表 + policy:横跨 app-domain / auth / identity / system 四层实体(另含libs/repository的SysDeletedEntity;无 security —— 其表均不入回收站与 行级清单)的行级数据权限唯一真源;放进任何一个域都会造成域间反向依赖。common/recoverable/app-recoverables.ts:app / auth / shared 三域恢复器的登记汇总, 只服务dto-entity.drift.spec的对账(运行期各域 module 调各自的登记 factory),属于跨域对账面。i18n/·socket/·middleware/·public/·views/:进程级入口能力 —— i18n 是 全局语言解析、socket 是 WebSocket 网关装配、middleware 是 HTTP 管道工序,public//views/是静态资产与模板。common/swagger/:swagger 装配是 API 文档的进程级配置,随 wire DTO 一起留 app。
进程级 provider 的归属判据:APP_FILTER 跟随所属域的数据面 module 进 lib 是允许的 —— 错误过滤器(libs/app-domain 的 AppErrorModule 注册的 APP_FILTER)本身就是该域数据面的错误 上报出口;而 11 个 APP_GUARD、2 个 APP_INTERCEPTOR 与 EventEmitterModule.forRoot() 属于 进程级编排(guard / interceptor 的顺序即安全语义,事件订阅方是 app 级适配器),必须留在 app 组合 根;throttler 的 options/storage 由 libs/throttle 的 ThrottlerModule.forRootAsync 提供, 而 WalnutAdminGuardThrottler 的 APP_GUARD 注册留在 app(modules/techniques/tech.module.ts)。
与 ADR 0013 的关系:ADR 0013 的「显式具名 re-export」约束 的是 workspace package 的公开入口(packages/*/src/index.ts);lib barrel(libs/<name>/src/index.ts) 的 export * 是内部 Nest monorepo 的消费口径,不受该条约束。
Consequences
- app 侧剩组合根、wire DTO 与依赖 app 能力的 controller;业务数据面在 34 个 lib 内。 lib ↔ app 边界由 ESLint + 三个 drift spec 机械守;lib 之间的层间方向由 ESLint 分层规则 机械守(本轮落地)。
- 基建层内部没有互相引用的 SCC:纯
process.env判定下沉到libs/const/src/app/env.ts(libs/config/src/utils/env.ts只做兼容转发),config/utils对const单向依赖,config对utils的pkg依赖也不再回头(层间规则只看八层,同层环只能靠这种「纯判定放最低叶子」 的结构约束)。 - 默认部署形态不变:未设
WORKER_STANDALONE时 api 行为与解耦前一致,openapi.json零 diff 是每次改动的裁判;worker 是可选入口,WORKER_STANDALONE=true时 api 让出 cron, worker 的组合根承接(见apps/worker/README.md)。 - 新增内部 lib 的动作固定为「建目录 + 五处注册(paths / nest / swc / tsconfig include / ESLint 分层名单)+ lint 通过」;漏任何一处 drift spec 会红(第 ⑤ 面即分层名单对账)。
- 新增领域的能力默认拆成「lib 数据面 + app HTTP 组合根」两层;只有确认不触碰 wire DTO / guard 装配时才考虑把 controller 也下沉。