Note 11. 后端开发新范式:DDD 领域驱动设计实战与业务逻辑重构

第 11 章 后端开发新范式:DDD 领域驱动设计思想与重构策略

摘要:在上一章掌握了分层架构和对象流转后,你可能发现了一个问题:Service 层依然是一个 “巨无霸”,充斥着各种 if-else 和业务计算,而实体类(PO)只是一个装满 getter/setter 的 “数据袋”。一个订单状态变更可能需要 Service 中 100 行代码来校验,而 Order 对象本身却对业务规则一无所知。本章将引入 DDD(领域驱动设计) 的核心思想,教你如何通过 充血模型值对象聚合根 让代码从 “数据的搬运工” 升级为 “业务的表达者”。我们不讲学术化的 DDD 理论,而是专注于在 Spring Boot + MyBatis-Plus 架构下的务实落地。


11.1 痛点诊断:当 Service 层成为 “大杂烩”

11.1.1 一个失控的用户注册方法

让我们看一个真实的用户注册 Service 方法(省略部分代码):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
@Service
public class UserServiceImpl implements UserService {

@Override
@Transactional
public Long register(UserRegisterDTO dto) {
// 1. 校验用户名是否已存在
if (userMapper.existsByUsername(dto.getUsername())) {
throw new BusinessException("用户名已存在");
}

// 2. 校验邮箱是否已被注册
if (userMapper.existsByEmail(dto.getEmail())) {
throw new BusinessException("邮箱已被注册");
}

// 3. 校验密码强度(10+行代码)
String password = dto.getPassword();
if (password.length() < 8) throw ...;
if (!password.matches(".*[A-Z].*")) throw ...;
if (!password.matches(".*[a-z].*")) throw ...;
if (!password.matches(".*\\d.*")) throw ...;

// 4. 校验手机号格式
if (!dto.getPhone().matches("^1[3-9]\\d{9}$")) {
throw new BusinessException("手机号格式错误");
}

// 5. 创建用户对象
User user = new User();
user.setUsername(dto.getUsername());
user.setPassword(passwordEncoder.encode(password));
user.setEmail(dto.getEmail());
user.setPhone(dto.getPhone());
user.setStatus(1); // 魔法数字
user.setCreateTime(LocalDateTime.now());

// 6. 插入数据库
userMapper.insert(user);

// 7. 发送欢迎邮件
mailService.sendWelcomeEmail(user.getEmail());

// 8. 发送欢迎短信
smsService.sendWelcomeSms(user.getPhone());

return user.getId();
}
}

这个方法已经有 60+行代码,而且还只是注册功能!

11.1.2 贫血模型的七宗罪

让我们剖析这段代码暴露的问题:

mermaid-diagram (10)

核心问题:我们把领域对象(User)当成了 “数据库表的映射”,而不是 “业务概念的载体”。

11.1.3 对比:数据驱动 vs 领域驱动

维度数据驱动思维(贫血模型)领域驱动思维(充血模型)
设计起点数据库表有哪些字段业务领域中 “用户” 是什么概念
对象定位数据的容器业务的载体
逻辑位置全部在 Service 中在实体内部
状态保护无(可以随意修改)有(通过方法修改)
可测试性需要 Mock 很多依赖实体可独立测试
可读性Service 方法冗长实体方法表达业务

思维转变

1
2
3
4
5
❌ 错误思维:
t_user表 → User类 → Service实现业务逻辑

✅ 正确思维:
业务中"用户"的概念 → User实体(包含业务行为) → Service协调流程

11.2 核心战术一:值对象——消灭原始类型偏执

在业务开发中,我们经常使用 String, Integer, BigDecimal 等 Java 内置类型来表示具有明确业务含义的数据,例如手机号、邮箱、地址、金额等。这种做法被称为 原始类型偏执(Primitive Obsession)。它虽然简单直接,但会给系统埋下诸多隐患。

11.2.1 原始类型的三大困境

困境一:缺乏语义

代码本身无法清晰地表达业务含义。String 究竟是手机号还是邮箱?类型系统无法提供任何帮助。

1
2
3
4
5
6
7
8
// ❌ 这些字段在类型系统中没有区别
private String phone;
private String email;
private String address;

// ❌ 编译器无法阻止错误的赋值
// 这是一个合法的赋值操作,但业务上是完全错误的
user.setPhone("this-is-an-email@example.com");

困境二:无法自我校验

校验逻辑被迫散落在系统的各个角落,导致代码重复、难以维护,且容易遗漏。

1
2
3
4
5
6
7
8
9
// ❌ 校验逻辑分散在各处,形成大量重复代码
// 在注册时校验
if (!dto.getPhone().matches("^1[3-9]\\d{9}$")) throw ...;

// 在修改资料时,又要重复一遍
if (!dto.getPhone().matches("^1[3-9]\\d{9}$")) throw ...;

// 在管理员添加用户时,可能又忘了写
adminService.createUser(dto); // 糟糕,一个格式错误的手机号被创建了!

困境三:缺乏业务行为

相关的数据和行为是割裂的。数据(如手机号字符串)本身无法携带业务操作,我们不得不编写大量的工具类或在 Service 中堆砌零散的业务逻辑。

1
2
3
4
5
6
7
8
9
// ❌ 无法封装业务方法
String phone = "13800138000";
// 想要获取区号?想要脱敏显示?只能在 Service 中编写工具方法
String maskedPhone = PhoneUtils.mask(phone);

