
简介基于领域驱动设计DDD的请假审批系统源码面向具备一定Java基础、想深入理解DDD与Spring Boot实践的开发者。项目围绕请假审批业务完整还原领域驱动设计的核心建模思路涵盖领域实体、值对象、聚合、仓储、领域服务、CQRS、领域事件与事件驱动等关键知识点并通过bounded context将整个系统合理拆分为领域层、基础设施层、应用层与REST API接口层模块职责清晰、边界明确适合作为企业级业务系统的代码参考。资源共40个文件压缩包大小2.66MB其中27个Java源码为主体辅以5个XML配置文件、5张PNG架构设计图、1个CSV数据文件以及README说明文档目录中还保留了docs文档与img图片资源便于对照理解整体架构。目前已有147人学习下载。学习该资源可以直观看到DDD各层如何协同工作掌握请假申请、审批流转、事件发布与持久化解耦的具体写法和典型代码示例对于想要落地DDD或重构复杂业务模块的工程师很有参考价值。1. 从「能用」到「能改」这套 Java DDD 请假审批源码到底给了你什么刚把 Java 后端做到能跑通 CRUD 的工程师第一次打开一套基于 DDD 架构的请假审批系统源码时通常不是兴奋而是发懵——代码分层比想象中多类也比想象中细明明只是一个请假流程为什么拆出这么多文件这套源码就是用来回答这个问题的它是一个完整的、可运行的 Java 工程把 DDD 领域驱动设计落到了请假审批这个足够小、又足够典型的业务场景上让你在几千行代码里看懂聚合、领域服务、仓储、应用服务到底各自干什么。适合两类人一类是正在学 DDD、被理论和 PPT 绕晕的开发者想看真实代码长什么样另一类是 Java 课程设计选题需要「有点架构味」的学生这套工程比普通 SSM 增删改查高一个段位演示时能讲清楚的东西也更多。2. DDD 落地拆解四层架构、聚合边界与 Spring Boot 技术栈选型2.1 为什么请假审批适合拿来做 DDD 样例选请假审批做 DDD 样例不是因为它简单而是因为它刚好卡在一个最舒服的复杂度区间。如果选电商订单聚合根、领域事件、防腐层全都要上新手直接看懵如果选用户管理又只有纯粹的增删改查强行套 DDD 反而显得做作。请假审批正好有状态流转、有业务规则校验、有跨部门协作数据量不大但业务逻辑密度高是练手 DDD 的标准样本。这套源码采用的是经典四层架构interfaces 接口层、application 应用层、domain 领域层、infrastructure 基础设施层。依赖方向从外向内领域层不依赖任何外部框架这是 DDD 最核心的约束。你拿到工程后第一件事应该是看包结构而不是先跑起来。com.example.leave ├── interfaces # 用户接口层Controller、DTO、参数校验 ├── application # 应用服务层用例编排、事务边界 ├── domain # 领域层实体、值对象、聚合根、仓储接口、领域服务 │ ├── leave │ │ ├── entity # LeaveOrder 聚合根、ApprovalRecord 实体 │ │ ├── vo # LeaveType、ApprovalStatus 值对象 │ │ ├── repository # LeaveOrderRepository 接口 │ │ ├── service # LeaveDomainService 领域服务 │ │ └── event # LeaveSubmittedEvent、LeaveApprovedEvent └── infrastructure # 基础设施层MyBatis-Plus 实现、Redis 缓存、消息发送判断一个 DDD 项目是否正统就看一点infrastructure 是否反向依赖了 domain。正规做法是 domain 里定义仓储接口infrastructure 里写 MyBatis-Plus 的 mapper 实现而 domain 层完全不出现数据库注解。这套源码的包结构是符合这个约束的这一点在课程设计答辩里非常加分面试时也能顺手讲两句。2.2 技术栈选型为什么是 MyBatis-Plus 而不是 JPA技术栈是理解这类源码的第二道门槛。这套工程用的是 Spring Boot 2.7 MyBatis-Plus 3.5 MySQL 8.0 Redis没有引入过于花哨的组件。选 MyBatis-Plus 而不是 Spring Data JPA我猜作者是有意为之——JPA 在 DDD 里的确更贴合聚合持久化但中国开发者对 MyBatis 的熟悉度远高于 JPA而且 MyBatis-Plus 的 LambdaQueryWrapper 写起来直观课程设计和二次开发的门槛都低。组件版本倾向作用选型理由Spring Boot2.7.x应用框架稳定、资料多、跟 JDK8 搭配舒服MyBatis-Plus3.5.xORM 持久化单表操作零 SQL分页插件好用MySQL8.0.x数据存储生产最常见课程设计演示环境容易搭Redis5.x 及以上缓存审批人列表、部门树演示缓存穿透和一致性问题Hutool5.8.x工具库日期计算、IdUtil 生成 ID 省事如果你拿这套源码做二次开发换成 JPA 也不是不行但要额外处理聚合内多个实体的级联持久化MyBatis-Plus 在这块反而更直白——聚合根落库、明细表落库、审批记录落库三次 insert 显式控制。DDD 不强制你用什么 ORM但组合使用时的边界要清楚聚合内的实体不通过仓储单独暴露只能从聚合根访问这个纪律比选哪个 ORM 重要得多。2.3 聚合边界到底怎么划聚合边界是这套源码里最值得读的部分。请假这个业务里有两个明显的聚合一个是请假单聚合包含 LeaveOrder 聚合根和 ApprovalRecord 实体另一个是员工聚合包含 Employee 聚合根和 AnnualLeaveBalance 值对象。两个聚合之间通过 ID 引用不直接持有对方聚合的内部实体。很多人把 DDD 的聚合理解成「把相关的表放到一个对象里」这是错的。聚合的边界是事务边界和一致性边界——一个聚合内的数据变更必须在一个事务里完成跨聚合的操作最终一致。这套代码里创建请假单时同时扣减年度假期余额这两个动作是跨聚合的作者的处理方式是先落请假单再通过领域事件触发余额调整。你在代码里会看到 LeaveSubmittedEvent 的发布逻辑应用服务层监听事件后调用员工聚合的服务。2.4 开始动手导入工程和初始化数据库拿到源码后第一个动作不是打开 IDE而是先建库。工程里一般会带 sql 初始化脚本如果没有按表结构手动建库也不会超过十分钟。mysql -uroot -p leave_approval.sql这个脚本会创建 leave_approval 数据库和六张核心表表注释都写在 DDL 里。建议你别一键执行完就关掉而是打开脚本仔细看一遍字段注释尤其是 approval_record 表里的 action 字段后面调试状态机的时候你一定会回来看它。导入工程时注意 JDK 版本。这套代码是基于 JDK8 写的用的是 java.time.LocalDateTime如果本地装的是 JDK17大概率会在编译期遇到 javax 包缺失的问题。常见做法是切换到 JDK8或者全局搜索javax.annotation.Resource这类 import手动替换成jakarta.annotation.Resource。后者的改动量不大但属于非技术问题没必要在第一步就卡住。# application.yml 里几个关键配置 spring: datasource: url: jdbc:mysql://localhost:3306/leave_approval username: root password: your_password redis: host: localhost port: 6379注意application.yml 里的数据库密码是占位的你第一次启动前必须改掉。如果 Redis 没启动工程也能跑只是缓存相关的接口会报连接异常。3. 核心编码实战请假单聚合、审批状态机与领域事件的实现3.1 聚合根的创建方法业务规则放在实体里而不是 Service 里这套代码跟普通三层架构最大的区别是你在 LeaveOrder 这个聚合根里能看到真实业务逻辑而不是一堆 getter 和 setter。创建请假单不是简单 new 一个对象而是调用聚合根的静态工厂方法把校验规则内聚在实体内部。public class LeaveOrder { private String leaveNo; private String applicantId; private LeaveType leaveType; private LocalDateTime startTime; private LocalDateTime endTime; private Integer totalHours; private ApprovalStatus status; private ListApprovalRecord approvalRecords; public static LeaveOrder create(LeaveType leaveType, LocalDateTime startTime, LocalDateTime endTime, String applicantId, LeaveCalculator calculator) { // 业务规则校验开始时间不能晚于结束时间 if (startTime.isAfter(endTime)) { throw new InvalidLeaveTimeException(开始时间不能晚于结束时间); } LeaveOrder order new LeaveOrder(); order.leaveNo generator.nextId(); // 分布式 ID避免多节点重复 order.applicantId applicantId; order.leaveType leaveType; order.startTime startTime; order.endTime endTime; order.status ApprovalStatus.PENDING_SUBMIT; // 调用领域服务计算时长跨天请假会扣减午休 order.totalHours calculator.calculate(startTime, endTime); order.approvalRecords new ArrayList(); return order; } public void submit() { // 只有草稿状态才能提交 if (this.status ! ApprovalStatus.PENDING_SUBMIT) { throw new IllegalStateException(当前状态不允许提交); } this.status ApprovalStatus.PENDING_APPROVAL; } }这段代码的核心不在校验本身而在「谁拥有这个校验逻辑」。在传统的 Service 里校验通常写在 XXServiceImpl 的方法开头一个方法几百行改规则时容易误伤其他逻辑。在 DDD 里创建对象的行为属于聚合根自己——外部只知道调 create 方法传入参数不关心内部怎么校验。你后续要加「提前三天申请」这类规则只需要改 create 方法内部接口层和应用层完全不动。注意 LeaveCalculator 是作为参数传入的而不是在聚合根里 new 一个工具类。这是 DDD 处理「聚合根需要外部计算能力」的标准姿势把依赖通过参数注入避免聚合根直接依赖基础设施。实际工程里 LeaveCalculator 的实现在基础设施层里面计算法定工作日、排除周末和节假日。3.2 审批状态机的设计状态模式还是规则表状态流转是请假审批系统的命脉也是面试官最爱问的点。这套源码采用的是规则表驱动而不是 GoF 的状态模式。原因很务实审批流程在真实业务里经常调整——今天加一个部门经理审批明天加一个总经理审批用 23 种设计模式里的 State 模式硬coding改一次流程要动好几个类用规则表改一个 Map 就完事。Component public class ApprovalStateMachine { // key: 当前状态 操作value: 结果状态 private static final MapString, ApprovalStatus TRANSITIONS new HashMap(); static { TRANSITIONS.put(PENDING_SUBMIT_SUBMIT, ApprovalStatus.PENDING_APPROVAL); TRANSITIONS.put(PENDING_APPROVAL_APPROVE, ApprovalStatus.APPROVED); TRANSITIONS.put(PENDING_APPROVAL_REJECT, ApprovalStatus.REJECTED); TRANSITIONS.put(APPROVED_CANCEL, ApprovalStatus.CANCELLED); } public ApprovalStatus transition(ApprovalStatus current, String action) { // 超出规则范围直接给异常而不是静默返回 ApprovalStatus next TRANSITIONS.get(current.name() _ action); if (next null) { throw new InvalidStateTransitionException( 状态 current 不允许执行操作 action); } return next; } }这段实现有一个容易被忽略的细节非法流转直接抛异常而不是返回 null 或者当前状态。很多初版状态机写得松散遇到未知流转就原样返回结果前端页面跳转了、后端状态没变排查半天。用异常来拦截非法流转链路清晰也方便测试覆盖所有路径。这套代码里的状态机没有用数据库表来配置直接写在静态代码块里。如果将来流程变得更复杂——比如加「部门审批中」「HR 审批中」两个中间态——我一般会建议把这个 Map 挪到数据库或者配置中心用枚举 注解反射来实现配置化。但课程设计和大多数中小型内部系统静态 Map 足够。3.3 领域事件解耦但不是滥用领域事件是这套源码另一个值得仔细读的地方。LeaveSubmittedEvent 的发布与监听完整演示了 DDD 里「聚合间通信」的标准方式领域层只定义事件对象和发布接口发布的具体实现比如用 Spring 的 ApplicationEventPublisher 还是 RocketMQ留在基础设施层。public class LeaveOrderService { private final ApplicationEventPublisher eventPublisher; public void submitLeave(String leaveNo) { LeaveOrder order leaveOrderRepository.findByLeaveNo(leaveNo); order.submit(); leaveOrderRepository.save(order); // 提交成功后发布事件不阻塞主流程 eventPublisher.publishEvent(new LeaveSubmittedEvent( order.getLeaveNo(), order.getApplicantId(), order.getLeaveType(), order.getTotalHours() )); } }要注意发布事件的位置是在应用服务层而不是领域层内部。原因在于保存聚合和发布事件必须保证顺序——先落库再发事件如果先发事件再落库监听方可能读到旧数据。这件事在很多文章里被简化掉了实际做的时候顺序错了会出现「余额扣了但单据没生成」这种灵异现象。事件监听方 A 是员工上下文里的 AnnualLeaveBalanceService收到事件后调用 adjustBalance 方法扣减年假余额。这里用的是同步事件监听方抛异常会影响主流程如果将来想改成异步把EventListener换成TransactionalEventListener(phase AFTER_COMMIT)加上Async即可但要注意事务边界和幂等——事件可能因为网络问题重复投递。4. 数据与基建层六张核心表设计、跨天工作日计算与统一返回封装4.1 数据库表设计的取舍审批记录为什么要独立成表数据的核心是六张表员工表、部门表、请假单表、审批记录表、年假余额表、节假日表。请假单表和审批记录表的设计是整个库的灵魂。表名关键字段说明employeeid, name, dept_id, hire_date员工基本信息departmentid, name, parent_id部门树支持多级审批路由leave_orderid, leave_no, applicant_id, leave_type, start_time, end_time, total_hours, status请假单主表状态字段冗余在这里方便查询approval_recordid, leave_no, approver_id, action, comment, create_time审批流水一个单多行记录annual_leave_balanceid, employee_id, total_days, used_days年度假期余额跨聚合引用holiday_configid, holiday_date, type节假日配置工作日计算依赖它审批记录独立成表而不是以 JSON 字符串存到 leave_order 的一个字段里是这套代码里一个正确的决定。JSON 字段查询起来痛苦不说审批链路一旦有并发追加就会遇到「读-改-写」竞争。独立成表后每条审批记录是独立插入天然免锁。另一个取舍是 leave_order 表里冗余了 status 字段。从建模角度说status 可以通过查询最新一条审批记录推导出来但实际查询列表页时每次都查子表做聚合SQL 复杂而且性能差。冗余状态字段同时用状态机约束流转是务实做法。这在 DDD 里是允许的——查询模型和领域模型分离CQRS 的一个轻量变体。4.2 跨天工作日计算一个容易被低估的复杂度点请假时长计算是这个项目里最能体现「业务理解」的代码。一个周一下午两点请假到周三上午十点一共多少小时直接按 24 小时减再除 8 是错的——里面包含了一个晚上和一个半天还跨过了周二如果周二恰好是法定节假日还得再减。LeaveCalculator 的实现在基础设施层注入 holiday_config 表的数据做计算。Component public class WorkingHoursCalculator implements LeaveCalculator { Override public int calculate(LocalDateTime startTime, LocalDateTime endTime) { int totalHours 0; LocalDateTime cursor startTime; while (cursor.isBefore(endTime)) { LocalDate currentDate cursor.toLocalDate(); // 周末直接跳过 if (isWeekend(currentDate)) { cursor cursor.plusDays(1).withHour(9).withMinute(0); continue; } // 节假日配置表命中的日期跳过 if (isHoliday(currentDate)) { cursor cursor.plusDays(1).withHour(9).withMinute(0); continue; } // 单日有效区间09:00 - 12:00 和 14:00 - 18:00 LocalDateTime workStart LocalDateTime.of(currentDate, LocalTime.of(9, 0)); LocalDateTime lunchEnd LocalDateTime.of(currentDate, LocalTime.of(14, 0)); LocalDateTime workEnd LocalDateTime.of(currentDate, LocalTime.of(18, 0)); LocalDateTime effectiveStart max(startTime, workStart); LocalDateTime effectiveEnd min(endTime, workEnd); if (effectiveStart.isBefore(effectiveEnd)) { totalHours Duration.between(effectiveStart, effectiveEnd).toHours(); } // 午休 12:00-14:00 天然不纳入计算游标直接跳到下午 cursor cursor.toLocalDate().plusDays(1).atTime(9, 0); } return totalHours; } }这里有一个很实用的边界细节12:00 到 14:00 的午休时间不需要显式判断因为 effectiveEnd 用 workEnd 截断、effectiveStart 用 workStart 抬升午休段自然落在区间外。新手写这段代码时最容易犯的错是想在循环里显式排除午休结果排叉了反而把 13:00 到 14:00 算进去。这个实现的精妙处在游标步进一天算完后 cursor 直接跳到次日 9 点不会出现 23:59 这种边缘值反复判断。4.3 统一返回结构与全局异常这套源码里有一个 R 类统一封装返回结构code、message、data 三段式。这不是 DDD 的专属要求但是工程化项目的标配课程设计里有没有统一返回结构是评阅老师一眼就能看出的代码质量分水岭。注意这里的 R 需要用在 controller 层application 层返回值不包装——把 HTTP 语义透传到应用层会让抽象泄漏这是分层里最容易越界的细节。RestControllerAdvice public class GlobalExceptionHandler { ExceptionHandler(InvalidLeaveTimeException.class) public RString handleInvalidLeaveTime(InvalidLeaveTimeException e) { return R.fail(400, e.getMessage()); } ExceptionHandler(InvalidStateTransitionException.class) public RString handleInvalidStateTransition(InvalidStateTransitionException e) { return R.fail(409, e.getMessage()); } }把这套全局异常处理和状态机的非法流转接起来看就有意思了领域层抛出的异常冒泡到接口层被全局处理器捕获并转成 HTTP 状态码。整个过程应用层几乎没写过 try-catch这正是 DDD「业务规则下沉」的好处——规则在领域层定义异常由基础设施层兜底。5. 避坑排查DDD 项目最常见的五个翻车现场与修复方法5.1 现象接口请求成功但数据没有落库原因这是新手跑 DDD 工程最容易遇到的第一坑。application 服务方法上有Transactional但方法内部先发布了领域事件事件监听方又去修改另一个聚合的数据两个数据源不在同一个事务里异常被 Spring 事件机制的默认逻辑吞掉了接口返回 200数据库里什么都没有。解决把发布事件的代码挪到事务提交之后用TransactionalEventListener(phase AFTER_COMMIT)保证主事务先提交再执行监听逻辑。如果监听方逻辑失败了要显式记录失败日志或做对账表不能指望它影响主事务。5.2 现象多实例部署后请假单号重复原因源码里生成 leaveNo 用的是IdUtil.simpleUUID()单机没问题多实例部署时如果使用时间戳加随机数的方案极端并发下可能出现重复。我当时接手一个改造成多实例的请假系统上线第二天就出现了两条单号完全一样的记录。解决切换成 MyBatis-Plus 自带的IdWorker.getId()雪花算法或者表里建一个 sequence 表统一发号。用雪花算法要注意时钟回拨问题但对内部系统而言标准 Snowflake 足够。改完后给 leave_no 加唯一索引双保险。5.3 现象Redis 缓存了审批人列表人员调整后审批人选不到新人原因接口层直接从 Redis 读取审批人列表缓存没有设置失效时间。人员入职或转岗后缓存里还是旧数据审批流程走到该节点时报「审批人不存在」。解决要么在员工聚合的更新方法里显式删除相关缓存 key要么给缓存 key 设置 30 分钟过期。实战里我会选前者为主、后者兜底——缓存穿透的代价远小于数据不一致的代价。代码调试时可以在缓存工具类里加一组 TTL 参数批量扫描过期时间快速定位问题出在哪个 key。5.4 现象跨天请假计算出来的时长总是少一小时原因这个坑非常隐蔽。WorkingHoursCalculator 里Duration.between(effectiveStart, effectiveEnd).toHours()是向下取整如果请假区间是 9:00 到 12:30得到的结果是 3 而不是 4——你以为是 3.5 小时向上取整实际丢了半小时。另一个更真实的场景是上午请 9:00 到 11:00下午请 14:00 到 16:00看起来是 4 小时但循环里 cursor 跳转逻辑写错下午那段根本没被遍历到。解决先打印日志把 cursor 的每次跳转值输出确认游标是否覆盖了整个区间再把取整改为Math.round()或者用分钟做单位计算最后统一转成小时。这个 bug 不改会产生连锁反应——扣减年假余额时多扣了用户投诉。5.5 现象MyBatis-Plus 查询条件失效查出来一堆被逻辑删除的数据原因LeaveOrder 实体上标注了TableLogic逻辑删除字段但聚合根里的查询写了自定义 SQL没有自动拼接deleted 0条件。MyBatis-Plus 的TableLogic只对内置方法生效自定义 XML 里的 SQL 要手动带上过滤条件。解决在 XML 里的每个 SELECT 后手动追加WHERE deleted 0或者统一用 LambdaQueryWrapper 避免写自定义 SQL。排查技巧是打开 MyBatis SQL 日志看实际发到数据库的语句——十次里面有八次问题在 SQL 拼接上。6. 验证 DDD 落地质量从一次事件回溯到一眼看穿贫血模型拿到这套源码后怎么判断它到底是「真的 DDD」还是「披着 DDD 外衣的三层架构」我给一个三分钟验证法这也是我每次评审代码时的固定动作。第一步看聚合根有没有业务方法。一个真正的聚合根方法名应该是提交、审批、驳回、取消而不是 getStatus、setStatus、getId。如果聚合根一屏拉下来全是 getter/setter不管目录结构分得多细都是贫血模型。这套源码里的 LeaveOrder 有 create、submit、approve、reject、cancel第一步直接通过。第二步验证依赖方向。在 IDE 里打开 domain 层的 LeaveOrderRepository 接口按 ShiftF12 看它的实现类在哪个包。如果实现类在 infrastructure 层且 domain 层里没有任何 import 了 Spring、MyBatis 注解的文件说明依赖方向是对的。常见的伪装 DDD 是 domain 层的实体上直接写TableName注解整个工程换个数据库都要改领域代码这是最典型的翻车现场。第三步做一次事件链路回溯。从提交请假单这个 HTTP 请求出发走一遍接口层 → 应用层 → 领域层 → 基础设施层 → 事件发布 → 事件监听的完整路径。关注一件事请求在每一层分别做了什么。如果接口层在拼装 DTO、应用层在校验参数、领域层在算业务规则、基础设施层在访问数据库各司其职说明边界清晰。我自己常用方法是给每个层级的关键方法加一行打印日志跑一次后看日志归属一目了然。从那以后我每次拿到一套自称 DDD 的源码都强制走这三步验证流程不通关的代码一律不细读——用这套方法确实筛掉了不少「看起来很美」的工程。这套请假审批源码是我见过的少数三层验证全通过的样例值得把整套工程下下来照着读一遍你会比我更快看懂 DDD 的落地姿势。希望帮到你。本文还有配套的精品资源点击获取