本篇要搞懂的 4 件事:前面学的"领域对象(聚合根 / 实体 / 值对象)"在 Java 代码里到底长什么样、领域服务和应用服务各写什么、工厂+仓储怎么把内存对象(DO)和数据库对象(PO)互相转、为什么从一开始就要"为将来拆微服务"留后路(聚合解耦)。
⚠️ 这是一篇代码实战加餐。前面 07/08/13/14 讲的都是"架构模型怎么画",全是图和概念;这一篇作者带着你走读真实的请假微服务 Java 代码,把那些画在图上的层和对象,一行一行落到
class上。看的时候别背代码,重点是看每个类对应图上的哪个格子。
承接前文:08 讲我们看明白了"领域层稳、应用层挡变、依赖一律向内"这套架构图;13/14 讲(事件风暴 + 微服务设计)把"在线请假考勤"这个例子拆出了请假和考勤两个微服务。这一篇就拿其中的请假微服务开刀,告诉你:那张分层图里的每一层,对应到代码里就是一个个 Java 类。技术栈是 Java + Spring Boot + PostgreSQL。
08 讲:分层架构图(领域层 / 应用层 / 用户接口层 / 基础层)
13/14 讲:事件风暴 → 拆出"请假微服务",里面有 3 个聚合
↓ 这一篇:把图落成代码
请假微服务代码 = 聚合对象 + 领域服务 + 应用服务 + 仓储/工厂 + 用户接口层
🪞 辅助理解(架构图 vs 代码):前面的架构图像是房子的设计图纸(哪儿是承重墙、哪儿是门);这一篇是带你进装修好的房子里转一圈,指着每面墙说"这就是图纸上那道承重墙"。图纸看一百遍,不如进屋摸一次。
这篇用到了哪些 DDD 设计思想
作者先给了一张总览表,列出请假微服务里用到的所有 DDD 招式。后面的每一节,基本就是在逐个拆解这张表里的某一行:
简单说就是这几块:聚合里的对象怎么管(聚合根/实体/值对象)、数据怎么初始化和落库(工厂+仓储)、聚合之间怎么解耦、领域事件怎么处理、四层架构怎么协作、服务怎么分层和编排、DTO/DO/PO 三种对象怎么转、微服务之间怎么调用。下面按这个顺序往下走。
一个聚合里有哪些代码对象
请假微服务里有 3 个聚合:leave(请假)、person(人员)、rule(审批规则)。leave 是核心聚合,请假申请和审核的核心逻辑都在它这儿;person 管人和上下级关系;rule 是个单实体聚合,只负责查审批规则。
先把"领域对象"和"代码类"对上号——这是这一节的钥匙:
| 领域概念(黑话) | 代码里是什么 | 在 leave 聚合里的例子 | 大白话 |
|---|---|---|---|
| 聚合根(Aggregate Root) | 一个 class,是聚合的"老大" |
Leave(请假单) |
这个聚合对外的唯一入口,外面只能找它办事 |
| 实体(Entity) | 一个 class,有唯一 ID、生命周期 |
ApprovalInfo(审批意见) |
有身份、会变化的东西 |
| 值对象(Value Object) | 一个 class,没独立 ID、不可改、整体替换 |
Applicant、Approver |
一组属性打包,描述"是什么样",不关心"是哪一个" |
| 枚举值对象 | enum |
Status、LeaveType、ApprovalType |
就那么几种固定取值 |
💡 注意一个细节:
Applicant(申请人)和Approver(审批人)这俩值对象,数据其实来自 person 聚合——从 person 那儿查到人,再把 personId / personName / level 几个字段拎出来,重新拼成 leave 自己的值对象。作者强烈建议这种"从别处来、只读不改、可复用"的对象优先设计成值对象,能省掉一堆数据库表关联,性能也好。
聚合根:充血模型,自己干自己的活
聚合根 Leave 里既有属性、又有对值对象/实体的引用,还有自己的业务方法。这种"数据和行为放一起"的写法叫充血模型(贫血模型则是只有 getter/setter、逻辑全在别处)。
public class Leave {
String id;
Applicant applicant; // 值对象引用
Approver approver; // 值对象引用
LeaveType type;
Status status;
Date startTime;
Date endTime;
long duration;
int leaderMaxLevel; // 审批领导的最高级别
ApprovalInfo currentApprovalInfo; // 实体引用
List<ApprovalInfo> historyApprovalInfos; // 实体列表
// ↓ 自己的业务行为,简单的、只动自己的逻辑
public long getDuration() {
return endTime.getTime() - startTime.getTime();
}
public Leave create() {
this.setStatus(Status.APPROVING);
this.setStartTime(new Date());
return this;
}
// 其它方法...
}
🪞 辅助理解(充血 vs 贫血):贫血模型像个只装数据的快递盒,所有操作得请别人来拆来装;充血模型像个会自己干活的机器人——“算我自己的请假时长”"把我自己置成审批中"这种只跟自己有关的简单逻辑,写在自己身上。
那什么时候不写在自己身上?当一个动作要同时摆弄好几个对象时,就交给下面要讲的"领域服务"。
领域服务:管"多个对象一起干"的复杂逻辑
实体方法 vs 领域服务,区别就一句话:
| 实体方法 | 领域服务 | |
|---|---|---|
| 管多少对象 | 只动单个实体自己 | 要组合多个实体/聚合 |
| 复杂度 | 简单原子逻辑(算时长、改状态) | 相对复杂的业务流程(创建+发事件+落库) |
| 代码位置 | 写在实体 class 里 |
单独的 XxxDomainService 类 |
一个聚合配一个领域服务类。leave 聚合的就是 LeaveDomainService,里面用到了一堆 DDD 模式:工厂建对象、仓储落库、领域事件保证最终一致性。看它的 createLeave:
public class LeaveDomainService {
@Autowired EventPublisher eventPublisher;
@Autowired LeaveRepositoryInterface leaveRepositoryInterface; // 仓储接口
@Autowired LeaveFactory leaveFactory; // 工厂
@Transactional
public void createLeave(Leave leave, int leaderMaxLevel, Approver approver) {
leave.setLeaderMaxLevel(leaderMaxLevel);
leave.setApprover(approver);
leave.create(); // 调聚合根自己的方法
leaveRepositoryInterface.save(leaveFactory.createLeavePO(leave)); // DO→PO 落库
LeaveEvent event = LeaveEvent.create(LeaveEventType.CREATE_EVENT, leave);
leaveRepositoryInterface.saveEvent(leaveFactory.createLeaveEventPO(event)); // 事件落库
eventPublisher.publish(event); // 发事件
}
}
💡 领域服务开发的红线(很重要,关系到将来拆微服务):在领域服务/实体方法里,尽量别去引用别的聚合的对象、别去调别的聚合的领域服务。现在同一个微服务里跑没问题,但哪天 person 和 leave 拆成两个微服务,这种"跨聚合的对象引用"立刻变成"跨微服务调用"——全部失效,得返工重构。
作者给了反例和正解,核心就是传参别传整个对象,传 ID 就行:
// ❌ 反例:把 leave 聚合的 Approver 对象,传进 person 聚合的领域服务
public Approver findNextApprover(Approver currentApprover, int leaderMaxLevel) { ... }
// ✅ 正解:只传一个 ID 字符串,person 聚合不依赖 leave 的任何对象
public Person findNextApprover(String currentApproverId, int leaderMaxLevel) { ... }
🪞 辅助理解(为什么传 ID 不传对象):传整个对象,像是把你家钥匙串整串塞给邻居——他用顺手了,你俩就分不开家了。传 ID,像只告诉邻居你家门牌号,他要进门自己拿自己的钥匙开。哪天搬家(拆微服务),互不牵连。
领域事件:业务发生了什么,记下来 + 广播出去
请假单创建、审批通过/驳回,这些"发生了的事"就是领域事件。作者把聚合内的事件代码统一放在 event/ 目录。
事件类用继承:基类 DomainEvent(事件 ID、时间戳、事件源、业务数据),子类 LeaveEvent 扩展自己的字段:
public class DomainEvent {
String id;
Date timestamp;
String source;
String data; // 业务数据,存 JSON/XML 字符串
}
public class LeaveEvent extends DomainEvent {
LeaveEventType leaveEventType;
public static LeaveEvent create(LeaveEventType eventType, Leave leave) {
LeaveEvent event = new LeaveEvent();
event.setId(IdGenerator.nextId());
event.setLeaveEventType(eventType);
event.setTimestamp(new Date());
event.setData(JSON.toJSONString(leave)); // 把整个 leave 序列化进 data
return event;
}
}
领域事件的标准四步(前面 createLeave 里就是这个套路):
| 步骤 | 干什么 | 对应代码 |
|---|---|---|
| 1 | 执行业务逻辑,产生事件 | leave.create() / LeaveEvent.create(...) |
| 2 | 业务数据落库 | leaveRepositoryInterface.save(...) |
| 3 | 事件数据落库 | leaveRepositoryInterface.saveEvent(...) |
| 4 | 发布事件 | eventPublisher.publish(event) |
💡 为什么事件也要单独落库(
LeaveEventPO)?为了对账。订阅方可能没收到、处理失败,靠两边持久化的数据比对,找出异常、补偿处理,保证最终一致性。这是分布式系统里"事件丢了怎么办"的标准答案。
仓储模式:领域层不碰数据库,只认接口
领域对象(DO)最终得存进数据库。DDD 不让领域层直接写 SQL,而是中间垫一层仓储(Repository),实现依赖倒置——领域层只依赖一个接口,具体怎么存是基础层的事。换数据库时只换仓储实现,上层业务代码一行不动。
这里有个关键的对象转换:DO ↔ PO。
| 对象 | 全称 | 是什么 | 活在哪一层 |
|---|---|---|---|
| DO | Domain Object | 领域对象,内存里带业务行为的对象(如 Leave) |
领域层 |
| PO | Persistent Object | 持久化对象,跟数据库表结构一一对应(如 LeavePO) |
基础层 |
作者为了少建表,把 leave 实体和它的几个值对象压平塞进一个 LeavePO——比如 Applicant 值对象在 PO 里展开成 applicantId / applicantName / applicantType 三个字段:
public class LeavePO {
@Id
@GenericGenerator(name="idGenerator", strategy="uuid")
@GeneratedValue(generator="idGenerator")
String id;
String applicantId; // ← Applicant 值对象被压平成几个字段
String applicantName;
@Enumerated(EnumType.STRING) PersonType applicantType;
String approverId;
String approverName;
@Enumerated(EnumType.STRING) LeaveType leaveType;
@Enumerated(EnumType.STRING) Status status;
Date startTime;
Date endTime;
long duration;
@Transient List<ApprovalInfoPO> historyApprovalInfoPOList;
}
仓储分接口 + 实现两块。接口面向领域服务(领域层看到的只有它):
public interface LeaveRepositoryInterface {
void save(LeavePO leavePO);
void saveEvent(LeaveEventPO leaveEventPO);
LeavePO findById(String id);
List<LeavePO> queryByApplicantId(String applicantId);
List<LeavePO> queryByApproverId(String approverId);
}
实现在基础层,真正调 DAO(这里用 JPA)干活:
@Repository
public class LeaveRepositoryImpl implements LeaveRepositoryInterface {
@Autowired LeaveDao leaveDao;
@Autowired ApprovalInfoDao approvalInfoDao;
@Autowired LeaveEventDao leaveEventDao;
public void save(LeavePO leavePO) {
leaveDao.save(leavePO);
approvalInfoDao.saveAll(leavePO.getHistoryApprovalInfoPOList());
}
// findById / queryByApplicantId / queryByApproverId ...
}
public interface LeaveDao extends JpaRepository<LeavePO, String> {
List<LeavePO> queryByApplicantId(String applicantId);
List<LeavePO> queryByApproverId(String approverId);
}
🪞 辅助理解(依赖倒置 + 仓储):把仓储接口想成插座标准(国标三孔)。领域层是电器,它只认插座标准,根本不在乎墙后面接的是火电还是水电。哪天把数据库从 PostgreSQL 换成别的(换了发电厂),只要插座标准没变,电器照常用——这就是"换基础资源不动业务代码"。
工厂模式:复杂对象的"组装车间"
聚合根 + 实体 + 值对象之间关系一复杂,用构造函数 new 一层层手搓就很难看。DDD 引入工厂(Factory),把"创建一整套对象"的复杂活封装起来。工厂和仓储常常成对出现:
- 初始化:从数据库拿到 PO → 工厂一把构建出整个聚合的 DO(
getLeave) - 持久化:把整个聚合的 DO → 工厂一把转成 PO(
createLeavePO)
public class LeaveFactory {
// DO → PO(落库前)
public LeavePO createLeavePO(Leave leave) {
LeavePO leavePO = new LeavePO();
leavePO.setId(UUID.randomUUID().toString());
leavePO.setApplicantId(leave.getApplicant().getPersonId());
leavePO.setApplicantName(leave.getApplicant().getPersonName());
leavePO.setApproverId(leave.getApprover().getPersonId());
// ... 把值对象的字段一个个搬到 PO 上
return leavePO;
}
// PO → DO(初始化时)
public Leave getLeave(LeavePO leavePO) {
Leave leave = new Leave();
Applicant applicant = Applicant.builder()
.personId(leavePO.getApplicantId())
.personName(leavePO.getApplicantName())
.build();
leave.setApplicant(applicant);
// ... 反向把 PO 字段重新拼回值对象
return leave;
}
}
💡 一句话理清工厂 vs 仓储:工厂管"对象怎么拼/拆"(DO↔PO 的转换逻辑),仓储管"数据怎么进出数据库"(真正的存和查)。领域服务里那行
leaveRepositoryInterface.save(leaveFactory.createLeavePO(leave)),就是先让工厂把 DO 转成 PO,再交给仓储存——两者接力。
服务的组合与编排:应用层很"薄"
到了应用层。它的活是编排——把各个聚合的领域服务按业务流程串起来,自己不写业务逻辑。以"创建请假单"为例,三步跨了三个聚合:
| 步骤 | 找哪个聚合 | 调哪个领域服务 | 拿到什么 |
|---|---|---|---|
| 1 | rule | approvalRuleDomainService.getLeaderMaxLevel(...) |
审批要到的最高领导级别 |
| 2 | person | personDomainService.findFirstApprover(...) |
第一个审批人 |
| 3 | leave | leaveDomainService.createLeave(...) |
创建并保存请假单 |
public class LeaveApplicationService {
@Autowired LeaveDomainService leaveDomainService;
@Autowired PersonDomainService personDomainService;
@Autowired ApprovalRuleDomainService approvalRuleDomainService;
public void createLeaveInfo(Leave leave) {
// 1. 从 rule 聚合拿审批规则
int leaderMaxLevel = approvalRuleDomainService.getLeaderMaxLevel(
leave.getApplicant().getPersonType(), leave.getType().toString(), leave.getDuration());
// 2. 从 person 聚合拿审批人(注意:传的是 personId,不是整个对象)
Person approver = personDomainService.findFirstApprover(
leave.getApplicant().getPersonId(), leaderMaxLevel);
// 3. 调 leave 聚合创建请假单
leaveDomainService.createLeave(leave, leaderMaxLevel, Approver.fromPerson(approver));
}
// 其余方法都只有一两行,直接转发给领域服务...
}
💡 看出来没——应用服务代码非常少,几乎每个方法就一两行转发。这正是 08 讲说的"应用层薄、领域层厚":核心逻辑都沉到领域层去复用了,应用层只负责按前端需求重新排列组合,所以前端怎么变,改这层就行,碰不到稳定的领域层。
⚠️ 这里又重复了那条红线:编排时跨聚合调用一律传 ID/基本参数,不传整个实体/值对象。反例是把整个
leave或Applicant对象传给别的聚合,正解是拆成personType / leaveType / duration或personId这样的散参。目的还是同一个——为将来拆微服务留后路。
拆微服务时,代码到底要改哪儿
这是这篇最有价值的一段:如果前面解耦做对了,拆微服务时领域层几乎不动,只改应用层那一行跨聚合调用。
假设 leave 和 person 要拆成两个微服务。拆之前 createLeaveInfo 里这行是进程内调用:
Person approver = personDomainService.findFirstApprover(leave.getApplicant().getPersonId(), leaderMaxLevel);
拆之后它得变成跨微服务调用(用 Feign 走 HTTP),并加一个组装器把对方返回的 DTO 转回值对象:
// 改成调远程 person 微服务的 Facade 接口
PersonResponse approverResponse = personFeignService.findFirstApprover(
leave.getApplicant().getPersonId(), leaderMaxLevel);
Approver approver = ApproverAssembler.toDO(approverResponse); // DTO → 值对象
| 拆分时谁要动 | 改动量 |
|---|---|
| leave / person 的领域层代码 | 基本不动(因为早就解耦了) |
| leave 的应用层 | 把 xxxDomainService 改成 xxxFeignService,加一个组装器 + DTO |
| person 微服务 | 若领域服务已封装成 Facade 接口 → 啥都不用改,发布到 API 网关即可 |
🪞 辅助理解(为什么改动这么小):因为跨聚合调用全发生在应用层这一个地方(领域层早被禁止跨聚合了)。所以拆分时"出血点"只有应用层那一行,像换插头——电器(领域层)整个不用动,只把墙上那个插头从"内线"换成"外线"。这就是前面反复强调"传 ID 不传对象"的回报。
服务接口的提供:用户接口层 + DTO/DO/PO 三种对象
最外面是用户接口层,前端和微服务之间的桥。核心是 Facade(门面)接口 + Assembler(组装器)+ DTO。
public class LeaveApi {
@PostMapping
public Response createLeaveInfo(LeaveDTO leaveDTO) {
Leave leave = LeaveAssembler.toDO(leaveDTO); // 进来:DTO → DO
leaveApplicationService.createLeaveInfo(leave);
return Response.ok();
}
// 出去时:DO → DTO
}
到这儿,DDD 里三种对象就集齐了,串成一条完整的数据流水线:
| 对象 | 全称 | 在哪一层 | 干嘛用 | 谁负责转换 |
|---|---|---|---|---|
| DTO | Data Transfer Object | 用户接口层 ↔ 前端 | 按前端展示需求定制,不暴露后端逻辑 | Assembler(组装器) |
| DO | Domain Object | 领域层 | 带业务行为的核心对象 | —(核心,居中) |
| PO | Persistent Object | 基础层 ↔ 数据库 | 对应数据库表结构 | Factory(工厂)/仓储 |
🪞 辅助理解(DTO/DO/PO 三道关):把一次请求想成过海关。前端递进来的是 DTO(旅客填的入境单)→ 组装器在前门把它翻译成 DO(系统内部认的身份)→ 业务在领域层用 DO 干活 → 要落库时工厂把 DO 翻译成 PO(数据库认的档案格式)。每跨一层就翻译一次,好处是任何一层格式变了都不会传染到别层——前端字段改了只动 DTO,数据库表改了只动 PO,中间的核心业务(DO)岿然不动。
💡 这正好呼应总结里那句话:微服务内有两个缓冲区——前端和应用层之间靠 Assembler(DTO↔DO)缓冲,领域层和基础层之间靠仓储/工厂(DO↔PO)缓冲。两个缓冲区把善变的前端和善变的数据库都挡在外面,护住中间稳定的领域逻辑。
总结
这一篇把前面一直在画的架构图,落成了能跑的 Java 代码。一句话主线:领域对象(聚合根/实体/值对象)写成 class → 领域服务管复杂逻辑、实体方法管自己的简单逻辑 → 仓储+工厂做 DO↔PO 转换并落库 → 应用层薄薄一层做编排 → 用户接口层用 Assembler 做 DO↔DTO 转换对外。
贯穿全文的真正主角是解耦,而且是为"将来拆微服务"提前解耦:
- 聚合之间解耦——跨聚合一律传 ID/基本参数,不传整个对象,也不互调对方领域服务。回报是拆微服务时领域层几乎不动,只改应用层一行调用。
- 层与层之间解耦——靠两个缓冲区:前端↔应用层用 Assembler(DTO↔DO),领域层↔基础层用仓储/工厂(DO↔PO)。前端变、数据库变,都传不到核心领域层。
DDD 不用一口气全上,挑适合自己项目的招式逐步引入即可。
一句话速记
领域对象写成 class、领域服务管复杂、仓储工厂转 DO↔PO、应用层只编排、接口层 Assembler 转 DO↔DTO;跨聚合永远传 ID 不传对象,这样将来拆微服务只改应用层一行。
思考题
你现在写代码基本靠 AI 代写。那能不能反过来,把这套 DDD 分层结构变成给 AI 的"脚手架指令",让它照着生成、而不是随手写成一锅面条代码?
- 试着给 AI 一份"约束清单":每个聚合必须有 聚合根/领域服务类/仓储接口+实现/工厂/应用服务/Facade+Assembler 这几类文件,跨聚合调用只许传 ID——看它能不能稳定产出符合分层的代码骨架。
- 你最该警惕的是:业务规则有没有被 AI 顺手写进了 Facade / 应用层?(这就是领域层被架空、退化回三层的信号——业务逻辑散在接口层而没沉到领域层,后面很难维护。)
- 拿这篇的"反例 vs 正解"去喂 AI:让它先生成一版"传整个对象"的耦合代码,再让它重构成"传 ID"的解耦版——你能不能一眼看出它改对了没?这种"会审而非会写"的能力,正是你靠 AI 代写时最需要练的。