// ❌ 金额计算繁琐且易错
BigDecimal price = new BigDecimal("10.50");
BigDecimal quantity = new BigDecimal("3");
BigDecimal total = price.multiply(quantity); // 繁琐,且容易出现精度问题

11.2.2 值对象的威力:现代且优雅的实现

为了解决以上困境,我们引入 值对象(Value Object)

值对象是一个没有唯一标识的、不可变 的对象,其核心是通过对象的属性值来识别和比较。它能够将数据和逻辑封装在一起,让代码回归面向对象的本质。

特征说明好处
无标识通过属性值(equals())比较相等性两个值为 “13800138000” 的手机号对象是完全等价的
不可变一旦创建,其内部状态不可修改线程安全,可放心传递,避免意外的副作用
自我校验在构造时就保证其合法性杜绝了非法数据在系统中流转的可能性
富含行为自身携带相关的业务方法phone.mask() 返回脱敏号码,高内聚,易复用

有人可能会问:“为了一个字段就要创建一个类,还要写构造函数、getter、equals、hashCode,这真的优雅吗?” 这个问题问得非常好。在现代 Java 开发中,我们借助 Lombok 这样的开源库,可以极大地消除样板代码,让值对象的创建变得异常简洁。


11.2.3 重构实战:Lombok + MyBatis-Plus

我们将通过一个完整的重构流程,展示如何在标准的 Spring Boot 项目中,使用 Lombok 和 MyBatis-Plus 优雅地应用值对象。

项目结构概览

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
src/main/java/com/example/
├── domain/ # 领域层: 存放核心业务逻辑和模型
│ ├── model/ # 可以叫 model, objects, core ...
│ │ ├── PhoneNumber.java # DDD 的值对象放在这里
│ │ └── Money.java # 或者更明确叫 valueobject/

├── entity/ # 持久化实体 (PO - Persistent Object)
│ └── User.java

├── dto/ # DTO 层: 用于各层之间数据传输
│ ├── UserRegistrationDto.java # 用于接收Controller层的数据
│ └── UserProfileDto.java # 用于Service层向Controller层传输数据

├── vo/ # VO 层: 明确用于前端视图展示
│ └── UserProfileVo.java # Controller返回给前端的最终结构

├── handler/ # MyBatis-Plus 类型处理器
├── mapper/ # ...
├── service/ # ...
└── controller/ # ...

第一步:定义值对象

使用 Lombok 的 @Value 注解,我们可以一行搞定不变性、构造函数、getter、equals()hashCode()toString()

src/main/java/com/example/domain/vo/PhoneNumber.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
import lombok.Value; // 引入 lombok.Value

@Value // 👈 就是这一个注解!
public class PhoneNumber implements Serializable {
String value; // 自动成为 private final

// private 的构造函数由 @Value 自动生成,我们只需提供静态工厂方法
public static PhoneNumber of(String value) {
if (value == null || !value.matches("^1[3-9]\\d{9}$")) {
throw new BusinessException("手机号格式错误");
}
return new PhoneNumber(value);
}

// 可以在值对象中融入业务逻辑
public String mask() {
return value.substring(0, 3) + "****" + value.substring(7);
}
}

看到了吗? Lombok 让我们只专注于核心的校验和业务逻辑,彻底消除了手动编写样板代码的烦恼。

src/main/java/com/example/domain/vo/Money.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
import lombok.Value;
import java.math.BigDecimal;
import java.math.RoundingMode;

@Value
public class Money implements Serializable, Comparable<Money> {
Long cents; // 使用 "分" 存储,避免精度问题

public static Money ofYuan(BigDecimal yuan) {
if (yuan == null || yuan.compareTo(BigDecimal.ZERO) < 0) {
throw new BusinessException("金额不能为负数");
}
return new Money(yuan.multiply(new BigDecimal("100")).longValue());
}

// --- 丰富的业务行为 ---
public Money add(Money other) {
return new Money(this.cents + other.cents);
}

public Money multiply(int quantity) {
return new Money(this.cents * quantity);
}

@Override
public int compareTo(Money other) {
return this.cents.compareTo(other.cents);
}

// ... 其他业务方法
}

第二步:创建类型处理器 (一次性投入)

为了让 MyBatis-Plus 能够理解我们的值对象,需要创建一个 TypeHandler。这是一个一次性的投入,一旦创建,所有使用 PhoneNumber 的地方都能复用。

src/main/java/com/example/handler/PhoneNumberTypeHandler.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
import com.example.domain.vo.PhoneNumber;
import org.apache.ibatis.type.BaseTypeHandler;
import org.apache.ibatis.type.JdbcType;
import org.apache.ibatis.type.MappedJdbcTypes;
import org.apache.ibatis.type.MappedTypes;

import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.SQLException;

@MappedTypes(PhoneNumber.class)
@MappedJdbcTypes(JdbcType.VARCHAR)
public class PhoneNumberTypeHandler extends BaseTypeHandler<PhoneNumber> {

@Override
public void setNonNullParameter(PreparedStatement ps, int i, PhoneNumber p, JdbcType jt) throws SQLException {
ps.setString(i, p.getValue());
}

@Override
public PhoneNumber getNullableResult(ResultSet rs, String columnName) throws SQLException {
String value = rs.getString(columnName);
return value == null ? null : PhoneNumber.of(value);
}

// ... 其他 getNullableResult 方法实现
}

