1. 项目概述:这不是一次“理论搬运”,而是一场前端工程化的真实突围
“前端 DDD 落地实录”这八个字,我第一次看到时心里咯噔一下——不是因为概念多新,而是因为太熟了。熟到几乎每个前端团队都聊过“要不要搞DDD”,最后却大多停在PPT里、停在技术分享会上、停在“等后端先搞明白再说”的推诿中。但这次不一样。我们真刀真枪把DDD从后端的专属领地,搬进了前端代码仓库,用四层架构重构了三个核心业务模块(物业工单系统、设备巡检平台、访客预约中台),并把质量门禁从“人工抽检”升级为“100%自动化拦截”。这不是炫技,是被逼出来的:去年Q3线上事故里,73%的问题根源指向状态管理混乱、领域逻辑散落在组件里、跨模块调用像在迷宫里扔飞镖。我们试过Redux Toolkit + Entity Adapter,也试过Zustand + 自定义hook封装,但越封装越臃肿,越抽象越难追溯。直到把DDD的“限界上下文”“聚合根”“领域服务”这些词,真正翻译成前端能执行的代码结构——比如一个“访客通行权限”的校验,不再是一堆if-else拼接的useEffect,而是VisitorAccessPolicy.validate()方法调用;比如“设备巡检任务”的状态流转,不再靠status: 'pending' | 'in_progress' | 'completed'硬编码,而是由InspectionTask.transitionTo(InspectionStatus.InProgress)驱动。这背后没有魔法,只有四层架构的物理分隔:UI层只负责渲染和事件转发,应用层协调用例执行,领域层承载业务规则,基础设施层对接API/Storage/EventBus。门禁也不是加个ESLint插件就完事——它覆盖从Git Commit前的本地预检、CI流水线中的AST静态分析、到打包产物的运行时契约校验三层防线。你不需要懂UML图或六边形架构论文,只需要知道:当你改一行代码,门禁会立刻告诉你“这个修改破坏了‘工单关闭’的不变量”,而不是等测试环境崩了才收到告警。这篇文章不讲DDD哲学,只讲我们怎么把“领域模型”变成src/domain/visitor/VisitorPolicy.ts里的真实类,怎么让yarn lint:domain命令成为每天开工的第一道门槛,以及那13个让我们连续熬了三周夜才填平的坑——它们真实存在,且每一个都足以让刚接触DDD的前端同学卡死在第一步。
2. 四层架构设计:为什么前端必须自己画这条“楚河汉界”
2.1 架构选型背后的血泪教训:当React组件成了“上帝对象”
我们最初尝试DDD时,最大的认知偏差是:以为只要把后端的分层照搬过来就行。于是写了DomainService、ApplicationService,结果发现所有service都在组件里被直接import、直接调用,useEffect里混着状态更新、API请求、领域逻辑判断,一个<WorkOrderDetail>组件文件长达1200行。问题出在哪?不是DDD错了,而是我们忽略了前端特有的约束:UI是状态的消费者,而非领域逻辑的容器。后端可以靠Spring IoC自动注入service,前端却要手动传递依赖;后端有JVM做内存隔离,前端所有JS代码跑在同一个全局作用域;后端能用AOP切面统一处理事务,前端连个简单的“撤销操作”都要手写undo栈。所以,我们彻底放弃了“模仿后端分层”的思路,转而基于前端运行时特性重新定义四层:
- UI层(Presentation Layer):严格限定为React/Vue组件,只做三件事:接收props、触发事件回调、调用
useAppServicehook。禁止任何业务逻辑、禁止直接调用API、禁止import domain文件。我们甚至用ESLint规则no-import-domain-in-ui强制拦截。 - 应用层(Application Layer):这是前端DDD的“心脏起搏器”。它不包含业务规则,只负责编排:接收UI层的用户意图(如“提交工单”),调用领域层验证规则,调用基础设施层执行持久化,最后返回结构化结果。关键设计是用TypeScript接口明确定义用例契约,例如
CreateWorkOrderUseCase.execute(input: CreateWorkOrderInput): Promise<Result<WorkOrder, Error>>,让UI层只关心“成功/失败”,不关心“怎么成功”。 - 领域层(Domain Layer):真正的业务规则所在地。这里没有React、没有fetch、没有localStorage——只有纯函数、实体类、值对象、领域服务。比如
WorkOrder实体自带canBeClosed(): boolean方法,其内部逻辑是this.status === Status.Completed && this.attachments.length > 0 && this.approverId !== null,所有校验逻辑内聚于此,UI层只需调用workOrder.canBeClosed()。 - 基础设施层(Infrastructure Layer):负责与外部世界对话。我们刻意拆分为
api/(Axios封装)、storage/(IndexedDB适配器)、event/(自定义EventBus)。重点在于所有基础设施实现都通过接口注入,应用层只依赖WorkOrderRepository接口,具体实现(如HttpWorkOrderRepository或MockWorkOrderRepository)由DI容器在启动时绑定。
提示:不要试图在UI层“复用”领域对象。我们曾让
<VisitorCard>组件直接接收Visitor实体,结果发现组件需要Visitor.name但Visitor里只有fullName字段,不得不在组件里写visitor.fullName.split(' ')[0]——这违反了“领域对象应提供UI所需视图方法”的原则。后来改为应用层提供VisitorViewDTO,UI层只消费DTO。
2.2 四层物理隔离:文件结构即架构契约
架构再好,如果代码没按约定组织,就是空中楼阁。我们用文件夹结构强制约束分层,这套结构经受住了23人协作、47个PR的考验:
src/ ├── ui/ # UI层:纯组件,无业务逻辑 │ ├── work-order/ │ │ ├── WorkOrderList.tsx # 只调用useListWorkOrders() │ │ └── WorkOrderForm.tsx # 只调用useCreateWorkOrder() ├── application/ # 应用层:用例编排,无领域规则 │ ├── work-order/ │ │ ├── useListWorkOrders.ts # 返回{ data, loading, error, refetch } │ │ ├── useCreateWorkOrder.ts # 封装execute()调用 │ │ └── work-order.use-case.ts # CreateWorkOrderUseCase类定义 ├── domain/ # 领域层:纯业务逻辑,零框架依赖 │ ├── work-order/ │ │ ├── entities/ # WorkOrder实体,含业务方法 │ │ ├── value-objects/ # Status、Priority等值对象 │ │ ├── services/ # WorkOrderDomainService(非CRUD) │ │ └── rules/ # 不变量校验规则(如"工单关闭需审批人ID") ├── infrastructure/ # 基础设施层:外部依赖适配 │ ├── api/ │ │ ├── work-order.api.ts # 实现WorkOrderRepository接口 │ │ └── axios.config.ts │ ├── storage/ │ │ └── local-storage.adapter.ts # 实现CacheRepository接口 │ └── event/ │ └── event-bus.ts └── shared/ # 跨层共享类型,如Result<T,E>、Id类型关键细节:
- 领域层禁止import任何非
shared/或同层文件。ESLint插件@typescript-eslint/no-import-from-outside-domain自动扫描,一旦domain/work-order/entities/WorkOrder.ts里出现import { api } from '@/infrastructure/api',CI直接失败。 - 应用层可import领域层和基础设施层,但禁止跨域引用。
application/visitor/不能importdomain/work-order/,否则打破限界上下文边界。 - UI层只能import应用层hook。
ui/work-order/WorkOrderForm.tsx里import { useCreateWorkOrder } from '@/application/work-order'是唯一合法路径,import { WorkOrder } from '@/domain/work-order'会被pre-commit hook拒绝。
这套结构带来的直接收益是:当产品经理说“把访客预约流程改成先选时间再选楼层”,我们只需修改application/visitor/下的用例编排,领域层VisitorPolicy完全不用动——因为业务规则(如“同一时段同一楼层最多5人”)早已在domain/visitor/rules/TimeSlotCapacityRule.ts里固化。
2.3 为什么不用CQRS?为什么拒绝“贫血模型”?
网上很多前端DDD方案推荐CQRS(命令查询职责分离),但我们踩坑后主动放弃。原因很实在:前端没有“读写分离”的物理必要性。后端用CQRS是因为数据库主从延迟、查询SQL复杂度高,前端数据源就一个API,缓存策略也简单(SWR或React Query)。强行拆分CreateVisitorCommand和GetVisitorQuery,反而导致:
- UI层要同时import两个hook,代码冗余;
- 领域事件发布/订阅机制在前端难以落地(谁来监听?组件卸载时如何清理?);
- 调试成本飙升:一个“创建访客”操作,要追踪command handler → domain service → event emitter → query invalidation → UI re-render五步。
我们选择更轻量的模式:应用层用例统一返回Result<T, E>,领域层通过抛出特定错误类型表达业务失败。例如VisitorPolicy.validate()校验不通过时,抛出VisitorCapacityExceededError,应用层捕获后转换为UI友好的提示。这样既保持了领域逻辑内聚,又避免了CQRS的复杂性。
至于“贫血模型”,我们坚决抵制。早期为了快速上线,WorkOrder实体只有id: string; status: string;等字段,业务逻辑全塞在useCreateWorkOrder里。结果一遇到“工单关闭需满足附件数≥2”的新需求,就得翻遍所有useCase文件找校验点。后来重构为富领域模型:
// domain/work-order/entities/WorkOrder.ts export class WorkOrder { private _status: WorkOrderStatus; private _attachments: Attachment[] = []; constructor( public readonly id: WorkOrderId, status: WorkOrderStatus, attachments: Attachment[] = [] ) { this._status = status; this._attachments = attachments; } // 业务方法:状态变更必须经过领域规则校验 close(approverId: UserId): Result<void, WorkOrderCloseError> { if (this._status !== WorkOrderStatus.Completed) { return Result.err(new WorkOrderNotCompletedError()); } if (this._attachments.length < 2) { return Result.err(new InsufficientAttachmentsError()); } if (!approverId) { return Result.err(new ApproverRequiredError()); } this._status = WorkOrderStatus.Closed; return Result.ok(); } get status(): WorkOrderStatus { return this._status; } }这个close()方法保证了:无论从哪个入口(UI按钮、定时任务、API回调)触发关闭,都经过同一套校验逻辑。UI层只需workOrder.close(approverId).match({ ok: () => ..., err: handleErr }),彻底告别散落的状态判断。
3. 100% 覆盖门禁:从“人肉Code Review”到“机器守门员”
3.1 门禁体系全景图:三层防御,缺一不可
所谓“100%覆盖门禁”,不是指某一个检查点,而是构建贯穿开发全流程的三层防御体系。我们拒绝“最后一道防线”思维——就像不会只在电梯出口装门禁,而是在大堂、电梯厅、轿厢都设卡。这三层分别是:
| 层级 | 触发时机 | 检查内容 | 技术实现 | 失败后果 |
|---|---|---|---|---|
| L1:本地预检门禁 | git commit前 | 1. 领域层代码是否import了UI/infra层 2. 应用层是否跨域引用 3. UI层是否直接调用API | Husky + lint-staged + 自定义ESLint插件 | Commit被拒绝,终端显示具体违规文件和行号 |
| L2:CI流水线门禁 | PR合并到main分支时 | 1. AST静态分析:领域方法调用链是否符合契约 2. 类型覆盖率:领域层TS类型使用率≥98% 3. 不变量校验:所有 xxx.rules.ts文件被至少一个用例调用 | GitHub Actions + TypeScript AST解析器 + custom type coverage tool | PR被阻塞,自动评论指出缺失的测试用例或未覆盖的规则 |
| L3:产物运行时门禁 | yarn build生成dist后 | 1. 打包产物中是否存在import * as React from 'react'在domain层2. 领域对象序列化/反序列化是否丢失业务方法 3. 运行时契约: WorkOrder.close()调用时是否传入必需参数 | Webpack plugin + 自定义Babel插件 + 运行时assert | 构建失败,错误日志精确到domain/work-order/entities/WorkOrder.ts:42 |
这三层不是叠加,而是递进:L1拦住80%低级错误(如误import),L2揪出架构性缺陷(如领域逻辑泄露到UI),L3守住最终底线(确保生产环境不运行违规代码)。我们曾统计,L1平均每天拦截17次违规commit,L2在Q4拦截了3个重大架构漏洞(如domain/visitor/被ui/work-order/意外引用),L3则在上线前发现1次因WebpackTreeShaking导致的领域方法丢失——若没这层,线上会出现“工单能关闭但不校验附件数”的致命bug。
3.2 L1本地门禁:Husky不是摆设,是每日开工仪式
很多人把Husky当装饰,我们的L1门禁让它成为开发者每天的第一个“仪式”。配置不是简单加个pre-commit脚本,而是深度集成到VS Code工作流:
// .husky/pre-commit #!/bin/sh # 严格顺序执行,任一失败即终止 yarn lint:domain && \ yarn lint:application && \ yarn lint:ui && \ yarn type-check:domain其中lint:domain是核心,它调用我们自研的ESLint插件eslint-plugin-ddd-frontend:
// eslint-plugin-ddd-frontend/rules/no-import-infrastructure-in-domain.js module.exports = { create(context) { return { ImportDeclaration(node) { const source = node.source.value; // 领域层禁止import基础设施层 if (context.getFilename().includes('/domain/') && (source.includes('/infrastructure/') || source.includes('axios') || source.includes('indexedDB'))) { context.report({ node, message: '领域层禁止import基础设施层,违反分层契约', suggest: [{ desc: '请将API调用移至应用层', fix: (fixer) => fixer.remove(node) }] }); } } }; } };注意:L1门禁必须快!我们要求所有检查在10秒内完成。为此做了三件事:1)用
--cache参数加速ESLint;2)type-check:domain只检查domain/**/*目录,不扫描node_modules;3)把最耗时的AST分析放到L2。如果开发者等30秒才看到报错,门禁就会沦为摆设。
VS Code用户还能获得实时反馈:安装ESLint插件后,违规代码下会有红色波浪线,悬停提示“领域层禁止import axios”,比commit后报错更早发现问题。我们甚至给新同事配了“门禁速查卡”:一张A4纸印着四层允许的import路径图,贴在显示器边框上——这是比文档更有效的培训。
3.3 L2 CI门禁:用AST解析器读懂你的业务逻辑
L2是门禁的灵魂,它不满足于语法检查,而是要理解代码语义。比如检测“WorkOrder.close()是否总在approve操作后调用”,这需要AST(抽象语法树)分析。我们用@babel/parser解析TS代码,构建调用图:
// ci/ast-analyzer.js const parser = require('@babel/parser'); const traverse = require('@babel/traverse'); function analyzeWorkOrderCloseCalls(filePath) { const code = fs.readFileSync(filePath, 'utf8'); const ast = parser.parse(code, { sourceType: 'module', plugins: ['typescript'] }); const calls = []; traverse(ast, { CallExpression(path) { const callee = path.node.callee; // 匹配WorkOrder.close()调用 if (t.isMemberExpression(callee) && t.isIdentifier(callee.object, { name: 'workOrder' }) && t.isIdentifier(callee.property, { name: 'close' })) { calls.push({ line: path.node.loc.start.line, file: filePath, // 分析调用上下文:是否在approve后? context: getContext(path) }); } } }); return calls; } // getContext()会向上查找最近的if/for/try块,判断是否在approve逻辑分支内这个分析器跑在GitHub Actions上,每次PR都会生成报告:
[AST Analysis Report] ✅ WorkOrder.close() 被正确调用 12 次 ⚠️ 2 次调用未在 approve 后: - src/application/work-order/useCloseWorkOrder.ts:88 - src/ui/work-order/WorkOrderActions.tsx:156 ❌ 违反业务契约:工单关闭必须 preceded by approval更狠的是类型覆盖率门禁。我们不满足于“写了类型”,而是确保类型被实际使用。工具ts-type-coverage扫描domain/目录,计算:
WorkOrder类中,id、status、attachments字段是否都被构造函数或方法使用;VisitorPolicy.validate()返回的Result<Visitor, VisitorError>,其err分支是否在应用层被处理。
要求domain/目录类型使用率≥98%,低于则CI失败。这迫使开发者写出真正有用的类型,而不是any或// @ts-ignore。曾有个同学为绕过门禁,在WorkOrder里加了// @ts-ignore注释,结果L2报告直接标红:“忽略类型声明导致覆盖率下降0.3%,请移除”。
3.4 L3产物门禁:Webpack插件是最后的守夜人
L3门禁常被忽视,但它防的是最危险的漏洞——构建时的意外。我们用Webpack插件webpack-domain-guard-plugin在打包阶段扫描产物:
// webpack.config.js const DomainGuardPlugin = require('./plugins/DomainGuardPlugin'); module.exports = { plugins: [ new DomainGuardPlugin({ // 确保domain层代码不包含React相关代码 forbiddenImports: [ { layer: 'domain', pattern: /react|jsx|tsx/i }, { layer: 'domain', pattern: /axios|fetch|indexedDB/i } ], // 确保领域对象序列化后仍可调用业务方法 serializableCheck: { classes: ['WorkOrder', 'Visitor'], methods: ['close', 'validate'] } }) ] };插件原理:在compilation.hooks.afterOptimizeChunkAssets钩子中,遍历所有chunk的源码字符串,用正则匹配违规import。对序列化检查,则在构建时注入一段测试代码:
// 注入的测试逻辑 const workOrder = new WorkOrder('WO-001', 'pending'); const serialized = JSON.stringify(workOrder); const deserialized = JSON.parse(serialized); // 检查deserialized是否有close方法(会失败,因JSON不保存方法) // 所以我们要求领域对象实现toJSON/fromJSON这暴露了关键设计:领域对象必须可序列化。我们因此强制所有实体实现:
export class WorkOrder { toJSON() { return { id: this.id, status: this.status, attachments: this._attachments.map(a => a.toJSON()) }; } static fromJSON(json: any): WorkOrder { return new WorkOrder( json.id, json.status, json.attachments?.map(Attachment.fromJSON) || [] ); } }L3门禁发现过一次严重问题:某次Webpack升级后,terser-webpack-plugin默认启用keep_fnames,导致WorkOrder.close.toString()返回function close() { ... }而非压缩后的function a(){...},破坏了我们基于函数名的运行时校验。门禁立即报警,我们回滚配置并加了keep_fnames: true显式声明。
4. 13个踩坑实录:那些文档里绝不会写的真相
4.1 坑1:领域层“纯函数”幻觉——浏览器API无处不在
我们天真地认为领域层能100%纯函数化,直到VisitorPolicy.validate()需要校验“当前时间是否在营业时间内”。营业时间存于后端配置,但领域层不能调API——于是我们把它设计为validate(time: Date, businessHours: BusinessHours), 让应用层把businessHours作为参数传入。问题来了:Date对象在序列化时会丢失时区信息,new Date('2023-01-01T09:00:00+08:00')变成"2023-01-01T01:00:00.000Z"。解决方案:领域层只接受ISO字符串,内部用date-fns-tz解析:
// domain/visitor/rules/OperatingHoursRule.ts export class OperatingHoursRule { validate(visitTimeIso: string, businessHours: BusinessHours): boolean { const visitTime = parseISO(visitTimeIso); // 解析为本地时区Date return isWithinInterval(visitTime, { start: parseISO(businessHours.open), end: parseISO(businessHours.close) }); } }实操心得:领域层永远不要信任
Date.now()或new Date(),所有时间输入必须是明确时区的ISO字符串。我们甚至在L1门禁里加了规则:domain/**/*中禁止出现new Date()。
4.2 坑2:UI层“无状态”陷阱——React.memo失效的真相
我们要求UI层组件必须是纯函数,结果<VisitorList>用了React.memo却没生效。调试发现:应用层hook返回的data是{ items: [Visitor], loading: boolean },但每次refetch后,items数组引用都变了,即使内容相同。根本原因是useQuery默认返回新数组。解决方案:应用层返回immutable数据结构:
// application/visitor/useListVisitors.ts export function useListVisitors() { const query = useQuery(['visitors'], fetchVisitors); return { data: query.data ? { items: Object.freeze(query.data.items), // 冻结数组 total: query.data.total } : undefined, loading: query.isLoading, refetch: query.refetch }; }Object.freeze()让items引用不变,React.memo终于生效。但要注意:freeze后无法push/splice,所以UI层只能用map/filter等纯函数操作。
4.3 坑3:领域服务循环依赖——当VisitorPolicy需要WorkOrderService时
限界上下文不是铁板一块。VisitorPolicy校验访客权限时,需检查该访客关联的工单是否已关闭(防止未完成工单的访客进入)。但WorkOrderService在work-order上下文,VisitorPolicy在visitor上下文。强行import会破坏边界。解法:定义跨上下文契约接口,由基础设施层桥接:
// shared/contracts/WorkOrderStatusContract.ts export interface WorkOrderStatusContract { isClosed(workOrderId: string): Promise<boolean>; } // infrastructure/bridge/work-order-status.bridge.ts export class HttpWorkOrderStatusContract implements WorkOrderStatusContract { async isClosed(workOrderId: string) { return axios.get(`/api/work-orders/${workOrderId}/status`) .then(res => res.data.status === 'closed'); } } // domain/visitor/services/VisitorPolicy.ts export class VisitorPolicy { constructor( private workOrderStatus: WorkOrderStatusContract // 依赖抽象,不依赖具体实现 ) {} canEnter(visitor: Visitor): boolean { return this.workOrderStatus.isClosed(visitor.workOrderId); } }应用层在初始化VisitorPolicy时注入HttpWorkOrderStatusContract,完美解耦。
4.4 坑4:L1门禁“假阳性”——Husky在Windows上路径分隔符报错
开发团队有Mac和Windows用户,L1门禁在Windows上总报错:“src\domain\visitor\VisitorPolicy.ts路径不合法”。原因是Node.js的path.join()在Windows返回\,而ESLint规则用/匹配。解决方案:统一用path.posix.join()处理路径:
// eslint-plugin-ddd-frontend/utils/path.js const path = require('path'); function normalizePath(filePath) { return filePath.replace(/\\/g, '/'); // 强制转为/ } module.exports = { normalizePath };并在所有规则中用normalizePath(context.getFilename())获取路径。
4.5 坑5:领域对象序列化丢失方法——JSON.stringify的温柔陷阱
WorkOrder.close()方法在JSON.stringify(workOrder)后消失,导致UI层拿到序列化数据后无法调用业务方法。我们曾想用JSON.stringify(workOrder, (key, value) => {...})手动保留方法,但方法是不可序列化的。正解:领域对象不直接暴露给UI,应用层提供DTO:
// application/work-order/work-order.dto.ts export interface WorkOrderDto { id: string; status: string; canBeClosed: boolean; // UI需要的视图状态,由领域对象计算 } // application/work-order/useWorkOrderDetail.ts export function useWorkOrderDetail(id: string) { const workOrder = useDomainStore(state => state.workOrders.find(w => w.id === id)); return { data: workOrder ? { id: workOrder.id, status: workOrder.status, canBeClosed: workOrder.canBeClosed() // 调用领域方法,返回布尔值 } : null, close: () => workOrder?.close(approverId) }; }UI层消费WorkOrderDto,完全不知道WorkOrder实体的存在。
4.6 坑6:TypeScript泛型擦除——领域层类型在运行时消失
Result<WorkOrder, WorkOrderError>在编译后变成Result,运行时无法区分WorkOrderError和NetworkError。我们曾用instanceof判断错误类型,结果总是false。解法:用symbol做类型标记:
// shared/result.ts export const WORK_ORDER_ERROR = Symbol('WORK_ORDER_ERROR'); export class WorkOrderError { readonly [WORK_ORDER_ERROR] = true; // 运行时标记 constructor(public message: string) {} } // 运行时判断 if ('WORK_ORDER_ERROR' in error) { // 处理领域错误 }L2门禁会扫描所有domain/**/errors/*.ts,确保每个错误类都有唯一symbol标记。
4.7 坑7:CI门禁超时——AST分析吃光GitHub Actions内存
初期L2门禁跑AST分析,1000行代码要3分钟,GitHub Actions免费版超时。优化三步:1)用--include只分析domain/和application/;2)用worker_threads并行解析;3)缓存AST结果:
# .github/workflows/ci.yml - name: Run AST Analysis run: yarn ast:analyze --cache-dir ./cache/ast env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}缓存命中率从32%提升到91%,平均耗时从180s降到22s。
4.8 坑8:L3门禁误报——Webpack TreeShaking删掉了“未使用”的领域方法
WorkOrder.close()在代码中只被useCloseWorkOrder调用,但useCloseWorkOrder本身被TreeShaking删掉(因未在UI中import),导致close方法也被删。解决方案:用/*#__PURE__*/标记关键领域方法:
export class WorkOrder { /*#__PURE__*/ close(approverId: UserId): Result<void, WorkOrderCloseError> { // ... } }Webpack会保留带此标记的方法,确保业务逻辑不被误删。
4.9 坑9:领域层单元测试“假覆盖”——mock让测试失去意义
早期用Jest mockWorkOrderRepository,测试WorkOrder.close()时只验证“是否调用了repository.save()”,却不验证close()内部的业务逻辑。后来改为测试领域对象自身:
// domain/work-order/entities/__tests__/WorkOrder.test.ts describe('WorkOrder.close()', () => { it('should fail if status is not completed', () => { const workOrder = new WorkOrder('WO-001', WorkOrderStatus.Pending); const result = workOrder.close('U-001'); expect(result.isErr()).toBe(true); expect(result.error).toBeInstanceOf(WorkOrderNotCompletedError); }); it('should succeed if all conditions met', () => { const workOrder = new WorkOrder('WO-001', WorkOrderStatus.Completed); workOrder.addAttachment(new Attachment('file.pdf')); const result = workOrder.close('U-001'); expect(result.isOk()).toBe(true); expect(workOrder.status).toBe(WorkOrderStatus.Closed); }); });测试不mock任何东西,只验证领域对象行为,这才是真正的单元测试。
4.10 坑10:VS Code IntelliSense在领域层失效——路径映射配置陷阱
import { WorkOrder } from '@/domain/work-order'在VS Code里跳转失败,提示“Cannot find module”。原因是tsconfig.json的baseUrl和paths没配对:
// tsconfig.json { "compilerOptions": { "baseUrl": "./", "paths": { "@/*": ["src/*"], "@/domain/*": ["src/domain/*"] // 必须精确匹配import路径 } } }漏掉@/domain/*这一行,IntelliSense就罢工。我们把这条加进新成员入职checklist。
4.11 坑11:L1门禁在IDE中不生效——WebStorm用户抱怨
Husky只在terminal中生效,WebStorm的GUI commit按钮绕过pre-commit。解法:在WebStorm设置中启用“Run Git hooks”:
Settings > Version Control > Git > Enable Git hooks execution并把.husky/pre-commit脚本路径填入。我们还写了自动化脚本,新成员clone仓库后自动执行yarn setup-ide,一键配置所有IDE。
4.12 坑12:领域层“过度设计”——Value Object vs Primitive
为Visitor.phone创建PhoneNumber值对象,结果发现90%场景只用phone.toString()。我们砍掉所有“为设计而设计”的VO,只保留真正有业务含义的:WorkOrderStatus(含isFinal()方法)、VisitorCapacity(含exceeds(max: number)方法)。其他如string、number直接用原始类型。L2门禁会扫描domain/**/value-objects/,如果某个VO超过3个文件未被引用,自动报警。
4.13 坑13:门禁配置“版本漂移”——不同项目用不同规则
三个业务模块用同一套门禁,但hzero模块要求domain层类型覆盖率99%,qiankun微前端只要95%。我们用package.json的ddd-config字段管理:
// packages/hzero/package.json { "ddd-config": { "typeCoverageThreshold": 99, "forbiddenImports": ["react", "axios"] } }L2门禁读取当前package的ddd-config,动态调整规则。避免“一刀切”导致某些模块无法推进。
5. 可直接抄的配置:开箱即用的门禁脚手架
5.1 一键初始化:create-ddd-app脚手架
我们把所有配置打包成npm包,新项目只需:
npx create-ddd-app@latest my-project \ --template vue3 \ --domain-layer work-order,visitor \ --ci-provider github它会生成:
- 预配置的四层文件结构;
- Husky L1门禁(含13个坑对应的修复规则);
- GitHub Actions L2门禁(AST分析+类型覆盖率);
- Webpack L3门禁插件;
- VS Code推荐插件列表(ESLint, Prettier, TypeScript)。
脚手架源码已开源,地址见文末。核心配置文件如下:
5.2 L1门禁配置(.husky/pre-commit)
#!/bin/sh echo "🔍 Running DDD Local Gate..." yarn lint:domain || exit 1 yarn lint:application || exit 1 yarn lint:ui || exit 1 yarn type-check:domain || exit 1 echo "✅ Local Gate passed!"5.3 ESLint领域层规则(.eslintrc.js)
module.exports = { extends: ['eslint:recommended', 'plugin:@typescript-eslint/recommended'], plugins: ['@typescript-eslint', 'ddd-frontend'], rules: { // 领域层禁止import