第 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) { if (userMapper.existsByUsername(dto.getUsername())) { throw new BusinessException("用户名已存在"); } if (userMapper.existsByEmail(dto.getEmail())) { throw new BusinessException("邮箱已被注册"); } 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 ...; if (!dto.getPhone().matches("^1[3-9]\\d{9}$")) { throw new BusinessException("手机号格式错误"); } 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()); userMapper.insert(user); mailService.sendWelcomeEmail(user.getEmail()); smsService.sendWelcomeSms(user.getPhone()); return user.getId(); } }
|
这个方法已经有 60+行代码,而且还只是注册功能!
11.1.2 贫血模型的七宗罪
让我们剖析这段代码暴露的问题:

核心问题:我们把领域对象(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";
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;
@Value public class PhoneNumber implements Serializable { String 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); } }
|
第三步:改造实体类并全局配置
改造实体
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;
@TableName(value = "t_user", autoResultMap = true) public class User { private Long id; private String username; private PhoneNumber phone; }
|
全局配置 (推荐)
为了避免在每个字段上都写 @TableField(typeHandler=...),我们进行全局配置,让 MyBatis-Plus 自动扫描。
application.yml
1 2 3
| mybatis-plus: 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) { PhoneNumber phone = PhoneNumber.of(dto.getPhone()); User user = new User(); user.setUsername(dto.getUsername()); user.setPhone(phone); this.save(user); }
@Override public UserProfileVo getUserProfile(Long userId) { User user = this.getById(userId); PhoneNumber phone = user.getPhone(); vo.setMaskedPhone(phone.mask()); 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; }
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; public void disable() { if (this.status == UserStatus.DISABLED) { throw new BusinessException("用户已被禁用"); } this.status = UserStatus.DISABLED; this.clearToken(); } }
public void disableUser(Long userId) { User user = userMapper.selectById(userId); user.disable(); userMapper.updateById(user); }
|
对比:
| 维度 | 贫血模型 | 充血模型 |
|---|
| 对象定位 | 数据容器 | 业务对象 |
| 逻辑位置 | Service 中 | 实体内部 |
| Service 行数 | 10+ 行 | 3 行 |
| 状态保护 | 无(可随意修改) | 有(只能通过方法修改) |
| 可复用性 | 逻辑分散,难以复用 | 逻辑内聚,易于复用 |
11.3.2 充血实体的设计原则
核心原则:“谁的数据,谁负责维护”

设计要点:
| 要点 | 说明 | 示例 |
|---|
| 封装状态 | 字段用 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; }
public void changePassword(Long userId, String oldPwd, String newPwd) { User user = userMapper.selectById(userId); if (!BCrypt.checkpw(oldPwd, user.getPassword())) { throw new BusinessException("原密码错误"); } 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) { if (!BCrypt.checkpw(oldPassword, this.password)) { throw new BusinessException("原密码错误"); } validatePasswordStrength(newPassword); 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 ...; } }
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");
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 { 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) 是聚合的入口。
经典示例:订单与订单项

核心原则:
| 原则 | 说明 | 好处 |
|---|
| 单一入口 | 外部只能通过 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
| public void addProductToOrder(Long orderId, Long productId, Integer quantity) { Order order = orderMapper.selectById(orderId); OrderItem item = new OrderItem(); item.setOrderId(orderId); item.setProductId(productId); item.setQuantity(quantity); orderItemMapper.insert(item); 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); }
|
暴露的问题:

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) { if (this.status != OrderStatus.PENDING_PAYMENT) { throw new BusinessException("只有待支付订单才能添加商品"); } for (OrderItem item : items) { if (item.getProductId().equals(productId)) { item.updateQuantity(item.getQuantity() + qty); recalculateTotalAmount(); return; } } OrderItem newItem = OrderItem.create(this.id, productId, name, price, qty); items.add(newItem); recalculateTotalAmount(); } private void recalculateTotalAmount() { this.totalAmount = items.stream() .map(OrderItem::calculateSubtotal) .reduce(Money.zero(), Money::add); } }
public void addProductToOrder(Long orderId, Long productId, Integer qty) { Order order = loadOrderAggregate(orderId); Product product = productMapper.selectById(productId); order.addItem(product.getId(), product.getName(), Money.ofYuan(product.getPrice()), qty); orderMapper.updateById(order); }
|
聚合根的价值:
| 维度 | 没有聚合根 | 使用聚合根 |
|---|
| 数据一致性 | 容易忘记重新计算 | 添加商品时自动计算 |
| 业务规则 | 分散在 Service 中 | 集中在 Order 中 |
| 状态保护 | 任何状态都能添加商品 | 只有待支付才能添加 |
| 代码行数 | Service 方法 30+行 | Service 方法 10 行 |
11.4.4 聚合边界的划分原则

聚合划分原则:
| 场景 | 是否同一聚合 | 原因 |
|---|
| 订单 & 订单项 | ✅ 是 | 生命周期一致,必须在同一事务中修改 |
| 订单 & 用户 | ❌ 否 | 独立的生命周期,通过 ID 关联 |
| 订单 & 商品 | ❌ 否 | 商品可以被多个订单引用 |
| 文章 & 评论 | 看情况 | 小型系统可同一聚合,大型系统分开 |
11.5 核心战术四:领域服务——跨实体的业务逻辑
11.5.1 领域服务 vs 应用服务
在 Spring 应用中,我们经常提到 “Service”,但在 DDD 中,Service 分为两种:

