你拆掉了一个臃肿的Service,却可能得到一个更臃肿的项目。
“一个下单功能,从1个类变成了10个。”
假设一下这样的场景,三个月前,团队决定用DDD重构系统——拆掉那个2000行的OrderService,让业务规则各归其位。所有人都觉得这是一场胜利。
三个月后,打开项目:152个类。IDE启动从3秒变成了15秒。新人入职第一周都在问同一个问题:“这些类都是干嘛的?我从哪里开始看?”
功能还没翻一倍,类先翻了四倍。
更讽刺的是,有些类只有十几行代码——甚至只有一行。就是一个构造器,就是一个只有@Override一个方法的接口实现。
DDD落地了,类爆炸了。
这不是编的。这是无数DDD项目的真实“后遗症”。
如果你的项目也正在经历同样的痛苦,这篇文章就是为你写的。
读完这篇文章,你将获得:
✅ 看清DDD项目中类爆炸的三种具体形态(继承树、DTO、Service)
✅ 用桥接模式把继承树上的M×N个类变成M+N个
✅ 用视图模式把几十个冗余DTO收拢到1个类
✅ 用充血模型把一堆Service逻辑塞回实体,让类数量砍半
✅ 学会三个预警信号,知道什么时候该停下来审视“类太多了”
一、从“拆Service”到“类爆炸”:发生了什么?
在前一篇文章里,我们做了一个手术:把一个超过2000行的三层Service,拆成了DDD四层架构。用充血模型让业务规则各归其位。代码变清晰了,变可测了,变好改了。
然后,另一个问题开始冒头。
我们把三层架构的一个OrderService“DDD化”之后,每一层都被“精细化”了。原来一个类能搞定的事,被拆分到了多个类和接口里:
| 原来的三层 | DDD化之后 | 类数量变化 |
|---|---|---|
OrderService(1个类) | Order实体(聚合根) OrderStatus枚举 OrderItem实体 Money值对象 OrderRepository接口 OrderRepositoryImpl CreateOrderCommand CreateOrderDto OrderAssembler CreateOrderAppService | 10个类 |
一个“下单”功能,从1个类变成了10个。如果系统有10个这样的核心功能,类数量就从10个变成了100个。
问题不是DDD本身,而是落地的方式。
当团队把DDD的每一个概念都“物化”为一个Java类时,类爆炸就开始了。DDD教我们“领域建模”,但没教我们“什么时候该停”——于是我们为每个概念建了一个类。
具体来说,有三种爆炸形态,几乎每个DDD项目都会踩中其中至少两个。
二、三种类爆炸形态:认清它们的真面目
形态一:继承树上的组合爆炸
症状:你开始看到这样的类名——UrgentSmsMessage、NormalWechatMessage、UrgentEmailMessage、UrgentWechatMessage……每增加一个消息渠道或消息级别,类就成倍增长。
场景还原:
你的消息推送系统最初支持“短信”和“邮件”两种渠道。后来业务方说:“消息要分‘普通’和‘加急’。”于是你建了四个类:
UrgentEmail(加急邮件)
NormalEmail(普通邮件)
UrgentSMS(加急短信)
NormalSMS(普通短信)
再后来,加了“微信”渠道。类变成了6个:
UrgentEmail、NormalEmail、UrgentSMS、NormalSMS、UrgentWechat、NormalWechat
再后来,加了“特急”级别。类变成了9个。
再后来,加了“站内信”渠道。类变成了12个。
类以乘法(M × N)的速度爆炸式增长。
java
// 文件: message/UrgentSmsMessage.java
public class UrgentSmsMessage extends SmsMessage {
// 加急短信——紧急程度和渠道被绑定在同一个类里
}
// 文件: message/NormalWechatMessage.java
public class NormalWechatMessage extends WechatMessage {
// 普通微信——同样是紧急程度和渠道的绑定
}
问题本质:消息的“渠道”和“紧急程度”是两个独立的维度。用继承去处理多维度变化,类的数量是各维度选项数的乘积。渠道有M种,紧急程度有N种,类数量就是M × N。
这就好比你要买一件衣服,颜色有10种,尺码有5种——如果你为每种组合都建一个独立的“商品编号”,你会得到50个编号。但现实中,你只需要一个“衣服”概念,颜色和尺码作为属性。 代码也是一样的道理。
形态二:DTO与VO的冗余膨胀
症状:一个User实体,对应了UserListDto、UserDetailDto、UserCreateDto、UserUpdateDto、UserExportDto、UserImportDto……每个DTO里的字段大部分重复,只是少了几个或多了几个。
// 文件: dto/UserListDto.java
public class UserListDto {
private Long id;
private String username;
// 列表只需要id和name,不需要密码、手机号、创建时间
}
// 文件: dto/UserDetailDto.java
public class UserDetailDto {
private Long id;
private String username;
private String phone; // 详情多了手机号
private String email; // 详情多了邮箱
private LocalDateTime createTime; // 详情多了创建时间
// 依然没有密码
}
// 文件: dto/UserCreateDto.java
public class UserCreateDto {
private String username;
private String password; // 创建需要密码
// 但不需要id、不需要createTime
}
// 文件: dto/UserUpdateDto.java
public class UserUpdateDto {
private Long id;
private String phone; // 更新允许改手机号
private String email; // 更新允许改邮箱
// 不能改密码(有独立的改密码接口)
}
字段稍微变化一下,就是一个新类。User实体的字段改了,所有DTO都要跟着改。
问题本质:用“物理隔离”(新建类)来处理“逻辑差异”(不同场景下同一实体的不同字段组合)。把差异变成了独立的代码单元,而不是同一单元内的不同配置。
形态三:过度设计的Service爆炸
症状:项目里充斥着UserValidator、OrderAmountCalculator、PaymentStatusHandler、PointsRuleEngine……每个类只有一两个方法,每个方法只有十几行代码。问团队为什么这么设计,回答是:“DDD说要分离关注点啊。”
// 文件: domain/service/UserValidator.java
public class UserValidator {
public void validateForOrder(User user, Order order) {
if (user.getStatus() != UserStatus.ACTIVE) {
throw new DomainException("用户状态异常");
}
if (user.getBalance().lessThan(order.getTotalAmount())) {
throw new DomainException("余额不足");
}
}
}
// 文件: domain/service/PointsCalculator.java
public class PointsCalculator {
public Points calculate(Order order, User user) {
int base = order.getTotalAmount().getAmount().intValue();
int multiplier = user.getLevel().getPointMultiplier();
return new Points(base * multiplier);
}
}
// 文件: domain/service/BalanceDeductService.java
public class BalanceDeductService {
public void deduct(User user, Money amount) {
if (user.getBalance().lessThan(amount)) {
throw new DomainException("余额不足");
}
user.setBalance(user.getBalance().minus(amount));
}
}
明明可以在User实体里加三个方法,却偏要建三个独立的类。
问题本质:把“属于实体自己的行为”错误地建模成了“独立的Service”。这是对“领域服务”概念的误用——领域服务是给“跨聚合的协调逻辑”准备的,不是给“实体自己的行为”准备的。
三、这三类问题,根子都在一个地方
回到源头:DDD的核心主张是“让业务规则各归其位”。但在落地过程中,我们做了一件事——把“归位”理解成了“分类” 。
- 看到“行为” → 建一个Service类
- 看到“数据变化” → 建一个DTO类
- 看到“多维度变化” → 继承出一个新类
这就像一个人去图书馆,不是按照“文学/历史/科学”来分类,而是按照“硬壳书/平装书/精装书”来分类——物理上分开了,逻辑上还是一片混乱。
类爆炸的根子只有一个:职责没有归位。
具体表现为三个层面的失位:
| 失位层面 | 表现 | 后果 |
|---|---|---|
| 变化维度没有分离 | 渠道和紧急程度绑在一个继承体系里 | 类数量是维度选项的乘积(M×N) |
| 数据与行为没有内聚 | 数据在实体里,逻辑在Service里 | 为每个小逻辑创建一个Service类 |
| 把工具当教条 | 强行使用Domain Service、DTO等模式 | 为了“符合DDD”而创造不必要的类 |
我们把“归位”理解成了“分类”——物理上分开了,逻辑上还是一团浆糊。
四、解决之道:三类爆炸的拆解方案
三类问题来自三个不同的根因,所以需要三种不同的解。但它们共享同一个指导思想——把“职责”放回它该在的地方。
解一:组合替代继承——解决继承树爆炸
方案:桥接模式(Bridge Pattern)。
不是每个维度变化都要创建新的子类。通过组合,把不同维度解耦,让它们各自变化,再在运行时组合起来。
核心思想:把“消息内容”和“发送方式”分开。“发送方式”是可插拔的,不绑定在某个具体消息类上。
实操:
还是那个消息推送的例子。消息类型(短信、微信、邮件)和发送方式(同步、异步、批量)是两个独立维度。
错误做法(继承) :UrgentSmsMessage、NormalSmsMessage、UrgentWechatMessage……类数量 = 3 × 3 = 9。
正确做法(桥接模式) :
// 文件: domain/message/MessageSender.java
// 维度一:发送方式(独立变化)
public interface MessageSender {
void send(String content, String target);
}
// 文件: infrastructure/message/SmsSender.java
@Component
public class SmsSender implements MessageSender {
@Override
public void send(String content, String phone) {
// 短信网关调用——这里可以同步发,也可以放进队列异步发
}
}
// 文件: infrastructure/message/WechatSender.java
@Component
public class WechatSender implements MessageSender {
@Override
public void send(String content, String openId) {
// 微信接口调用
}
}
// 文件: domain/message/Message.java
// 维度二:消息类型(独立变化)
public abstract class Message {
protected MessageSender sender; // 通过组合连接两个维度
public Message(MessageSender sender) {
this.sender = sender;
}
public abstract void send(String target);
}
// 文件: domain/message/SmsMessage.java
public class SmsMessage extends Message {
private String content;
public SmsMessage(MessageSender sender, String content) {
super(sender);
this.content = content;
}
@Override
public void send(String phone) {
sender.send(content, phone);
}
}
// 文件: domain/message/WechatMessage.java
public class WechatMessage extends Message {
private String content;
public WechatMessage(MessageSender sender, String content) {
super(sender);
this.content = content;
}
@Override
public void send(String openId) {
sender.send(content, openId);
}
}
效果:
- 新增渠道(如钉钉):只新增一个
Sender实现,Message体系完全不动 - 新增消息类型(如语音):只新增一个
Message子类,Sender体系完全不动 - 类数量从乘法变成了加法(M + N)
什么时候该用:当系统在两个或以上维度上变化,且各维度需要独立扩展时。判断标准:如果你发现新加一个功能需要新建多个类,说明多个维度被绑定在一起了。
解二:视图机制替代多DTO——解决DTO爆炸
方案:视图模式(View Pattern),在同一个模型上通过注解标记视图,运行时动态渲染。
核心思想:不要为“同一个事物的不同样子”创建不同的类。用标签标明“什么时候显示什么字段”,让渲染引擎去执行。
实操:
// 文件: interfaces/dto/UserResponse.java
// 一个类替代了26个冗余DTO
public class UserResponse {
// 定义视图分组
public interface ListView {} // 列表视图
public interface DetailView extends ListView {} // 详情视图
public interface AdminView extends DetailView {} // 管理员视图
@JsonView(ListView.class)
private Long id;
@JsonView(ListView.class)
private String username;
@JsonView(DetailView.class)
private String phone;
@JsonView(DetailView.class)
private String email;
@JsonView({DetailView.class, AdminView.class})
private LocalDateTime createTime;
@JsonView(AdminView.class)
private UserStatus status; // 只有管理员能看到状态
@JsonView(AdminView.class)
private String lastLoginIp; // 只有管理员能看到敏感信息
}
// 文件: interfaces/web/UserController.java
@RestController
public class UserController {
// 列表接口:只返回id和username
@GetMapping("/users")
@JsonView(UserResponse.ListView.class)
public List<UserResponse> list() {
return userAppService.listUsers()
.stream()
.map(UserResponse::fromDomain)
.collect(Collectors.toList());
}
// 详情接口:返回id、username、phone、email、createTime
@GetMapping("/users/{id}")
@JsonView(UserResponse.DetailView.class)
public UserResponse detail(@PathVariable Long id) {
return UserResponse.fromDomain(userAppService.getUser(id));
}
// 管理接口:返回全部字段(包含敏感信息)
@GetMapping("/admin/users/{id}")
@JsonView(UserResponse.AdminView.class)
public UserResponse adminDetail(@PathVariable Long id) {
return UserResponse.fromDomain(userAppService.getUser(id));
}
}
效果:
- 1个
UserResponse类替代了26个冗余DTO - User实体新增字段:只需在
UserResponse中加一个字段并标记视图,所有接口自动生效 - 所有转换逻辑收拢在
UserResponse.fromDomain()一处
什么时候该用:当同一个领域对象需要在多个场景(列表、详情、创建、更新、导出、管理)以不同字段组合呈现时。
解三:充血模型替代过度Service——解决Service爆炸
方案:把“属于实体自己的逻辑”下沉到实体内部,只把“跨聚合的协调逻辑”留在Domain Service里。
核心思想:实体不只是数据的容器,它应该对自己的行为负责。“扣余额”是User自己的事,“校验订单状态”是Order自己的事——它们不应该被外包给Service。
实操:
观察那些过度设计的Service,本质上都是在做两件事:
- “属于某个实体的行为” ——比如校验用户状态、计算用户积分、扣减余额
- “跨多个实体的协调” ——比如扣库存然后生成订单、支付成功然后更新订单状态
第1类逻辑应该下沉到实体中:
// 文件: domain/user/User.java(充血模型)
public class User {
private Long id;
private String username;
private Money balance;
private Points points;
private UserLevel level;
private UserStatus status;
// === 原本在UserValidator里的逻辑,下沉到实体 ===
public void validateForOrder(Order order) {
if (this.status != UserStatus.ACTIVE) {
throw new DomainException("用户状态异常,无法下单");
}
if (this.balance.lessThan(order.getTotalAmount())) {
throw new DomainException("余额不足,当前余额:" + this.balance);
}
}
// === 原本在PointsCalculator里的逻辑,下沉到实体 ===
public Points calculatePointsForOrder(Order order) {
int base = order.getTotalAmount().getAmount().intValue();
int multiplier = this.level.getPointMultiplier();
return new Points(base * multiplier);
}
// === 原本在BalanceDeductService里的逻辑,下沉到实体 ===
public void deductBalance(Money amount) {
if (this.balance.lessThan(amount)) {
throw new DomainException("余额不足");
}
this.balance = this.balance.minus(amount);
}
// === 原本在PointsAddService里的逻辑,下沉到实体 ===
public void addPoints(Points points) {
this.points = this.points.add(points);
}
}
// 文件: domain/order/Order.java(充血模型)
public class Order {
private Long id;
private Long userId;
private Money totalAmount;
private OrderStatus status;
private LocalDateTime payTime;
// === 原本在OrderStatusValidator里的逻辑,下沉到实体 ===
public void validateCanPay() {
if (this.status != OrderStatus.UNPAID) {
throw new DomainException("订单已支付或已取消");
}
}
// === 原本在OrderStatusUpdater里的逻辑,下沉到实体 ===
public void markAsPaid() {
this.status = OrderStatus.PAID;
this.payTime = LocalDateTime.now();
}
}
第2类逻辑(跨聚合协调)才保留为Domain Service:
// 文件: domain/service/OrderDomainService.java
// 只做“跨聚合”的协调,不写“属于某个实体”的逻辑
@Service
public class OrderDomainService {
private final OrderRepository orderRepo;
private final UserRepository userRepo;
private final InventoryService inventoryService; // 外部服务
// 跨聚合逻辑:扣库存 + 校验用户 + 创建订单
@Transactional
public Order createOrderWithInventoryCheck(Long userId, List<OrderItem> items) {
// 1. 调用商品聚合扣库存(跨聚合)
inventoryService.reserveStock(items);
// 2. 获取用户聚合
User user = userRepo.findById(userId);
// 3. 计算订单金额
Money total = calculateTotal(items);
Order order = new Order(userId, total);
// 4. 调用User的行为(不是自己写if/else)
user.validateForOrder(order);
// 5. 保存订单
orderRepo.save(order);
return order;
}
}
效果对比:
| 做法 | 类数量 | 内聚性 | 可测性 |
|---|---|---|---|
| 过度设计:UserValidator + PointsCalculator + BalanceDeductService + OrderStatusValidator... | 5个Service类 | 低(逻辑散落各处) | 需Mock多个依赖 |
| 充血模型:逻辑下沉到User和Order实体 | 2个实体类 | 高(数据和行为在一起) | 直接new对象测试 |
判断标准:如果一个逻辑只涉及一个聚合(比如只涉及User,或只涉及Order),就应该放在该聚合的实体内部。只有跨多个聚合的协调逻辑,才需要创建Domain Service。
用大白话问自己三个问题:
- 这个逻辑是“User自己该知道的”吗?→ 放User里
- 这个逻辑是“Order自己该知道的”吗?→ 放Order里
- 这个逻辑需要同时操作多个聚合根吗?→ 才考虑Domain Service
五、三个预警信号:什么时候该停下来审视“类太多了”?
不是每个项目都需要在第一个版本就把所有模式套上。以下三个信号,提醒你该收手了:
| 信号 | 危险程度 | 你该做什么 |
|---|---|---|
| 一个实体对应了5个以上的DTO | 🔴 高危 | 立即考虑用视图模式替代 |
| 一个实体对应的Service类超过3个 | 🟡 中危 | 把逻辑下沉到实体内部 |
| 新增一个功能,要先新建5个以上的类 | 🟡 中危 | 停下来,看看是不是在通过“加法”满足需求,而“乘法”才是真正的问题 |
记住这个原则:按需创建,绝不提前设计。
- 把逻辑从Service移到实体,不是一次性重构,是渐进式演进
- 当同一逻辑在≥2个Service中出现时,再提取到实体里
- 当同一个实体需要在≥3个场景以不同字段呈现时,再引入视图机制
- 当系统确实存在≥2个独立变化维度时,再考虑桥接模式
六、核心总结:一张表讲透三类爆炸
如果你没时间读完全文,读完这张表就够了。
| 问题 | 表象 | 根因 | 解法 | 核心思路 |
|---|---|---|---|---|
| 继承树爆炸 | UrgentSms、NormalWechat、UrgentEmail... 类名越来越长 | 多个变化维度被绑定在继承中 | 桥接模式 | M×N → M+N |
| DTO爆炸 | UserListDto、UserDetailDto、UserCreateDto... 每个场景一个类 | 用“物理隔离”处理“逻辑差异” | 视图模式 | 26个DTO → 1个类 |
| Service爆炸 | UserValidator、PointsCalculator、BalanceDeductService... | 逻辑没有归属到实体 | 充血模型 | 逻辑下沉,Service只做跨聚合协调 |
最后说一个可能得罪人的判断
DDD落地后的类爆炸,90%不是DDD的问题,是你的问题。
- 是你把“领域服务”当成了每个小逻辑的容器
- 是你把DTO当成了字段差异的唯一解
- 是你把继承当成了多维度变化的唯一路
- 是你把DDD的每个概念都“物化”成了一个Java类
DDD给你的是一把刀。有人用它切菜,有人用它自残。
如果你正在做一个CRUD系统,三层架构够用了,别碰DDD。
如果你正在做一个复杂业务系统,做好砍掉一半类的准备再去碰DDD。
好的设计不是在增加类,而是在消灭不必要的类。
复杂度守恒定律告诉我们:每个系统都有其固有的、无法简化的复杂度。但我们可以选择让这份复杂度待在该待的地方——内聚在实体里,而不是散落在成百上千个零碎的类中。
当你在代码里写下第一个“把逻辑从Service移到实体”的重构时,你其实是在用行动说:我知道这个职责属于谁,以及它应该待在哪里。