DDD 落地实战——为什么大多数团队"用了 DDD"但又不像 DDD
DDD 落地实战——为什么大多数团队"用了 DDD"但又不像 DDD
适读人群:正在或准备在项目中落地 DDD 的工程师、技术 TL | 阅读时长:约18分钟 | 核心价值:DDD 不是架构层级划分,而是一种把业务复杂度转化为代码可读性的思维方式
我见过的最多 DDD 误用是什么
两年前我加入了一个新项目,代码库里有这样的目录结构:
src/
├── domain/
│ ├── entity/
│ ├── repository/
│ └── service/
├── application/
│ └── service/
├── infrastructure/
│ ├── repository/
│ └── mapper/
└── interfaces/
└── controller/"我们用了 DDD",团队的人骄傲地说。
我打开 domain/entity/Order.java,里面是:
@Data
@Entity
@Table(name = "t_order")
public class Order {
private Long id;
private String userId;
private BigDecimal amount;
private Integer status;
private Date createTime;
private Date updateTime;
// 还有 20 个字段...
}再打开 domain/service/OrderService.java:
@Service
public class OrderService {
@Autowired
private OrderMapper orderMapper;
@Autowired
private UserMapper userMapper;
@Autowired
private ProductMapper productMapper;
public void createOrder(CreateOrderDTO dto) {
// 300 行的方法,什么都在这里
}
}目录结构有了,但代码和普通的三层架构没有任何区别。这不是 DDD,这是披着 DDD 外衣的贫血模型。
DDD 的本质是什么
在说"怎么用"之前,必须先说清楚"是什么"。
DDD(领域驱动设计)的核心思想只有一句话:让代码的结构和语言,反映业务的结构和语言。
这句话里有两个关键词:
结构:代码的职责划分应该和业务概念对齐,而不是和技术层次对齐。
语言:代码里的类名、方法名、变量名,应该是业务人员能看懂的,而不是 CRUD 方言(findById、updateStatus、deleteRecord)。
如果你的代码让业务人员看不懂,那 DDD 没有落地。
贫血模型 vs 充血模型
这是 DDD 落地最核心的一个概念区别。
贫血模型:实体只有数据,没有行为。行为全部在 Service 里。
// 贫血模型
public class Order {
private OrderStatus status;
// 只有 getter/setter
}
public class OrderService {
public void cancelOrder(Long orderId) {
Order order = orderMapper.findById(orderId);
if (order.getStatus() != OrderStatus.PAID) {
throw new IllegalStateException("只有已支付订单可以取消");
}
order.setStatus(OrderStatus.CANCELLED);
order.setCancelTime(new Date());
orderMapper.update(order);
}
}充血模型:实体包含自己的业务行为,Service 只协调领域对象。
// 充血模型
public class Order {
private OrderId id;
private OrderStatus status;
private Money amount;
private LocalDateTime cancelTime;
// 业务行为在实体里
public void cancel() {
if (!this.status.canCancel()) {
throw new DomainException("当前订单状态不允许取消:" + this.status);
}
this.status = OrderStatus.CANCELLED;
this.cancelTime = LocalDateTime.now();
// 发布领域事件
DomainEvents.raise(new OrderCancelledEvent(this.id));
}
public boolean isPaid() {
return this.status == OrderStatus.PAID;
}
}
public class OrderApplicationService {
public void cancelOrder(OrderId orderId) {
Order order = orderRepository.findById(orderId)
.orElseThrow(() -> new OrderNotFoundException(orderId));
order.cancel(); // 把业务逻辑的决策权还给领域对象
orderRepository.save(order);
}
}看出区别了吗?
充血模型里,Order 对象知道自己的"取消"规则,它自己负责检查状态、更新状态、发布事件。ApplicationService 只是协调:找到对象、调用行为、持久化。
为什么大多数团队落地不了充血模型
我见过的原因主要有三个:
原因一:ORM 的惯性
Spring Data JPA 或 MyBatis 的实体类,默认就是数据库表的映射,充满了 @Column、@Table 等注解。这让开发者天然地把实体当成"数据容器"来用,而不是"业务对象"。
解决方案:把领域对象和持久化对象分开。
// 领域对象(不带任何 ORM 注解)
public class Order {
private final OrderId id;
private OrderStatus status;
public void cancel() { ... }
}
// 持久化对象(只负责和数据库打交道)
@Entity
@Table(name = "t_order")
public class OrderPO {
@Id
private Long id;
private Integer status;
// 纯数据,无业务逻辑
}
// 转换器
public class OrderConverter {
public Order toDomain(OrderPO po) { ... }
public OrderPO toPO(Order domain) { ... }
}代价:多了转换层,代码量增加了。收益:领域对象干净,不被基础设施污染。
原因二:不知道聚合边界在哪
DDD 里有个概念叫"聚合根",很多团队对它的理解是:每个表对应一个聚合根。这是错的。
聚合的边界应该由业务一致性规则决定:哪些数据必须在同一个事务里保持一致,它们就应该在同一个聚合里。
一个常见的反例:把 Order 和 OrderItem 设计成两个独立的聚合,然后在 Service 里用分布式事务来保证它们的一致性。
正确做法:Order 和 OrderItem 是同一个聚合,Order 是聚合根,OrderItem 是聚合内的实体,整个聚合一起持久化。
public class Order { // 聚合根
private OrderId id;
private List<OrderItem> items; // 聚合内实体
private Money totalAmount;
public void addItem(ProductId productId, int quantity, Money price) {
// 验证业务规则
if (this.status != OrderStatus.DRAFT) {
throw new DomainException("只有草稿状态的订单才能添加商品");
}
// 检查是否已有相同商品
this.items.stream()
.filter(item -> item.getProductId().equals(productId))
.findFirst()
.ifPresentOrElse(
item -> item.increaseQuantity(quantity),
() -> this.items.add(new OrderItem(productId, quantity, price))
);
// 重新计算总金额(业务逻辑在聚合内部完成,不需要外部 Service 协助)
this.totalAmount = calculateTotal();
}
}原因三:技术团队和业务团队语言不通
DDD 强调通用语言(Ubiquitous Language)——技术和业务用同一套词汇描述系统。
但很多项目里,业务说"商品上架",技术代码里叫 updateProductStatus(id, 1);业务说"用户绑卡",技术代码里叫 insertBankCard。
这种语言鸿沟导致代码无法真实反映业务意图。
踩坑记录
踩坑一:领域服务和应用服务分不清楚
团队里有人把所有逻辑都堆在 DomainService 里,因为"这样也算在领域层"。
实际上:
- 领域服务:处理的是跨多个聚合的,但不属于任何一个聚合的领域逻辑(比如"转账"操作,涉及两个账户聚合的协调)
- 应用服务:处理用例编排,调用领域对象/领域服务,处理事务、权限、事件发布
如果一个方法只涉及单个聚合的行为,它就应该在聚合里,不需要单独建 DomainService。
踩坑二:Repository 接口暴露了 ORM 语义
// 错误的 Repository 接口(泄露了基础设施细节)
public interface OrderRepository {
List<Order> findByStatusAndCreateTimeAfter(OrderStatus status, LocalDateTime time);
Page<Order> findAll(Pageable pageable); // Pageable 是 Spring Data 的概念
}// 正确的 Repository 接口(用业务语言描述查询)
public interface OrderRepository {
Optional<Order> findById(OrderId id);
List<Order> findPendingPaymentOrders(LocalDateTime since);
void save(Order order);
}Repository 接口的方法名应该是业务可理解的,不能暴露 Pageable、Specification 等技术概念。
踩坑三:强行 DDD 了不该 DDD 的部分
有个同学把系统里的"用户注册"也搞成了充血模型,User 聚合里有 register() 方法、sendVerificationEmail() 方法、bindWechat() 方法……越搞越大,最后一个聚合根里有 50 个方法,比 Service 还复杂。
DDD 不适合用在 CRUD 为主的场景里。如果一个功能就是"增删改查数据",强行用 DDD 只会增加复杂度。DDD 的价值在于复杂的业务规则和领域逻辑,不是替代所有的 CRUD。
真正的 DDD 长什么样
一个判断 DDD 是否真正落地的标准
我给团队定了一个简单的标准:
把产品经理叫过来,给他看一段核心业务的代码,他能不能大体理解这段代码在描述什么业务流程。
如果他能理解,DDD 落地得不错。
如果他只看到一堆 mapper.update()、status = 3、type = 2,DDD 没有真正落地,只是换了个目录结构。
