加载中...

本篇要搞懂的 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 招式。后面的每一节,基本就是在逐个拆解这张表里的某一行:

请假微服务用到的 DDD 设计思想总览

简单说就是这几块:聚合里的对象怎么管(聚合根/实体/值对象)、数据怎么初始化和落库(工厂+仓储)、聚合之间怎么解耦领域事件怎么处理四层架构怎么协作服务怎么分层和编排DTO/DO/PO 三种对象怎么转微服务之间怎么调用。下面按这个顺序往下走。

一个聚合里有哪些代码对象

请假微服务里有 3 个聚合:leave(请假)、person(人员)、rule(审批规则)。leave 是核心聚合,请假申请和审核的核心逻辑都在它这儿;person 管人和上下级关系;rule 是个单实体聚合,只负责查审批规则。

先把"领域对象"和"代码类"对上号——这是这一节的钥匙:

领域概念(黑话) 代码里是什么 在 leave 聚合里的例子 大白话
聚合根(Aggregate Root) 一个 class,是聚合的"老大" Leave(请假单) 这个聚合对外的唯一入口,外面只能找它办事
实体(Entity) 一个 class,有唯一 ID、生命周期 ApprovalInfo(审批意见) 有身份、会变化的东西
值对象(Value Object) 一个 class,没独立 ID、不可改、整体替换 ApplicantApprover 一组属性打包,描述"是什么样",不关心"是哪一个"
枚举值对象 enum StatusLeaveTypeApprovalType 就那么几种固定取值

💡 注意一个细节: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/基本参数,不传整个实体/值对象。反例是把整个 leaveApplicant 对象传给别的聚合,正解是拆成 personType / leaveType / durationpersonId 这样的散参。目的还是同一个——为将来拆微服务留后路。

拆微服务时,代码到底要改哪儿

这是这篇最有价值的一段:如果前面解耦做对了,拆微服务时领域层几乎不动,只改应用层那一行跨聚合调用。

假设 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 代写时最需要练的。
公告栏
这是我的个人知识库。
记录技术,也记录生活 —— 读过的、试过的、想明白的,都堆在这儿。
最新文章
网站资讯
文章数目 :
5
已运行时间 :
本站总字数 :
15.7k
本站访客数 :
本站总访问量 :
最后更新时间 :
全局知识图谱
当前页面 已访问 文章 标签
ESC 关闭 · 滚轮缩放 · 拖拽移动 · Ctrl+G 开关