第三步:改造实体类并全局配置

改造实体

src/main/java/com/example/entity/User.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
import com.baomidou.mybatisplus.annotation.TableName;
import com.example.domain.vo.PhoneNumber;

// autoResultMap = true 必须开启,以启用 TypeHandler
@TableName(value = "t_user", autoResultMap = true)
public class User {
private Long id;
private String username;

// 直接使用 PhoneNumber 类型,无需注解
private PhoneNumber phone;

// ... getter/setter
}

全局配置 (推荐)

为了避免在每个字段上都写 @TableField(typeHandler=...),我们进行全局配置,让 MyBatis-Plus 自动扫描。

application.yml

1
2
3
mybatis-plus:
# 扫描 TypeHandler 所在的包,一劳永逸
type-handlers-package: com.example.handler

11.2.4 重构后的惊艳效果

在业务代码中,我们现在直接与有明确业务含义的 PhoneNumber 对象交互,代码的清晰度和安全性得到了质的飞跃。

src/main/java/com/example/service/impl/UserServiceImpl.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
@Service
public class UserServiceImpl extends ServiceImpl<UserMapper, User> implements IUserService {

@Override
public void register(UserRegistrationDto dto) {
// 1. 创建值对象,校验在构造时自动完成,失败则直接抛异常
PhoneNumber phone = PhoneNumber.of(dto.getPhone());

User user = new User();
user.setUsername(dto.getUsername());
// 2. 赋给实体的是一个 100%合法的、富含行为的 PhoneNumber 对象
user.setPhone(phone);

this.save(user);
}

@Override
public UserProfileVo getUserProfile(Long userId) {
User user = this.getById(userId);
// ...

// 3. 直接调用值对象的业务方法,代码清晰且内聚
PhoneNumber phone = user.getPhone();
// phone 不可能为 null 或格式错误 (除非数据库原始数据有问题)
vo.setMaskedPhone(phone.mask()); // 输出 "138 **** 8000"

return vo;
}
}

通过 值对象(思想) + Lombok(实现) + TypeHandler(集成) 的现代组合拳,我们完美地解决了“原始类型偏执”问题。

  • 这优雅吗? 非常优雅。因为真正的优雅并非代码行数最少,而是 业务意图最清晰、代码最安全、维护成本最低。Lombok 的引入,则解决了实践中最大的痛点——样板代码。
  • 职责单一与高内聚:校验、格式化等逻辑从分散的 Service 层,回归到它本该属于的数据自身(值对象)中,实现了真正的高内聚。
  • 让类型成为防线:我们创建了能够自我保护的强类型,让类型系统成为保护业务规则的第一道防线,极大地减少了运行时出错的可能。

11.3 核心战术二:充血实体——让对象 “活” 起来

11.3.1 贫血 vs 充血:对象的两种人生

贫血模型(Anemic Model)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// 实体只是数据容器
@Data
public class User {
private Long id;
private String username;
private String password;
private Integer status; // 魔法数字
}

// 所有逻辑在 Service
public void disableUser(Long userId) {
User user = userMapper.selectById(userId);
if (user.getStatus() == 2) {
throw new BusinessException("用户已被禁用");
}
user.setStatus(2); // 直接修改
userMapper.updateById(user);
}

充血模型(Rich Model)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// 实体包含业务行为
@Getter
public class User {
private Long id;
private String username;
private String password;
private UserStatus status; // 枚举,不是 Integer

// 业务方法:禁用用户
public void disable() {
if (this.status == UserStatus.DISABLED) {
throw new BusinessException("用户已被禁用");
}
this.status = UserStatus.DISABLED;
this.clearToken(); // 禁用时清空令牌
}
}

// Service 只负责协调
public void disableUser(Long userId) {
User user = userMapper.selectById(userId);
user.disable(); // 业务逻辑在实体内部
userMapper.updateById(user);
}

对比

维度贫血模型充血模型
对象定位数据容器业务对象
逻辑位置Service 中实体内部
Service 行数10+ 行3 行
状态保护无(可随意修改)有(只能通过方法修改)
可复用性逻辑分散,难以复用逻辑内聚,易于复用

11.3.2 充血实体的设计原则

核心原则“谁的数据,谁负责维护”

mermaid-diagram (11)

设计要点

要点说明示例
封装状态字段用 private,不提供 setter不允许外部直接 user.setStatus(2)
提供业务方法通过方法修改状态提供 user.disable() 方法
自我校验方法内部校验前置条件disable() 检查当前状态
保持不变性通过业务方法保证对象始终合法不会出现 status = null 的情况

11.3.3 实战案例:用户实体重构

重构前(贫血模型)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
@Data
public class User {
private Long id;
private String username;
private String password;
private Integer status;
}

// Service 中的密码修改逻辑
public void changePassword(Long userId, String oldPwd, String newPwd) {
User user = userMapper.selectById(userId);

// 校验旧密码
if (!BCrypt.checkpw(oldPwd, user.getPassword())) {
throw new BusinessException("原密码错误");
}

// 校验新密码强度(10+行代码)
if (newPwd.length() < 8) throw ...;
if (!newPwd.matches(".*[A-Z].*")) throw ...;
if (!newPwd.matches(".*[a-z].*")) throw ...;
if (!newPwd.matches(".*\\d.*")) throw ...;

// 加密并保存
user.setPassword(BCrypt.hashpw(newPwd));
userMapper.updateById(user);
}

