DDD是什么?如何正确使用DDD(附代码示例)
我们常常会听到领域驱动设计(DDD,Domain-Driven Design )这个术语,但很多人对它的理解还停留在表面,网络上的相关文章大多晦涩难懂。今天,咱们就深入浅出地聊聊DDD,看看它究竟是什么,以及如何在实际项目中应用。
一、DDD究竟是什么?
简单来说,DDD是一种构建复杂系统的软件开发方法,它的核心在于将代码结构与业务领域的实际需求紧密结合。和传统开发对着需求文档写代码不同,DDD强调拉着业务方一起绘制领域模型,让代码能够精准地反映业务本质。用大白话讲,就是用代码还原业务的真实情况,而不只是单纯实现功能。
二、从用户注册案例看传统开发模式
为了让大家更好地理解,我们以一个简单的用户注册功能为例。在这个案例中,业务规则规定用户名必须唯一,密码要满足复杂度要求,注册成功后还得记录日志。
在传统开发模式下,可能会写出这样的代码:
@Controller public class UserController { public void register(String username, String password) { // 校验密码 // 检查用户名 // 保存数据库 // 记录日志 // 所有逻辑混在一起 } }
有些开发者可能会说,代码肯定不能这么写,得进行分层,比如分成controller、service、dao层。于是,代码就变成了这样:
// Service层:仅有流程控制,业务规则散落在各处 public class UserService { public void register(User user) { // 校验规则1:写在工具类里 ValidationUtil.checkPassword(user.getPassword()); // 校验规则2:通过注解实现 if (userRepository.exists(user)) { ... } // 数据直接传递到DAO userDao.save(user); } }
虽然代码进行了分层,流程看起来也清晰了不少,但这并不意味着就是DDD。在这段代码里,User对象只是用来承载数据的“贫血模型”,业务逻辑被分散到了各个外部模块。
三、DDD的正确打开方式——以用户注册为例
在DDD中,一些业务逻辑会被内聚到领域对象中。还是以用户注册为例,密码规则的校验就可以放到User对象里。用专业的话来说,就是业务规则被封装在领域对象内部,对象不再仅仅是“数据袋子” ,而是“充血模型”。具体代码如下:
// 领域实体:业务逻辑内聚 public class User { public User(String username, String password) { // 密码规则内聚到构造函数 if (!isValidPassword(password)) { throw new InvalidPasswordException(); } this.username = username; this.password = encrypt(password); } // 密码复杂度校验是实体的职责 private boolean isValidPassword(String password) { ... } }
从这段代码可以看出,校验密码的逻辑下沉到了User领域实体对象中,这就是DDD和传统开发的一个重要区别。
四、DDD的关键设计要素
(一)聚合根(Aggregate Root)
在实际业务场景中,比如用户(User)和收货地址(Address)有关联的情况。按照传统方式,会在Service中分别管理User和Address。而在DDD里,会将User作为聚合根,由它来控制Address的增删操作。代码示例如下:
public class User { private List<Address> addresses; // 添加地址的逻辑由聚合根控制 public void addAddress(Address address) { if (addresses.size() >= 5) { throw new AddressLimitExceededException(); } addresses.add(address); } }
通过这种方式,业务逻辑更加集中,管理也更加方便。
(二)领域服务与应用服务
- 领域服务:主要处理跨多个实体的业务逻辑。比如说转账操作,涉及到两个账户,这种核心业务逻辑就由领域服务来处理。
- 应用服务:负责协调流程,比如调用领域服务,再加上发送消息等操作。具体代码示例如下:
// 领域服务:处理核心业务逻辑 public class TransferService { public void transfer(Account from, Account to, Money amount) { from.debit(amount); // 账户扣款逻辑内聚在Account实体 to.credit(amount); } } // 应用服务:编排流程,不包含业务规则 public class BankingAppService { public void executeTransfer(Long fromId, Long toId, BigDecimal amount) { Account from = accountRepository.findById(fromId); Account to = accountRepository.findById(toId); transferService.transfer(from, to, new Money(amount)); messageQueue.send(new TransferEvent(...)); // 基础设施操作 } }
从代码中可以看出,领域服务专注于业务逻辑的实现,应用服务则负责流程的编排。
(三)领域事件(Domain Events)
领域事件是用事件来明确表达业务的变化。比如用户注册成功后,会触发UserRegisteredEvent事件。代码示例如下:
public class User { public void register() { // ...注册逻辑 this.events.add(new UserRegisteredEvent(this.id)); // 记录领域事件 } }
这样,当业务发生变化时,可以通过这些事件进行后续的处理。
五、传统开发与DDD的差异对比
下面我们通过表格来直观地对比一下传统开发和DDD的区别:
维度 | 传统开发 | DDD |
---|---|---|
业务逻辑归属 | 分散在Service、Util、Controller等多个地方 | 内聚在领域实体或领域服务中 |
模型作用 | 主要作为数据载体,即贫血模型 | 是携带行为的业务模型,也就是充血模型 |
技术实现影响 | 数据库表结构驱动代码设计 | 业务需求驱动数据库表结构设计 |
六、电商下单案例:DDD的实际应用
为了进一步加深大家对DDD的理解,我们再来看一个电商下单的案例。假设业务需求是用户下单时要校验库存、使用优惠券、计算实付金额并生成订单。
(一)传统写法(贫血模型)
// Service层:大杂烩式下单 public class OrderService { @Autowired private InventoryDAO inventoryDAO; @Autowired private CouponDAO couponDAO; public Order createOrder(Long userId, List<ItemDTO> items, Long couponId) { // 1. 校验库存(散落在Service) for (ItemDTO item : items) { Integer stock = inventoryDAO.getStock(item.getSkuId()); if (item.getQuantity() > stock) { throw new RuntimeException("库存不足"); } } // 2. 计算总价 BigDecimal total = items.stream() .map(i -> i.getPrice().multiply(i.getQuantity())) .reduce(BigDecimal.ZERO, BigDecimal::add); // 3. 应用优惠券(规则写在工具类) if (couponId != null) { Coupon coupon = couponDAO.getById(couponId); total = CouponUtil.applyCoupon(coupon, total); // 优惠逻辑隐藏在Util } // 4. 保存订单(纯数据操作) Order order = new Order(); order.setUserId(userId); order.setTotalAmount(total); orderDAO.save(order); return order; } }
这种传统写法存在一些问题,比如库存校验、优惠计算等逻辑分散在Service、Util、DAO中,Order对象只是数据载体,当需求变更时,修改代码就像“考古”一样困难。
(二)DDD写法(充血模型)
// 聚合根:Order(承载核心逻辑) public class Order { private List<OrderItem> items; private Coupon coupon; private Money totalAmount; // 构造函数内聚业务逻辑 public Order(User user, List<OrderItem> items, Coupon coupon) { // 1. 校验库存(领域规则内聚) items.forEach(item -> item.checkStock()); // 2. 计算总价(业务逻辑在值对象) this.totalAmount = items.stream() .map(OrderItem::subtotal) .reduce(Money.ZERO, Money::add); // 3. 应用优惠券(规则在实体内部) if (coupon != null) { validateCoupon(coupon, user); // 优惠券使用规则内聚 this.totalAmount = coupon.applyDiscount(this.totalAmount); } } // 优惠券校验逻辑(业务归属清晰) private void validateCoupon(Coupon coupon, User user) { if (!coupon.isValid() || !coupon.isApplicable(user)) { throw new InvalidCouponException(); } } } // 领域服务:协调下单流程 public class OrderService { public Order createOrder(User user, List<Item> items, Coupon coupon) { Order order = new Order(user, convertItems(items), coupon); orderRepository.save(order); domainEventPublisher.publish(new OrderCreatedEvent(order)); // 领域事件 return order; } }
采用DDD写法后,库存校验封装在了OrderItem值对象中,优惠券规则内聚在Order实体内部方法里,计算逻辑由Money值对象保证精度。当业务发生变化时,只需要修改领域对象即可。比如产品提出新需求:优惠券需满足“订单满100减20”,且仅限新用户使用。传统开发方式需要修改Service层和Util类,而DDD只需要修改Order.validateCoupon()方法。
七、DDD的适用场景
DDD虽然强大,但并非适用于所有场景。在业务复杂的系统,像电商、金融、ERP系统,以及需求频繁变更的互联网业务中,DDD能够发挥出巨大的优势。但对于简单的CRUD操作,比如管理后台、数据报表这类功能,使用DDD反而会增加开发成本,有些小题大做。
可以这样判断是否适合使用DDD:当修改业务规则时,只需要调整领域层代码,而不需要改动Controller或DAO,那就说明DDD在这个项目中得到了较好的落地。
总之,希望通过本文,大家对DDD有了更清晰的认识。如果在阅读过程中有任何疑问,欢迎在评论区留言讨论。