明天你会感谢今天奋力拼搏的你。
ヾ(o◕∀◕)ノヾ
上一篇文章对DDD的相关概念进行了介绍。本篇文章聊聊DDD的战术设计,因为作为一个一线的程序员,最想知道的还是如何把DDD落地,而DDD的战术设计就是关注领域模型的具体实现,解决 “如何做” 的问题。本文主要通过一个示例工程,让初学者能快速明白DDD的项目该如何搭建,以及为什么要这么编写。源码地址
本示例项目基于领域驱动设计(Domain-Driven Design,DDD),采用六边形架构(也称为端口和适配器架构)并实现了CQRS(命令查询职责分离)模式,模拟实现了一个简单的订单管理模块。旨在帮助初学者理解如何在实践中应用DDD的核心概念和最佳实践。
本项目分为以下几个主要模块:
架构效果:
设计原则:
领域层主要封装核心业务逻辑,不依赖任何外部技术,保持领域模型的纯粹性。
本项目以ddd-example-domain 模块作为领域层,是DDD架构的核心,包含所有业务逻辑和规则:
应用层职责为协调领域对象完成业务用例,处理事务边界,不包含业务规则。
本项目以ddd-example-application 模块作为应用层,负责业务流程的编排和用例实现:
基础设施层的职责是提供技术实现,隐藏技术细节,支持领域层和应用层的需求。
本项目以 ddd-example-infrastructure 模块作为基础设施层,提供技术实现和外部系统集成:
接口层的职责是对外提供标准化的API接口,处理用户交互,不包含业务逻辑。
本项目以 ddd-example-interfaces 模块作为接口层,提供对外API接口和用户界面:
启动模块的职责是组装各个模块,配置运行环境,启动应用程序。
本项目以 ddd-example-starter 模块作为启动模块,负责应用程序的组装、配置和启动:
上文介绍了几个模块的职责,现在继续进一步深入介绍DDD项目开发的具体实践。
六边形架构(又称 “端口与适配器架构”,Port and Adapter Architecture)由 Alistair Cockburn 于 2005 年提出,其核心思想是将系统划分为以领域逻辑为中心的层次,通过 “端口” 和 “适配器” 隔离外部依赖,使领域层独立于技术实现和外部交互方式。
依赖关系:接口层(ddd-example-interfaces) → 应用层(ddd-example-application) → 领域层(ddd-example-domain) ← 基础设施层(ddd-example-infrastructure)
通过划分边界确保上下文内的领域模型(实体、值对象、聚合等)语义一致、职责聚焦。
通俗点讲:战术设计阶段划分好上下文后,我们在开发阶段应该要设计好实体、值对象、聚合等类,让其没有二义性,并且这些定义只能在设定好的上下文中使用。
在本示例项目中,订单管理的限界上下文如下:
订单管理限界上下文包含:
├── 订单(Order):核心业务实体
├── 订单项(OrderItem):订单的组成部分
├── 订单状态(OrderStatus):订单的生命周期状态
├── 金额(Money):订单金额的表示
├── 订单仓储(OrderRepository):订单的持久化接口
└── 订单领域服务(OrderDomainService):跨实体的业务逻辑
聚合(Aggregate):一组相关联的实体和值对象的集合,作为数据修改的最小单元。
聚合根(Aggregate Root):聚合的唯一入口,负责维护聚合内对象的生命周期和业务规则。
聚合 = 聚合根 + 实体 + 值对象
以本示例项目为例,Order作为聚合根,负责整个订单的生命周期管理
public class Order extends AggregateRoot<OrderId> {
private OrderId id;
private UserId userId;
private List<OrderItem> items;
private OrderStatus status;
private Money totalAmount;
// 业务操作方法
public void pay() { /* 支付逻辑 */ }
public void cancel() { /* 取消逻辑 */ }
public void addItem(ProductId productId, int quantity) { /* 添加商品逻辑 */ }
}
实体,以OrderItem为例是聚合内的对象,有自己的身份标识(主键ID)
public class OrderItem extends Entity<OrderItemId> {
private OrderItemId id;
private ProductId productId;
private int quantity;
private Money unitPrice;
// 实体有自己的业务方法
public Money getSubtotal() {
return unitPrice.multiply(quantity);
}
}
值对象没有身份标识,通过属性值来区分,可以是类也可以是枚举
// 值对象:金额
public class Money extends ValueObject {
private final BigDecimal amount;
private final Currency currency;
public Money add(Money other) {
// 金额计算逻辑
}
}
// 值对象:订单状态
public enum OrderStatus {
CREATED, PAID, CANCELLED, SHIPPED, DELIVERED
}
领域事件是发生在领域中的重要事情,用于跨聚合或上下文通信。事件是不可变的,并要按时间顺序执行。
领域事件的核心价值在于:以 “事件” 为纽带,将分散的业务环节串联起来,同时通过 “发布 - 订阅” 模式减少直接依赖,让领域模型更聚焦于自身业务规则,而非跨边界的协作逻辑。
为什么需要领域事件?因为在复杂的业务系统中,一个操作可能会触发多个后续动作。如:订单支付成功可能会触发发送通知、生成发票、开发发货等。
本示例项目中对领域事件的处理如下:
1、领域层的事件基础设施
2、具体的领域事件实现
3、聚合根中的事件发布
4、应用层Service中的事件发布
5、应用层的事件处理
当前项目使用了Spring的事件机制(ApplicationEventPublisher),这种方式适合单体应用或简单的分布式场景。
如果要改造成为MQ方案,新增一个MessageQueueEventPublisher实现DomainEventPublisher接口,并增加MQ的消费者即可。
在领域驱动设计(DDD)中,仓储模式(Repository) 是连接领域模型(尤其是聚合根)与数据存储的中间层,它通过抽象数据访问细节,让领域模型专注于业务逻辑,而无需关心数据如何存储或读取。
实现步骤:
仓储模式的核心作用:
注意:DDD中的仓储(Repository)和JPA中的Repository是不同的概念。
CQRS是"命令查询职责分离"的缩写,它将系统中的操作分为命令(Command)和查询(Query)两类。
CQRS把查询分离出来的目的是为了应对高性能查询和复杂查询。
为什么需要CQRS?
在传统的CRUD模式中,读写操作使用同一个模型,但随着业务复杂度增加,这种模式会遇到以下问题:
具体实践:
在本示例项目在application层中分别建立了command和query包,用于创建命令和查询的服务。
为什么CQRS在application层中?
CQRS主要解决的是数据读写分离的问题,这种分离更多是针对应用场景的优化,而不是业务领域。
DDD如何优雅地应对各种业务变化场景?在此基于本示例项目代码,列举几个场景进行说明。
假设原始需求是"已支付订单不可取消",现在需求变更为"已支付订单在24小时内可以取消"。如下所示,只需要修改Order聚合根中的业务规则即可。
public class Order {
public void cancel() {
// 原始规则
if (status == OrderStatus.PAID) {
throw new BusinessException("已支付订单不可取消");
}
// 新规则:仅需在领域层修改判断逻辑
if (status == OrderStatus.PAID &&
Duration.between(paidTime, LocalDateTime.now()).toHours() > 24) {
throw new BusinessException("已支付订单超过24小时不可取消");
}
this.status = OrderStatus.CANCELLED;
// 发布订单取消事件
domainEventPublisher.publish(new OrderCancelledEvent(this));
}
}
假设订单创建时需要增加库存检查,原始流程只需创建订单,现在需要增加库存检查步骤(库存和订单是不同领域)。
修改步骤:
// 应用服务层
public class OrderApplicationService {
@Transactional
public void createOrder(CreateOrderCommand cmd) {
// 1. 通过领域服务进行库存检查
orderDomainService.checkInventory(cmd.getItems());
// 2. 创建订单(原有流程)
Order order = Order.create(cmd.getUserId(), cmd.getItems());
orderRepository.save(order);
// 3. 发布订单创建事件
DomainEventPublisher.publish(new OrderCreatedEvent(order));
}
}
// 领域服务层
public class OrderDomainService {
public void checkInventory(List<OrderItem> items) {
// 库存检查逻辑
// 若库存不足则抛出领域异常
}
}
假设订单支付接入新的支付渠道,需要支持新的支付方式,如微信支付。
// 领域层定义支付接口
public interface PaymentGateway {
PaymentResult pay(Order order, Money amount);
}
// 基础设施层实现具体支付渠道
public class WeChatPaymentGateway implements PaymentGateway {
@Override
public PaymentResult pay(Order order, Money amount) {
// 实现微信支付逻辑
}
}
// 应用层通过依赖注入使用支付网关
public class OrderApplicationService {
private final PaymentGateway paymentGateway;
public void payOrder(PayOrderCommand cmd) {
Order order = orderRepository.findById(cmd.getOrderId());
PaymentResult result = paymentGateway.pay(order, order.getTotalAmount());
order.handlePaymentResult(result);
orderRepository.save(order);
}
}
面向对象设计原则在DDD中的应用不是机械的教条式遵守,而是通过领域模型的合理设计自然地体现出来。在实际开发中,这些原则不是孤立的,而是相互配合、彼此支持,共同构建起健壮的领域模型。
含义:一个类 / 对象应仅负责一个业务职责,当职责变化时,仅有一个原因导致其修改。(一个对象只做一件事)
单一职责在项目中多个层面都有体现:
含义:软件实体(类、模块)应允许扩展新功能,但不允许修改原有代码。(对扩展开放,对修改关闭)
在DDD应用中的体现:
含义:子类对象应能替换父类对象在程序中出现的任何位置,且不改变程序的正确性。(子类可替换父类,不破坏原逻辑)
这一原则在实体和值对象中体现:
// 基础实体类
public abstract class Entity<ID> {
protected ID id;
@Override
public boolean equals(Object obj) {
// 基于ID的相等性比较
}
}
// 具体实体类
public class Order extends Entity<String> {
// 订单特有的属性和行为
}
含义:不应强迫客户端依赖其不需要的接口,应将大接口拆分为多个小接口,每个接口服务于特定场景。(接口应小而专,避免冗余)
这一原则在DDD中有如下体现:
含义:高层模块不应依赖低层模块,两者都应依赖抽象;抽象不应依赖细节,细节应依赖抽象。(依赖抽象,而非具体)
这一原则在DDD中有如下体现:
含义:通过 “对象组合” 实现功能复用,而非过度依赖继承(避免继承带来的紧耦合和复杂性)。
这一原则在 DDD 的 “聚合” 设计中体现得尤为明显。
含义:一个对象应当对其他对象有尽可能少的了解。其实就是信息屏蔽。
这一原则通过聚合根和限界上下文的边界控制的一体现,如:
public class Order {
// 外部只能通过Order访问OrderItem
public void addItem(String productId, int quantity, Money unitPrice) {
OrderItem item = new OrderItem(productId, quantity, unitPrice);
this.items.add(item);
this.recalculateTotalAmount();
}
}
全部评论