重构后(充血模型)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
@Getter
public class User {
private Long id;
private String username;
private String password;
private UserStatus status; // 枚举

// 业务方法:修改密码
public void changePassword(String oldPassword, String newPassword) {
// 1. 校验旧密码
if (!BCrypt.checkpw(oldPassword, this.password)) {
throw new BusinessException("原密码错误");
}
// 2. 校验新密码强度(私有方法)
validatePasswordStrength(newPassword);
// 3. 加密并保存
this.password = BCrypt.hashpw(newPassword);
}

// 业务方法:禁用用户
public void disable() {
if (this.status == UserStatus.DISABLED) {
throw new BusinessException("用户已被禁用");
}
this.status = UserStatus.DISABLED;
}

// 私有校验方法
private void validatePasswordStrength(String password) {
if (password.length() < 8) throw ...;
if (!password.matches(".*[A-Z].*")) throw ...;
// ...
}
}

// Service 变得极其简洁
public void changePassword(Long userId, String oldPwd, String newPwd) {
User user = userMapper.selectById(userId);
user.changePassword(oldPwd, newPwd); // 所有逻辑在实体内部
userMapper.updateById(user);
}

11.3.4 工厂方法:控制对象创建

传统方式创建对象容易遗漏必要字段:

1
2
3
4
5
6
// ❌ 容易遗漏字段
User user = new User();
user.setUsername("test");
// 忘记设置 password
// 忘记设置 status
userMapper.insert(user); // 插入了不完整的数据

使用工厂方法保证对象完整性:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
@Getter
public class User {
// 私有构造函数,外部无法直接 new
private User() {}

// 工厂方法:创建新用户
public static User create(String username, String rawPassword, String email) {
User user = new User();
user.username = username;
user.password = BCrypt.hashpw(rawPassword); // 自动加密
user.email = email;
user.status = UserStatus.NORMAL; // 默认状态
user.createTime = LocalDateTime.now();
return user; // 保证返回的对象是完整的
}
}

// 使用工厂方法
User user = User.create("zhangsan", "password123", "zhang@example.com");
// 对象一定是完整且合法的

11.4 核心战术三:聚合根——保护业务不变性

11.4.1 什么是聚合根?

聚合(Aggregate) 是一组相关对象的集合,作为一个整体来管理。聚合根(Aggregate Root) 是聚合的入口。

经典示例:订单与订单项

mermaid-diagram (12)

核心原则

原则说明好处
单一入口外部只能通过 Order 访问 OrderItem控制访问路径
维护不变性Order 负责保证数据一致性添加商品时自动重算总价
事务边界一个聚合在一个事务中修改保证原子性
生命周期一致删除 Order 时级联删除 OrderItem避免孤儿数据

11.4.2 反例:没有聚合根的混乱

传统做法(错误)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// Service 直接操作 OrderItem
public void addProductToOrder(Long orderId, Long productId, Integer quantity) {
// 1. 查询订单
Order order = orderMapper.selectById(orderId);

// 2. 创建订单项(直接操作)
OrderItem item = new OrderItem();
item.setOrderId(orderId);
item.setProductId(productId);
item.setQuantity(quantity);
orderItemMapper.insert(item); // 直接插入

// 3. 手动重新计算订单总价
List<OrderItem> items = orderItemMapper.selectByOrderId(orderId);
BigDecimal total = items.stream()
.map(i -> i.getPrice().multiply(new BigDecimal(i.getQuantity())))
.reduce(BigDecimal.ZERO, BigDecimal::add);
order.setTotalAmount(total);
orderMapper.updateById(order);

// 问题:如果忘记重新计算总价,数据就不一致了!
}

暴露的问题

mermaid-diagram (13)

11.4.3 使用聚合根重构

充血模型(正确)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
@Getter
public class Order {
private Long id;
private List<OrderItem> items = new ArrayList<>();
private Money totalAmount;
private OrderStatus status;

// 工厂方法
public static Order create(Long userId) {
Order order = new Order();
order.userId = userId;
order.status = OrderStatus.PENDING_PAYMENT;
order.totalAmount = Money.zero();
return order;
}

// 聚合根方法:添加商品
public void addItem(Long productId, String name, Money price, Integer qty) {
// 1. 校验状态
if (this.status != OrderStatus.PENDING_PAYMENT) {
throw new BusinessException("只有待支付订单才能添加商品");
}

// 2. 检查是否已存在该商品
for (OrderItem item : items) {
if (item.getProductId().equals(productId)) {
item.updateQuantity(item.getQuantity() + qty); // 增加数量
recalculateTotalAmount(); // 自动重算总价
return;
}
}

// 3. 创建新订单项
OrderItem newItem = OrderItem.create(this.id, productId, name, price, qty);
items.add(newItem);

// 4. 自动重新计算总价
recalculateTotalAmount();
}

// 私有方法:重新计算总价
private void recalculateTotalAmount() {
this.totalAmount = items.stream()
.map(OrderItem::calculateSubtotal)
.reduce(Money.zero(), Money::add);
}
}

