ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Java DDD架构请假审批系统:聚合根、领域服务与仓储实战指南

Java DDD架构请假审批系统:聚合根、领域服务与仓储实战指南 简介这是一套基于领域驱动设计DDD的请假审批系统Java源码面向中高级Java开发者与架构设计人员用于理解DDD在真实业务场景中的落地方式。项目采用Spring Boot构建按领域边界拆分为多个模块包含请假申请、审批流程、员工等核心领域对象完整呈现聚合、仓储、领域服务、领域事件、CQRS与事件驱动等关键实践。压缩包共40个文件其中27个Java源文件承载业务逻辑5个XML文件负责配置另有5张PNG示意图、1份Markdown说明文档及1个CSV数据文件整体仅2.66MB结构紧凑、便于本地运行与分析。已有147人学习适合作为企业级审批类系统的建模参考也可用于学习模块化架构设计、异步事件联动以及读写分离在Spring Boot项目中的具体实现。1. 拿到这份基于DDD架构的请假审批系统源码先别急着解压跑起来多数 java 工程师第一次接触 DDDDomain-Driven Design领域驱动设计架构不是从书架上那本蓝色封面经典开始的而是从面试题、课程设计或者一份像「Java基于DDD架构的请假审批系统源码.zip」这样的压缩包开始的。请假审批这个业务场景几乎是为 DDD 量身定做的它有明确的业务规则按天数分级审批、销假、抄送、清晰的生命周期状态草稿、审批中、已通过、已驳回、多个参与角色员工、直属主管、HR。把这些逻辑揉进传统的三层架构里最后往往变成 Service 层几百行的 if-else也就是俗称的「贫血模型」而用 DDD 落地代码结构会从一开始就逼着你思考「业务规则到底该住在哪里」。这份笔记适合两类人一类是想把 DDD 理论真正落进 Java 工程的初中级工程师想搞懂分层边界、聚合根、仓储这些概念怎么变成目录和代码另一类是刚解压了一份这样的源码包正在困惑「我从哪个文件开始读才不会被绕晕」。2. 请假审批为什么适合 DDD先理清领域模型再谈代码结构在双击解压之前先回答一个问题为什么不直接建三个包controller / service / mapper就开工因为请假审批不是一个简单的增删改查它有需要被保护的业务不变量业务规则而这些不变量散落在 Service 里的话每次需求变更都要在几十个方法里找出「在哪改」。DDD 的初衷就是让业务规则回到它该待的地方领域层。2.1 限界上下文与聚合根从「员工请假」场景里切出边界整个系统可以拆成多个上下文员工管理上下文、组织架构上下文、请假审批上下文、考勤统计上下文。我们在标题里的这个系统核心就是「请假审批上下文」它只关心请假单从提交到归档的生命周期。在这个上下文内部聚合根选谁很关键。最常见的正确建模方式是LeaveRequest请假单作为聚合根根下面挂LeaveItem请假明细比如某个时间段、某个工作日和一组审批记录ApprovalRecord。所有对这个聚合内部的修改都必须经由LeaveRequest这个门面对象的方法来完成外部不能直接拿到ApprovalRecord的 list 后乱改。2.2 实体与值对象的划分为什么「时段」不是实体区分实体Entity和值对象Value Object是 DDD 里最容易引起争论的地方但也是收益最明显的一步。实体有身份标识它的唯一性靠 ID 保持状态会变值对象没有独立身份两个对象只要属性相同就是同一个对象且推荐设计成不可变的。在请假单里「请假人」是EmployeeIdEmployeeName它指向员工上下文的数据所以它在聚合里可以是一个值对象也可以只是一个 ID 引用。而「时间段 LeaveTimeRange」开始日期、结束日期、时长就是标准的值对象两个都是 2025-03-01 到 2025-03-03 的时段本质上没有任何区别。「审批通过」这个动作不是把字段改成「通过」就完了而是LeaveRequest.approve(approver, comment)这个方法内部去维护状态和追加审批记录——这正是 DDD 封装业务规则的地方。2.3 仓储接口为什么定义在领域层实现却放在基础设施层很多 Java 工程师第一次看到 DDD 项目结构会蒙圈IRepository接口放在 domain 包RepositoryImpl却跑到 infrastructure 包而且 impl 里居然注入 MyBatis 的Mapper。这个「倒置」才是依赖倒置原则在 DDD 里的落实领域层只声明「我需要把聚合持久化」但不关心你用 MySQL 还是 Oracle也不关心你用 MyBatis 还是 JPA。常用工程做法的分层职责表所属模块包路径职责依赖方向接口层interfacesHTTP 入口、DTO、参数校验、路由依赖应用层应用层application事务边界、任务编排、权限校验、事件发布依赖领域层领域层domain实体、值对象、领域服务、仓储接口、领域事件零依赖基础设施层infrastructureJPA/MyBatis 实现、消息发送、外部 API实现领域层接口看清楚这张表你读源码的时候就有地图了。领域层是项目的核心它被其他层围绕而不是反过来。3. 解压源码并跑通项目工程结构与启动避坑这一步的实际操作新人最容易在这里卡住。和「从 Git 仓库 clone 然后 mvn spring-boot:run」不同zip 包里的工程可能是半成品也可能是带完整环境配置的能直接跑起来的工程。先从结构判断工程类型是第一步。3.1 zip 解压与工程识别别用中文路径先看顶层文件压缩包拿到手不要双击就地解压更不要解压到桌面「新建文件夹 (2)」。我一般会先建一个工作目录比如D:/projects/leave-ddd再解压进去。 解压完先看顶层find . -maxdepth 2 -type f | head -50这一步的产出是文件清单。如果看到pom.xml说明是 Maven 工程看到build.gradle是 Gradle如果既没有 pom.xml 也没有 build.gradle只有 src 目录那可能就是一份「半成品」源码需要你手动补齐工程骨架。还有另一种常见情况解压完后pom.xml在子目录里这是外层又包了一层文件夹——用 IDEA 打开的时候要选到pom.xml所在的那一层而不是最外层。判断完工程类型后用 IntelliJ IDEA 的「File → Open」选中 pom.xml等待 Maven 依赖下载。这里有个血泪经验国内网络环境下载 Maven 依赖很慢不要用 IDEA 默认的中央仓库。打开~/.m2/settings.xml没有就新建配置阿里云镜像mirrors mirror idaliyun/id mirrorOfcentral/mirrorOf urlhttps://maven.aliyun.com/repository/public/url /mirror /mirrorssettings.xml 不会污染项目本身它是全局生效的。配好镜像重新刷新 Maven 项目速度差异是肉眼可见的。3.2 启动配置从本地跑通请假审批的最小动作大多数这类源码使用 Spring Boot所以核心是找到application.yml。里面至少有数据源配置、Redis 配置如果有的话、端口号。一份典型的本地配置长这样server: port: 8080 spring: datasource: driver-class-name: com.mysql.cj.jdbc.Driver url: jdbc:mysql://localhost:3306/leave_ddd?useSSLfalseserverTimezoneAsia/Shanghai username: root password: 123456 jpa: show-sql: true hibernate: ddl-auto: updateddl-auto要特别注意如果项目用的是 JPAupdate会自动建表这对第一次运行是福利但如果项目用 MyBatis 搭配schema.sql来建表你需要手动执行建表脚本。顺序反了先启动项目再去执行 schema.sql就会出现「表不存在」的启动错误。如果是 MyBatis 的工程mybatis.mapper-locations指向 xml 文件的位置在 IDEA 里最容易翻车的点就是 xml 放在了src/main/java目录下但没配resources对应的构建路径。启动报错常见的现象是 Mapper 方法找不到 statement。排查命令mvn clean compile mvn spring-boot:run看编译阶段是否报资源文件拷贝问题。如果编译能过但启动时提示Invalid bound statement (not found)去target/classes目录下看mapper的 xml 是否被拷贝进来。没有的话在pom.xml里加构建配置把src/main/java里的.xml一并打包build resources resource directorysrc/main/java/directory includes include**/*.xml/include /includes /resource resource directorysrc/main/resources/directory /resource /resources /build这块没有捷径IDE 不会替你处理资源文件的位置只有配置文件里的路径和构建配置里的 resources 同时正确Mapper 才能被扫到。3.3 从入参到出参的请求链路读懂源码的阅读顺序接口层通常是rest/LeaveRequestController这是入口。对着代码从前到后看一遍执行一次完整链路的顺序是POST /api/leave/requests Body: {employeeId: 1, startDate: 2025-03-01, endDate: 2025-03-03, reason: 回家办事}这个请求会依次经过 Controller 参数校验、DTO 转 Command 对象、调用应用服务LeaveAppService.submit()、应用服务里再调领域层聚合根的方法、最后调用仓储保存。阅读源码时要在心里画好这条线而不是从domain.entity底层开始翻——那样很容易迷失。4. 核心代码落地聚合根、领域服务与仓储的协作方式跑通项目之后要把重心放在 domain 包下的几个核心类上。请假审批系统里最值得细读的地方就是LeaveRequest这个聚合根以及它和ApprovalPolicyService的协作方式。许多号称 DDD 的项目点开 domain 一看全是 Lombok 注解的 getter/setter——那是「贫血模型」业务逻辑全在 application 层和领域服务里这种不叫 DDD。4.1 聚合根 LeaveRequest 的状态机设计只有状态没有行为的实体是半成品聚合根需要提供有业务含义的行为方法而不是让外部通过setStatus()乱改状态。请假单的状态流转可以被建模成public class LeaveRequest extends AbstractAggregateRootLeaveRequest { private Long id; private Long employeeId; private LeaveTimeRange timeRange; private LeaveType type; // PERSONAL 事假 / ANNUAL 年假 / SICK 病假 private LeaveStatus status; // DRAFT 草稿 / APPROVING 审批中 / APPROVED 已通过 / REJECTED 已驳回 / CANCELLED 已取消 private String reason; private ListApprovalRecord approvalRecords new ArrayList(); public void submit() { if (this.status ! LeaveStatus.DRAFT) { throw new IllegalStateException(只有草稿状态的请假单才能提交); } if (!timeRange.isAfter(OffsetDateTime.now())) { throw new IllegalArgumentException(请假开始时间不能早于当前时间); } this.status LeaveStatus.APPROVING; } public void approve(Long approverId, String comment) { if (this.status ! LeaveStatus.APPROVING) { throw new IllegalStateException(当前状态不可审批); } this.approvalRecords.add(new ApprovalRecord(approverId, ApprovalResult.APPROVED, comment, OffsetDateTime.now())); this.status LeaveStatus.APPROVED; } public void reject(Long approverId, String comment) { if (this.status ! LeaveStatus.APPROVING) { throw new IllegalStateException(当前状态不可驳回); } this.approvalRecords.add(new ApprovalRecord(approverId, ApprovalResult.REJECTED, comment, OffsetDateTime.now())); this.status LeaveStatus.REJECTED; } }这个类的关键不是字段而是三个方法里各自的「状态前置校验」。如果这个校验出现在 Controller 或者 AppService 里那么任何调用方都可能在状态不对的时候强行推进审批流。把状态校验收进实体方法里等于给业务规则上了锁。这里有个参数细节approve()方法的第二个参数是comment默认可以允许为空字符串但不可以为 null——空串是「没有意见」null 是「未传值」两者在后续审计时有完全不同的语义。可以在方法开头加Objects.requireNonNull(comment, comment must not be null)。4.2 领域服务 vs 应用服务的边界审批规则到底放在哪当业务规则需要同时参考请假单之外的信息时比如「请假天数超过 3 天需要二级主管审批」这个规则不能放在聚合根里因为聚合根自己不知道组织架构的信息。这时候需要领域服务Domain Service它站在聚合根外部负责协调多个聚合或访问外部上下文的信息。public class ApprovalPolicyService { private final LeaveRequestRepository leaveRequestRepository; private final EmployeeInfoGateway employeeInfoGateway; public ApprovalPolicyService(LeaveRequestRepository repo, EmployeeInfoGateway gateway) { this.leaveRequestRepository repo; this.employeeInfoGateway gateway; } public boolean isApprovalNeedHigherLevel(LeaveRequest leaveRequest) { // 年假超过 3 天或事假超过 2 天都需要二级主管审批 if (leaveRequest.getType() LeaveType.ANNUAL leaveRequest.getTimeRange().getDays() 3) { return true; } if (leaveRequest.getType() LeaveType.PERSONAL leaveRequest.getTimeRange().getDays() 2) { return true; } return false; } }既然领域服务要查仓储它的实现自然依赖了仓储接口注意是接口。而应用服务LeaveAppService的职责则变成「对提交过来的命令做编排」它调用ApprovalPolicyService.isApprovalNeedHigherLevel()决定要不要走多级审批再把审批任务交给ApprovalTaskAppService。业务规则div 多少天、什么类型在领域服务里任务的流程编排在应用服务里两者职责不重叠。4.3 仓储接口与实现领域层只管契约不依赖 MyBatis仓储接口放在domain.repository包这是规则。public interface LeaveRequestRepository { LeaveRequest findById(Long id); void save(LeaveRequest leaveRequest); }而基础设施层里的实现类是这么写的Repository public class LeaveRequestRepositoryImpl implements LeaveRequestRepository { private final LeaveRequestMapper leaveRequestMapper; private final LeaveItemMapper leaveItemMapper; private final ApprovalRecordMapper approvalRecordMapper; public LeaveRequestRepositoryImpl(...) { ... } Override public LeaveRequest findById(Long id) { LeaveRequestDO requestDO leaveRequestMapper.selectById(id); if (requestDO null) { return null; } ListLeaveItemDO itemDOS leaveItemMapper.selectByRequestId(id); ListApprovalRecordDO recordDOS approvalRecordMapper.selectByRequestId(id); return requestDO.toEntity(itemDOS, recordDOS); } Override public void save(LeaveRequest leaveRequest) { // 聚合新增或修改需要区分 insert 和 update if (leaveRequest.getId() null) { leaveRequestMapper.insert(leaveRequest.toDO()); leaveItemMapper.batchInsert(leaveRequest.getItems().stream().map(LeaveItem::toDO).toList()); } else { leaveRequestMapper.update(leaveRequest.toDO()); leaveItemMapper.deleteByRequestId(leaveRequest.getId()); leaveItemMapper.batchInsert(leaveRequest.getItems().stream().map(LeaveItem::toDO).toList()); } } }看到这里的实现方式拆开来看每一行都很直白真正的 DDD 味道在于聚合根在 save 时整体保存而不是各层各自为政。Save 会删除明细再重新插入这是简化处理方式做课程设计或中小型项目没问题但如果担心这一块的重数据量问题可以改成增量对比判断哪些明细有变化。这个toDO() / toEntity()的转换很可能占据整个基础层代码量的 50% 以上项目越大这部分越重熟手一般会引入 MapStruct 来减少手写转换。5. 落地 DDD 架构时最容易踩的 5 个坑读源码或自己改造这份代码时以下几个地方是翻车重灾区。每一条我都按「现象 → 原因 → 解决」写方便你直接对照排查。5.1 领域实体被 JSON 序列化后循环引用现象调用 POST 提交请假单返回的 JSON 在浏览器里出现重叠层级不停展开或者接口直接报 500 栈溢出。原因Controller 直接把聚合根LeaveRequest作为响应体返回而聚合根里引用了ListApprovalRecord每个ApprovalRecord又引用回LeaveRequest通常是因为双向关联Jackson 序列化时无限循环。这不是 DDD 本身的问题是把「内部领域模型」和「对外响应模型」混用的结果。解决Controller 里绝不直接返回实体必须转成 DTO/VO。在代码里体现为 response 包里定义LeaveRequestDTO只包含当前请求方需要的字段。真需要返回实体时在实体字段上加JsonIgnore也不能完全规避风险——正确选择永远是 DTO 隔离。5.2 事务边界放错位置领域事件还没发就提交了现象审批通过后要发送站内信通知申请人但在approve()方法里直接调消息服务结果事务回滚时消息已经发出去了出现「审批失败但邮件已发」。原因把外部副作用塞进了领域逻辑。领域层不应该知道消息中间件的存在事务回滚之后领域方法已经执行完错误代码把外部调用和业务逻辑耦合在同一个事务里。解决Transactional只放在应用层方法上。聚合根里产生「领域事件」比如LeaveApprovedEvent应用层在事务提交后发布事件。用 Spring 的话最简单的方法是Service public class LeaveAppService { Transactional public void approve(Long requestId, Long approverId, String comment) { // 1. 加载聚合 LeaveRequest leaveRequest leaveRequestRepository.findById(requestId); // 2. 执行业务 leaveRequest.approve(approverId, comment); // 3. 保存 leaveRequestRepository.save(leaveRequest); // 4. 事务提交后再发送事件或使用 ApplicationEventPublisher 配合 TransactionalEventListener EventPublisher.publish(new LeaveApprovedEvent(requestId, approverId)); } }TransactionalEventListener的phase AFTER_COMMIT是标准解法让外部通知在真正提交后执行。记住一句话领域方法里只做业务决策不做 IO 副作用。5.3 仓储实现里拿到 Lazy 属性的序列化问题现象在RestController里调用leaveRequestRepository.findById()拿到LeaveRequest对象然后遍历它关联的leaveItems报了LazyInitializationException。原因如果用的是 JPA/Hibernate聚合根里的关联集合是延迟加载的离开事务上下文后再访问集合Session 已经关闭。MyBatis 如果不做嵌套查询也可能在二次查询时出现空指针。解决在领域模型设计时明确「聚合根加载必须完整」使用EntityGraph或显式 JOIN FETCH 让整个聚合在一次查询里加载完成仓储接口约定返回的聚合根是完整的。不要用懒加载来缓解性能问题——懒加载会破坏聚合的一致性边界。5.4 状态机校验只写了一半过于分散导致漏状态现象请假单从「已驳回」可以直接被调成「审批中」理由是「重新提交」。但代码只允许DRAFT状态才能submit()。原因新增「驳回后重新提交」需求时改代码的人只在 Controller 加了一个判断「如果状态是 REJECTED 就先改成 DRAFT 再调 submit」结果这只是临时绕过校验state 的流转图在代码里并不存在。解决状态流转不要靠业务代码里零散的 if 判断建议把submit()、resubmit()、approve()、reject()、cancel()全部写进聚合根并把校验集中到私有方法validateStateTransition(LeaveStatus from, LeaveStatus to)。用一张EnumMap维护允许流转的矩阵非法流转直接抛异常。这块代码虽然绕但后续维护需求变更的时候你只需要改矩阵而不是满仓库搜if(status 。5.5 把 DTO、DO、Entity 三个类互相转换的逻辑写散现象代码库里到处都是new LeaveRequestDO()然后setXxx()的复制粘贴代码块改动一个字段要全局搜索改造。原因这是 Java 后端在老项目里最常见的技术债。手写转换代码虽然直观但实体字段一旦增加所有转换代码都要跟着改很容易漏。解决用 MapStruct 定义转换器接口。LeaveRequestMapper.INSTANCE.toDO(leaveRequest)一行就能搞定编译期生成实现代码。映射不到的字段会编译报错这反而是一种保护。代码里的toDO()方法放在实体类里只是一种过渡做法真实业务代码里建议全部抽到infrastructure.converter包中。6. 验证 DDD 工程是否合格用单元测试倒逼业务规则收口最后这节分享一个实践习惯拿到任何一份 DDD 架构源码不看业务代码先看它的单元测试怎么写的。测试风格直接反映作者的领域理解深度。如果测试类只测 Controller 返回了什么 HTTP 状态码说明领域层大概率是空的如果测试的焦点集中在LeaveRequest.submit()在非草稿状态时抛异常在timeRange无效时抛异常这个工程才算真有骨架。写请假审批系统的验证可以用 JUnit 5 AssertJ关键是绕开 Spring 容器的测试方法class LeaveRequestTest { Test void submit_whenStatusIsApproving_shouldThrow() { LeaveRequest leaveRequest LeaveRequest.builder() .status(LeaveStatus.APPROVING) .build(); assertThrows(IllegalStateException.class, leaveRequest::submit); } Test void approve_whenPeriodIsInvalid_shouldThrow() { LeaveRequest leaveRequest new LeaveRequest(); leaveRequest.setStatus(LeaveStatus.DRAFT); leaveRequest.setTimeRange(new LeaveTimeRange( LocalDate.now().minusDays(1), LocalDate.now().minusDays(3))); assertThrows(IllegalArgumentException.class, () - leaveRequest.submit()); } }这两个测试完全不依赖 Spring 上下文跑得极快。你会发现测试写起来这么顺手的根本原因在于聚合根把业务规则收口了——业务逻辑如果没有封装在实体方法里这种测试根本无从下手只能去 init 一个巨大的 mock 环境。写测试的过程中能反向暴露代码的坏味道如果为了构造一个干净的聚合根需要 set 六个字段说明实体缺少有语义的构造工厂用静态工厂方法替代 builder 是更 DDD 的风格。对这份源码做二次开发时我还有一个习惯在同级目录放一个「读码笔记」文件夹把上面这些分层依赖关系和状态机流转画成文字版不用 UML 工具直接用缩进和箭头符号。下次再回到这个工程改需求打开笔记三分钟就能定位修改点落在哪一层比重新翻代码快多了。尤其 Java 工程师常年面对这类业务系统源码靠的从来不是一次看懂全部而是快速定位「这一改动该动哪一层」。愿你这份请假审批系统源码也能成为你理解 DDD 的第一个扎实样例或者是你在面试里讲清「领域规则如何收口」的最佳素材希望帮到你。本文还有配套的精品资源点击获取
返回列表