DDD落地之后:类爆炸从哪来,到哪里去?

0 阅读15分钟

你拆掉了一个臃肿的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 CreateOrderAppService10个类

一个“下单”功能,从1个类变成了10个。如果系统有10个这样的核心功能,类数量就从10个变成了100个。

问题不是DDD本身,而是落地的方式。

当团队把DDD的每一个概念都“物化”为一个Java类时,类爆炸就开始了。DDD教我们“领域建模”,但没教我们“什么时候该停”——于是我们为每个概念建了一个类。

具体来说,有三种爆炸形态,几乎每个DDD项目都会踩中其中至少两个。

二、三种类爆炸形态:认清它们的真面目

形态一:继承树上的组合爆炸

症状:你开始看到这样的类名——UrgentSmsMessageNormalWechatMessageUrgentEmailMessageUrgentWechatMessage……每增加一个消息渠道或消息级别,类就成倍增长。

场景还原

你的消息推送系统最初支持“短信”和“邮件”两种渠道。后来业务方说:“消息要分‘普通’和‘加急’。”于是你建了四个类:

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实体,对应了UserListDtoUserDetailDtoUserCreateDtoUserUpdateDtoUserExportDtoUserImportDto……每个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爆炸

症状:项目里充斥着UserValidatorOrderAmountCalculatorPaymentStatusHandlerPointsRuleEngine……每个类只有一两个方法,每个方法只有十几行代码。问团队为什么这么设计,回答是:“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)。

不是每个维度变化都要创建新的子类。通过组合,把不同维度解耦,让它们各自变化,再在运行时组合起来。

核心思想:把“消息内容”和“发送方式”分开。“发送方式”是可插拔的,不绑定在某个具体消息类上。

实操

还是那个消息推送的例子。消息类型(短信、微信、邮件)和发送方式(同步、异步、批量)是两个独立维度。

错误做法(继承)UrgentSmsMessageNormalSmsMessageUrgentWechatMessage……类数量 = 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. “属于某个实体的行为” ——比如校验用户状态、计算用户积分、扣减余额
  2. “跨多个实体的协调” ——比如扣库存然后生成订单、支付成功然后更新订单状态

第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。

用大白话问自己三个问题:

  1. 这个逻辑是“User自己该知道的”吗?→ 放User里
  2. 这个逻辑是“Order自己该知道的”吗?→ 放Order里
  3. 这个逻辑需要同时操作多个聚合根吗?→ 才考虑Domain Service

五、三个预警信号:什么时候该停下来审视“类太多了”?

不是每个项目都需要在第一个版本就把所有模式套上。以下三个信号,提醒你该收手了:

信号危险程度你该做什么
一个实体对应了5个以上的DTO🔴 高危立即考虑用视图模式替代
一个实体对应的Service类超过3个🟡 中危把逻辑下沉到实体内部
新增一个功能,要先新建5个以上的类🟡 中危停下来,看看是不是在通过“加法”满足需求,而“乘法”才是真正的问题

记住这个原则:按需创建,绝不提前设计。

  • 把逻辑从Service移到实体,不是一次性重构,是渐进式演进
  • 当同一逻辑在≥2个Service中出现时,再提取到实体里
  • 当同一个实体需要在≥3个场景以不同字段呈现时,再引入视图机制
  • 当系统确实存在≥2个独立变化维度时,再考虑桥接模式

六、核心总结:一张表讲透三类爆炸

如果你没时间读完全文,读完这张表就够了。

问题表象根因解法核心思路
继承树爆炸UrgentSmsNormalWechatUrgentEmail... 类名越来越长多个变化维度被绑定在继承中桥接模式M×N → M+N
DTO爆炸UserListDtoUserDetailDtoUserCreateDto... 每个场景一个类用“物理隔离”处理“逻辑差异”视图模式26个DTO → 1个类
Service爆炸UserValidatorPointsCalculatorBalanceDeductService...逻辑没有归属到实体充血模型逻辑下沉,Service只做跨聚合协调

最后说一个可能得罪人的判断

DDD落地后的类爆炸,90%不是DDD的问题,是你的问题。

  • 是你把“领域服务”当成了每个小逻辑的容器
  • 是你把DTO当成了字段差异的唯一解
  • 是你把继承当成了多维度变化的唯一路
  • 是你把DDD的每个概念都“物化”成了一个Java类

DDD给你的是一把刀。有人用它切菜,有人用它自残。

如果你正在做一个CRUD系统,三层架构够用了,别碰DDD。
如果你正在做一个复杂业务系统,做好砍掉一半类的准备再去碰DDD。

好的设计不是在增加类,而是在消灭不必要的类。

复杂度守恒定律告诉我们:每个系统都有其固有的、无法简化的复杂度。但我们可以选择让这份复杂度待在该待的地方——内聚在实体里,而不是散落在成百上千个零碎的类中。

当你在代码里写下第一个“把逻辑从Service移到实体”的重构时,你其实是在用行动说:我知道这个职责属于谁,以及它应该待在哪里。