// Service 变得极其简洁
public void addProductToOrder(Long orderId, Long productId, Integer qty) {
// 1. 加载聚合
Order order = loadOrderAggregate(orderId);

// 2. 通过聚合根添加商品(自动计算总价)
Product product = productMapper.selectById(productId);
order.addItem(product.getId(), product.getName(),
Money.ofYuan(product.getPrice()), qty);

// 3. 持久化
orderMapper.updateById(order);
// ... 保存新增的订单项
}

聚合根的价值

维度没有聚合根使用聚合根
数据一致性容易忘记重新计算添加商品时自动计算
业务规则分散在 Service 中集中在 Order 中
状态保护任何状态都能添加商品只有待支付才能添加
代码行数Service 方法 30+行Service 方法 10 行

11.4.4 聚合边界的划分原则

Spring_DispatcherServlet-2025-12-17-141333

聚合划分原则

场景是否同一聚合原因
订单 & 订单项✅ 是生命周期一致,必须在同一事务中修改
订单 & 用户❌ 否独立的生命周期,通过 ID 关联
订单 & 商品❌ 否商品可以被多个订单引用
文章 & 评论看情况小型系统可同一聚合,大型系统分开

11.5 核心战术四:领域服务——跨实体的业务逻辑

11.5.1 领域服务 vs 应用服务

在 Spring 应用中,我们经常提到 “Service”,但在 DDD 中,Service 分为两种:

Spring_DispatcherServlet-2025-12-17-141440

职责对比

类型职责是否依赖基础设施示例
应用服务协调流程、事务控制、调用领域服务是(依赖 Mapper)UserServiceOrderService
领域服务纯粹的业务逻辑,不涉及持久化TransferServicePriceCalculator

判断标准

1
2
3
4
5
6
7
8
9
如果一个业务逻辑:
├── 只涉及单个实体 → 放在实体内部
│ └── 例如:user.changePassword()

├── 涉及多个实体,但有明确的主体 → 放在聚合根中
│ └── 例如:order.addItem()

└── 涉及多个实体,没有明确的主体 → 放在领域服务中
└── 例如:转账(涉及两个账户)

11.5.2 包结构规范

✅ 推荐的包结构(按聚合组织)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
com.example.project
├── domain # 领域层(核心业务)
│ ├── account # 账户聚合 ⭐
│ │ ├── entity
│ │ │ ├── Account.java
│ │ │ └── AccountId.java
│ │ ├── vo(value object)
│ │ │ ├── Money.java
│ │ │ └── AccountStatus.java
│ │ ├── service # 领域服务 ⭐
│ │ │ ├── TransferDomainService.java
│ │ │ └── InterestCalculationService.java
│ │ └── repository # 仓储接口
│ │ └── AccountRepository.java
│ │
│ ├── order # 订单聚合 ⭐
│ │ ├── entity
│ │ │ ├── Order.java
│ │ │ ├── OrderItem.java
│ │ │ └── OrderId.java
│ │ ├── vo
│ │ │ └── OrderStatus.java
│ │ ├── service # 领域服务 ⭐
│ │ │ ├── PriceCalculationService.java
│ │ │ └── DiscountService.java
│ │ └── repository
│ │ └── OrderRepository.java
│ │
│ └── shared # 共享内核(跨聚合使用)
│ ├── vo
│ │ ├── Money.java # 通用值对象
│ │ └── Address.java
│ └── exception
│ └── BusinessException.java

├── application # 应用层(协调层)
│ ├── service # 应用服务 ⭐
│ │ ├── AccountServiceImpl.java
│ │ └── OrderServiceImpl.java
│ └── dto # 数据传输对象
│ ├── TransferRequestDTO.java
│ └── OrderCreateDTO.java

└── infrastructure # 基础设施层
├── persistence
│ ├── mapper
│ │ ├── AccountMapper.java
│ │ └── OrderMapper.java
│ └── po # 持久化对象
│ ├── AccountPO.java
│ └── OrderPO.java
└── config
└── MybatisPlusConfig.java

11.5.3 领域服务的 10 大注意事项

1. 禁止依赖基础设施

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// ❌ 错误:领域服务依赖 Mapper
@Service
public class TransferDomainService {
@Autowired
private AccountMapper accountMapper; // ❌ 领域服务不能依赖 Mapper

public void transfer(Long fromId, Long toId, Money amount) {
Account from = accountMapper.selectById(fromId); // ❌ 破坏了领域层的纯粹性
// ...
}
}

// ✅ 正确:通过应用服务传入已加载的实体
@Service
public class TransferDomainService {
// ✅ 不依赖任何基础设施

public Money transfer(Account from, Account to, Money amount) {
// 纯业务逻辑
from.debit(amount);
to.credit(amount);
return calculateFee(amount);
}
}

2. 命名要体现业务含义

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// ❌ 错误:技术导向的命名
public class AccountDomainService { // 太宽泛
public void handle() { } // 含义不明
}

// ✅ 正确:业务导向的命名
public class TransferDomainService { // 明确转账业务
public Money transfer() { } // 动词+名词
}

public class PriceCalculationService { // 明确价格计算
public Money calculate() { }
}

public class DiscountService { // 明确折扣业务
public Money applyDiscount() { }
}

3. 单一职责原则

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// ❌ 错误:一个领域服务承担太多职责
public class AccountDomainService {
public void transfer() { } // 转账
public void withdraw() { } // 取款
public void calculateInterest() { } // 计息
public void freeze() { } // 冻结
// 太多职责,难以维护
}

