☰
Java DDD请假审批源码实战:四层架构、聚合根与状态机设计解析
2026/10/7 6:35:55 网站建设 项目流程

简介:基于领域驱动设计(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 List<ApprovalRecord> 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 Map<String, 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 R<String> handleInvalidLeaveTime(InvalidLeaveTimeException e) { return R.fail(400, e.getMessage()); } @ExceptionHandler(InvalidStateTransitionException.class) public R<String> 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 接口,按 Shift+F12 看它的实现类在哪个包。如果实现类在 infrastructure 层且 domain 层里没有任何 import 了 Spring、MyBatis 注解的文件,说明依赖方向是对的。常见的伪装 DDD 是 domain 层的实体上直接写@TableName注解,整个工程换个数据库都要改领域代码,这是最典型的翻车现场。

第三步,做一次事件链路回溯。从提交请假单这个 HTTP 请求出发,走一遍接口层 → 应用层 → 领域层 → 基础设施层 → 事件发布 → 事件监听的完整路径。关注一件事:请求在每一层分别做了什么。如果接口层在拼装 DTO、应用层在校验参数、领域层在算业务规则、基础设施层在访问数据库,各司其职,说明边界清晰。我自己常用方法是给每个层级的关键方法加一行打印日志,跑一次后看日志归属,一目了然。

从那以后,我每次拿到一套自称 DDD 的源码,都强制走这三步验证流程,不通关的代码一律不细读——用这套方法确实筛掉了不少「看起来很美」的工程。这套请假审批源码是我见过的少数三层验证全通过的样例,值得把整套工程下下来照着读一遍,你会比我更快看懂 DDD 的落地姿势。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询