职责对比:
| 类型 | 职责 | 是否依赖基础设施 | 示例 |
|---|
| 应用服务 | 协调流程、事务控制、调用领域服务 | 是(依赖 Mapper) | UserService、OrderService |
| 领域服务 | 纯粹的业务逻辑,不涉及持久化 | 否 | TransferService、PriceCalculator |
判断标准:
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
| @Service public class TransferDomainService { @Autowired private AccountMapper accountMapper; 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; }
@Service public class PaymentDomainService { @Autowired private OrderDomainService orderService; }
@Service public class OrderDomainService { }
@Service public class PaymentDomainService { @Autowired private OrderDomainService orderService; }
@Service public class OrderPaymentDomainService { public void processOrderPayment(Order order, Payment 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
| 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; }
|
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()); assertEquals(new Money(600), to.getBalance()); assertEquals(new Money(1), fee); }
@Test public void testTransfer() { }
|
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 应用服务对比总结

| 对比维度 | 应用服务 | 领域服务 |
|---|
| 包位置 | application.service | domain.{聚合}.service |
| 职责 | 协调流程、事务控制、数据转换 | 纯业务逻辑、业务规则 |
| 依赖 | 可依赖 Mapper、外部服务 | 只依赖实体和值对象 |
| 事务 | 有 @Transactional | 无事务注解 |
| 测试 | 需要 Mock 基础设施 | 直接单元测试 |
| 复用 | 面向用例,复用性低 | 面向业务能力,复用性高 |
| 示例 | AccountServiceImpl | TransferDomainService |
11.6 MyBatis-Plus 下的 DDD 落地策略
在 MyBatis-Plus 生态中落地 DDD,核心挑战在于如何处理 领域对象(Domain Entity) 与 持久化对象(Persistence Object/PO) 的关系。我们不能为了追求 DDD 的“纯洁性”而抛弃 MP 的便利性,也不能为了便利性而写成“贫血模型”。
根据业务复杂度和团队习惯,我们将落地策略分为三个层级:

11.6.1 策略一:务实的融合(单对象模式)
适用场景:业务逻辑相对简单,团队追求开发效率,不需要严格的代码防腐层。
核心思想:Domain Entity = Persistence Object。也就是让同一个 Java 类既承担映射数据库的职责,又承担业务行为的职责。
落地实操:
- 实体定义:使用 MP 注解(
@TableName)映射数据库。 - 拒绝贫血:不要只写 Getter/Setter,将业务行为(校验、状态变更)写入实体类。
- 防腐处理:对于不该持久化到数据库的字段,使用
@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
|
@Getter @TableName("t_user") public class User { @TableId(type = IdType.AUTO) private Long id; private String username; private String password; private Integer status;
public void activate() { if (Objects.equals(this.status, 1)) { throw new BusinessException("用户已处于激活状态"); } this.status = 1; }
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 机制,将复杂的业务概念封装为“值对象”,平铺或序列化存储在数据库中,但在代码中以对象形式存在。
落地实操:
- 定义值对象:例如
Money(包含金额和币种)、Address(省市区详细地址)或 PhoneNumber。 - 配置 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) public class User { @TableId private Long id;
@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)并写入数据库。
架构分层:
- Domain Layer:
User (纯 POJO), UserRepository (接口)。 - Infrastructure Layer:
UserPO (带 MP 注解), UserMapper (MP 接口), UserRepositoryImpl (实现类)。
代码演示:
1. 纯净的领域实体 (Domain)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
| public class User { private UserId 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; @Autowired private UserConverter converter;
@Override public void save(User user) { UserPO po = converter.toPO(user); if (po.getId() == null) { userMapper.insert(po); user.setId(new UserId(po.getId())); } else { userMapper.updateById(po); } }
@Override public User find(UserId id) { UserPO po = userMapper.selectById(id.getValue()); 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?

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

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

避坑速查表:
| 陷阱 | 现象 | 对策 |
|---|
| 过度设计 | 简单 CRUD 也用全套 DDD | 根据复杂度选择策略 |
| 实体依赖基础设施 | User 中注入 UserMapper | 实体只包含业务逻辑 |
| 值对象可变 | Money 提供 setter | 使用 final+@Getter |
| 聚合过大 | Order 包含 User、Product 完整对象 | 只保存 ID 引用 |
| 忘记事务 | 修改聚合不加@Transactional | Service 方法必加事务 |
11.8 本章总结
11.8.1 核心要点回顾

核心收获:
- 理解了贫血模型的问题:Service 臃肿、逻辑分散、难以维护
- 掌握了值对象的威力:消除原始类型偏执,类型系统保护业务规则
- 学会了充血实体设计:让对象拥有业务行为,Service 层瘦身
- 理解了聚合根的价值:保护数据一致性,维护业务不变性
- 区分了领域服务和应用服务:职责清晰,易于测试和复用
- 掌握了 MyBatis-Plus 下的落地策略:务实地应用 DDD,不做过度设计
最后的建议:
DDD 不是银弹,不是所有项目都需要 DDD。关键是 根据业务复杂度选择合适的策略,从简单开始,渐进式演进。记住核心原则:让代码表达业务,而不是表达数据库。