// ✅ 正确:按业务能力拆分
public class TransferDomainService {
public Money transfer(Account from, Account to, Money amount) { }
}

public class WithdrawalDomainService {
public void withdraw(Account account, Money amount) { }
}

public class InterestCalculationService {
public Money calculate(Account account, LocalDate date) { }
}

4. 无状态设计

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// ❌ 错误:领域服务有状态
@Service
public class TransferDomainService {
private Money totalFee; // ❌ 实例变量(有状态)

public void transfer(Account from, Account to, Money amount) {
this.totalFee = calculateFee(amount); // ❌ 多线程不安全
from.debit(amount.add(totalFee));
}
}

// ✅ 正确:无状态设计
@Service
public class TransferDomainService {
// ✅ 没有实例变量

public Money transfer(Account from, Account to, Money amount) {
Money fee = calculateFee(amount); // ✅ 局部变量
from.debit(amount.add(fee));
to.credit(amount);
return fee;
}
}

5. 避免循环依赖

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
// ❌ 错误:循环依赖
@Service
public class OrderDomainService {
@Autowired
private PaymentDomainService paymentService; // 依赖 Payment
}

@Service
public class PaymentDomainService {
@Autowired
private OrderDomainService orderService; // 依赖 Order(❌ 循环)
}

// ✅ 正确:重构为单向依赖或提取第三方服务
@Service
public class OrderDomainService {
// 不依赖 Payment
}

@Service
public class PaymentDomainService {
@Autowired
private OrderDomainService orderService; // 单向依赖
}

// 或者提取第三方服务
@Service
public class OrderPaymentDomainService { // 新的领域服务
public void processOrderPayment(Order order, Payment payment) {
// 协调 Order 和 Payment
}
}

6. 不要加 @Transactional

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// ❌ 错误:领域服务控制事务
@Service
public class TransferDomainService {
@Transactional // ❌ 领域服务不应控制事务
public void transfer(Account from, Account to, Money amount) {
from.debit(amount);
to.credit(amount);
}
}

// ✅ 正确:应用服务控制事务
@Service
public class AccountServiceImpl {
@Transactional // ✅ 应用服务控制事务
public void transfer(Long fromId, Long toId, BigDecimal amount) {
Account from = accountMapper.selectById(fromId);
Account to = accountMapper.selectById(toId);

transferDomainService.transfer(from, to, Money.ofYuan(amount));

accountMapper.updateById(from);
accountMapper.updateById(to);
}
}

7. 返回值要有意义

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
// ❌ 错误:返回 void 或布尔值
public class TransferDomainService {
public void transfer(Account from, Account to, Money amount) {
// ❌ 手续费信息丢失了
}

public boolean canTransfer(Account account, Money amount) {
return account.getBalance().isGreaterThan(amount);
// ❌ 无法知道为什么不能转账
}
}

// ✅ 正确:返回有意义的业务对象
public class TransferDomainService {
public TransferResult transfer(Account from, Account to, Money amount) {
Money fee = calculateFee(amount);
from.debit(amount.add(fee));
to.credit(amount);

return new TransferResult(amount, fee, LocalDateTime.now());
}
}

// 结果对象
public class TransferResult {
private final Money amount;
private final Money fee;
private final LocalDateTime timestamp;

// 构造方法、getter...
}

8. 异常处理要明确

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
// ❌ 错误:吞掉异常或抛出技术异常
public class TransferDomainService {
public void transfer(Account from, Account to, Money amount) {
try {
from.debit(amount);
} catch (Exception e) {
e.printStackTrace(); // ❌ 吞掉异常
}
}
}

// ✅ 正确:抛出业务异常
public class TransferDomainService {
public Money transfer(Account from, Account to, Money amount) {
// 检查业务规则
if (from.isFrozen()) {
throw new AccountFrozenException("账户已冻结,无法转账");
}

if (amount.isGreaterThan(DAILY_LIMIT)) {
throw new TransferLimitExceededException("超过单日转账限额");
}

// 业务逻辑
Money fee = calculateFee(amount);
from.debit(amount.add(fee));
to.credit(amount);

return fee;
}
}

9. 测试要覆盖核心逻辑

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// ✅ 领域服务天生易于测试(不依赖数据库)
@Test
public void testTransfer() {
// 准备测试数据
Account from = new Account(new Money(1000));
Account to = new Account(new Money(500));

// 调用领域服务
TransferDomainService service = new TransferDomainService();
Money fee = service.transfer(from, to, new Money(100));

// 断言
assertEquals(new Money(899), from.getBalance()); // 1000 - 100 - 1(手续费)
assertEquals(new Money(600), to.getBalance()); // 500 + 100
assertEquals(new Money(1), fee);
}

// ❌ 如果领域服务依赖 Mapper,测试会很痛苦
@Test
public void testTransfer() {
// 需要 Mock Mapper、启动 Spring 容器等
// ...
}

10. 何时不需要领域服务

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// ❌ 过度设计:简单逻辑抽成领域服务
public class UserDomainService {
public void changePassword(User user, String newPassword) {
user.setPassword(newPassword); // 太简单,不需要领域服务
}
}

// ✅ 直接在实体中处理
public class User {
public void changePassword(String newPassword) {
if (newPassword.length() < 6) {
throw new IllegalArgumentException("密码长度不能少于6位");
}
this.password = passwordEncoder.encode(newPassword);
}
}

// ✅ 只有在涉及多个实体或复杂计算时才需要领域服务
public class OrderDomainService {
public Money calculateTotalPrice(Order order, List<Coupon> coupons, VipLevel vipLevel) {
// 复杂的价格计算逻辑(涉及订单、优惠券、会员等级)
// ...
}
}

11.5.4 领域服务 vs 应用服务对比总结

mermaid-diagram (16)

对比维度应用服务领域服务
包位置application.servicedomain.{聚合}.service
职责协调流程、事务控制、数据转换纯业务逻辑、业务规则
依赖可依赖 Mapper、外部服务只依赖实体和值对象
事务@Transactional无事务注解
测试需要 Mock 基础设施直接单元测试
复用面向用例,复用性低面向业务能力,复用性高
示例AccountServiceImplTransferDomainService

11.6 MyBatis-Plus 下的 DDD 落地策略

在 MyBatis-Plus 生态中落地 DDD,核心挑战在于如何处理 领域对象(Domain Entity)持久化对象(Persistence Object/PO) 的关系。我们不能为了追求 DDD 的“纯洁性”而抛弃 MP 的便利性,也不能为了便利性而写成“贫血模型”。

根据业务复杂度和团队习惯,我们将落地策略分为三个层级:

mermaid-diagram-2025-12-21-201639

11.6.1 策略一:务实的融合(单对象模式)

适用场景:业务逻辑相对简单,团队追求开发效率,不需要严格的代码防腐层。
核心思想Domain Entity = Persistence Object。也就是让同一个 Java 类既承担映射数据库的职责,又承担业务行为的职责。

落地实操

  1. 实体定义:使用 MP 注解(@TableName)映射数据库。
  2. 拒绝贫血:不要只写 Getter/Setter,将业务行为(校验、状态变更)写入实体类。
  3. 防腐处理:对于不该持久化到数据库的字段,使用 @TableField(exist = false)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
// 📂 domain/entity/User.java

@Getter // 尽量减少 Setter 的使用,保护内部状态
@TableName("t_user")
public class User {

@TableId(type = IdType.AUTO)
private Long id;

private String username;

private String password;

// 状态:0-冻结,1-激活
private Integer status;

// ❌ 错误做法:Service 层写 logic: if(user.getStatus()==0) { user.setStatus(1); }

// ✅ 正确做法:充血模型(Rich Domain Model)
public void activate() {
if (Objects.equals(this.status, 1)) {
throw new BusinessException("用户已处于激活状态");
}
this.status = 1;
// 可以在此发布领域事件,例如 DomainEventPublisher.publish(new UserActivatedEvent(this.id));
}

public void changePassword(String oldPwd, String newPwd) {
if (!this.password.equals(oldPwd)) {
throw new BusinessException("旧密码错误");
}
this.password = newPwd;
}

// 基础设施层字段,不参与持久化
@TableField(exist = false)
private List<Role> roles;
}

Service 层调用

1
2
3
4
5
6
7
8
9
10
11
12
@Service
public class UserService {
@Autowired UserMapper userMapper;

public void activeUser(Long userId) {
User user = userMapper.selectById(userId);
// 调用领域行为
user.activate();
// 显式调用持久化
userMapper.updateById(user);
}
}

11.6.2 策略二:值对象的引入(增强模式)

适用场景:中等复杂度系统。业务中存在大量标准化的概念(如金额、地址、联系方式、坐标),需要提高代码的复用性和表达力。
核心思想实体 + 值对象(Value Object)。利用 MyBatis-Plus 的 TypeHandler 机制,将复杂的业务概念封装为“值对象”,平铺或序列化存储在数据库中,但在代码中以对象形式存在。

落地实操

  1. 定义值对象:例如 Money(包含金额和币种)、Address(省市区详细地址)或 PhoneNumber
  2. 配置 MP 映射:使用 @TableField(typeHandler = ...) 将数据库字段转换为对象。

代码演示

1. 定义值对象 (Value Object)

1
2
3
4
5
6
7
8
9
10
11
12
13
// 值对象是不可变的
@Data
@AllArgsConstructor
public class ContactInfo {
private String email;
private String phone;

// 值对象内部的自我校验
public void validate() {
if (!phone.startsWith("1")) throw new IllegalArgumentException("手机号格式错误");
}
}

2. 实体类集成

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
@TableName(value = "t_user", autoResultMap = true) // 必须开启 autoResultMap
public class User {
@TableId
private Long id;

// 数据库中可能是 JSON 字段,或者通过自定义 Handler 映射
@TableField(typeHandler = JacksonTypeHandler.class)
private ContactInfo contactInfo;

// 业务方法
public void updateContact(String newEmail, String newPhone) {
ContactInfo newContact = new ContactInfo(newEmail, newPhone);
newContact.validate(); // 校验逻辑内聚
this.contactInfo = newContact;
}
}

优势

  • 类型安全:避免了 String phone, String email 满天飞。
  • 逻辑内聚:手机号格式校验逻辑在 ContactInfo 里,而不是散落在 Service 层。

11.6.3 策略三:完全解耦(Repository 模式)

适用场景:核心复杂业务系统(如电商交易核心、银行账务)。数据库表结构与业务模型差异巨大,或者需要保持领域层的纯净性(不依赖任何 MP 注解)。
核心思想领域模型与持久化模型分离。通过 Repository(仓储)层进行转译。Domain 层只看得到纯净的 Entity,Infrastructure 层负责将 Entity 转换为 PO(Persistence Object)并写入数据库。

架构分层

  1. Domain Layer: User (纯 POJO), UserRepository (接口)。
  2. Infrastructure Layer: UserPO (带 MP 注解), UserMapper (MP 接口), UserRepositoryImpl (实现类)。

代码演示

1. 纯净的领域实体 (Domain)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// 不依赖 MyBatis-Plus 任何包
public class User {
private UserId id; // 强类型 ID
private String name;

// 纯业务逻辑
public void register() { ... }
}

// 仓储接口
public interface UserRepository {
void save(User user);
User find(UserId id);
}

2. 持久化对象 (Infrastructure)

1
2
3
4
5
6
7
8
9
@TableName("t_user")
@Data
public class UserPO { // 专门用于映射数据库表
@TableId
private Long id;
private String name;
private Date gmtCreate;
}

3. 仓储实现 (Infrastructure)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
@Repository
public class UserRepositoryImpl implements UserRepository {

@Autowired private UserMapper userMapper; // MP 的 Mapper
@Autowired private UserConverter converter; // 转换器(如 MapStruct)

@Override
public void save(User user) {
// 1. Domain -> PO
UserPO po = converter.toPO(user);

// 2. 使用 MP 进行操作
if (po.getId() == null) {
userMapper.insert(po);
// 回填 ID 到领域对象(如果需要)
user.setId(new UserId(po.getId()));
} else {
userMapper.updateById(po);
}
}

@Override
public User find(UserId id) {
// 1. MP 查询 PO
UserPO po = userMapper.selectById(id.getValue());
// 2. PO -> Domain
return converter.toEntity(po);
}
}


11.6.4 总结与建议

在 MyBatis-Plus 环境下,没有“唯一正确”的 DDD 落地方式,只有“最适合当前业务”的方式。

特性策略一:务实融合策略二:值对象增强策略三:完全解耦
模型数量1 (Entity 即 PO)1+N (Entity + VOs)2 (Entity + PO)
MP 注解侵入高 (直接在实体上)中 (实体+TypeHandler)无 (仅在 PO 上)
代码复杂度⭐ 低⭐⭐ 中⭐⭐⭐⭐ 高
性能损耗低 (序列化开销)中 (对象转换开销)
推荐阶段初创期、非核心模块发展期、标准化模块核心域、复杂业务逻辑

专家建议

大多数互联网业务系统,推荐使用“策略二”。它在保持了开发效率(避免写繁琐的转换器)的同时,引入了值对象来吸纳大部分复杂的业务规则,是 MyBatis-Plus 与 DDD 结合的最佳平衡点。只有在极少数逻辑极其复杂、且需要长期维护的核心域(Core Domain),才建议上“策略三”。

11.7 DDD 应用决策指南

11.7.1 何时使用 DDD?

image-20251221203109662

决策表

项目类型是否推荐 DDD推荐策略原因
简单 CRUD 系统❌ 不推荐贫血模型业务规则简单,DDD 是过度设计
后台管理系统⚠️ 可选关键模块使用充血模型大部分是简单操作
电商系统✅ 推荐充血模型+聚合根订单、库存有复杂规则
金融系统✅✅ 强烈推荐全套 DDD 战术模式涉及资金,业务规则严格
内容管理系统⚠️ 可选审核流程用充血模型发布审核有状态流转

11.7.2 渐进式应用 DDD

不要一次性重构整个系统,而是渐进式应用:

image-20251221203210610

实施建议

阶段重构范围风险收益
阶段 1识别核心领域低(只是分析)建立领域知识
阶段 2引入值对象消除原始类型偏执
阶段 3充血实体Service 层瘦身
阶段 4聚合根数据一致性保障
阶段 5领域服务逻辑复用

11.7.3 核心避坑指南

diagram-2025-12-22

避坑速查表

陷阱现象对策
过度设计简单 CRUD 也用全套 DDD根据复杂度选择策略
实体依赖基础设施User 中注入 UserMapper实体只包含业务逻辑
值对象可变Money 提供 setter使用 final+@Getter
聚合过大Order 包含 User、Product 完整对象只保存 ID 引用
忘记事务修改聚合不加@TransactionalService 方法必加事务

11.8 本章总结

11.8.1 核心要点回顾

diagram-2025-12-22

核心收获

  1. 理解了贫血模型的问题:Service 臃肿、逻辑分散、难以维护
  2. 掌握了值对象的威力:消除原始类型偏执,类型系统保护业务规则
  3. 学会了充血实体设计:让对象拥有业务行为,Service 层瘦身
  4. 理解了聚合根的价值:保护数据一致性,维护业务不变性
  5. 区分了领域服务和应用服务:职责清晰,易于测试和复用
  6. 掌握了 MyBatis-Plus 下的落地策略:务实地应用 DDD,不做过度设计

最后的建议

DDD 不是银弹,不是所有项目都需要 DDD。关键是 根据业务复杂度选择合适的策略,从简单开始,渐进式演进。记住核心原则:让代码表达业务,而不是表达数据库