DDD领域驱动设计
什么是 DDD?
DDD(领域驱动设计,Domain-Driven Design)是一种软件设计方法论,它为软件工程设计提供了一套完整的指导思想与实践手段。通过领域、界限上下文、实体、值对象、聚合、工厂、仓储等概念,DDD 帮助我们合理划分工程模型,从而在前期投入更多思考,规划出可持续迭代和演进的系统架构。
在工程实践中,DDD 通常分为两个层面的设计:
1. 战略设计
战略设计的目标是应对复杂业务需求。通过抽象与分治,将系统合理拆分为独立的业务模块或微服务,以实现“分而治之”。但拆分是否合理,要看实际开发与上线过程:如果每次上线都需要改动多个微服务,那么说明设计失败,反而形成了“微服务单体”。相比之下,更合理的模式是:以少数几个中等规模的单体应用为核心,周围构建一个服务生态系统,这样既能保持灵活性,也避免过度拆分。
2. 战术设计
战术设计关注如何在代码层面表达业务概念。它强调利用面向对象的思维方式,将业务逻辑封装在领域模型中,并通过实体、聚合、领域服务来承载业务行为。
传统的 MVC 三层架构往往只是 Service 层加数据模型的简单组合,容易导致 Service 类臃肿、逻辑复杂,甚至出现“贫血模型”问题——数据与行为分离,增加了维护难度。DDD 的战术设计通过丰富的领域模型来规避这一问题,使系统结构更清晰、业务逻辑更可维护。
为什么要用DDD?
先说说传统Spring MVC:
- Spring MVC 传统上多采用 分层架构(Controller-Service-DAO)。
- 对于 简单业务 或 原型开发,这种方式足够清晰,开发成本低,上手快。
说说Spring MVC的不足:
在 复杂业务场景(核心逻辑复杂、规则频繁变化的系统)中,传统分层模式会暴露出明显问题:
-
1)业务逻辑分散:
大量 if-else、规则判断和外部调用混杂在 Service 中。
代码难以维护,稍有业务变更,就需要在已有方法里继续堆条件分支。
@Service public class OrderService { @Autowired private OrderRepository orderRepository; @Autowired private PaymentGateway paymentGateway; public void payOrder(Long orderId, String payMethod) { Order order = orderRepository.findById(orderId); // 校验订单 if (order == null) { throw new IllegalArgumentException("订单不存在"); } if (!order.getStatus().equals("UNPAID")) { throw new IllegalStateException("订单状态不允许支付"); } // 校验金额 if (order.getAmount().compareTo(BigDecimal.ZERO) <= 0) { throw new IllegalStateException("订单金额异常"); } // 支付逻辑 boolean success = paymentGateway.pay(order.getId(), order.getAmount(), payMethod); if (!success) { throw new RuntimeException("支付失败"); } // 修改状态 order.setStatus("PAID"); orderRepository.save(order); } }
一个 Service 同时承担校验、业务逻辑、状态修改、持久化和外部调用,演变成“上帝类”。
-
2)贫血模型:Entity/POJO 只存数据,业务逻辑全堆在 Service,对象与业务语义严重脱节,比如
Order
类里找不到pay()
,只能在OrderService
找到payOrder()
。 -
3)随着需求增长,Service 越来越庞大,修改风险高、测试困难。
引出DDD的价值
1)业务逻辑回归领域模型(充血模型)
通过 实体、值对象、聚合、领域服务 等概念,把业务规则放回领域模型中,实现高内聚:
public class Order {
private Long id;
private BigDecimal amount;
private String status;
// 领域方法:支付
public void pay(PaymentGateway paymentGateway, String payMethod) {
validateBeforePay();
boolean success = paymentGateway.pay(this.id, this.amount, payMethod);
if (!success) {
throw new RuntimeException("支付失败");
}
this.status = "PAID";
}
private void validateBeforePay() {
if (!"UNPAID".equals(this.status)) {
throw new IllegalStateException("订单状态不允许支付");
}
if (amount.compareTo(BigDecimal.ZERO) <= 0) {
throw new IllegalStateException("订单金额异常");
}
}
}
@Service
public class OrderAppService {
@Autowired
private OrderRepository orderRepository;
@Autowired
private PaymentGateway paymentGateway;
public void payOrder(Long orderId, String payMethod) {
Order order = orderRepository.findById(orderId);
order.pay(paymentGateway, payMethod); // 业务逻辑放在 Order 里
orderRepository.save(order);
}
}
扩展更容易:如果要加优惠券逻辑,只需要在 Order
里扩展,而不是在 Service 里继续堆 if-else
。
2)统一语言(Ubiquitous Language)
DDD 强调与业务专家使用一致的术语建模,保证沟通顺畅:
传统写法:
Controller: OrderController.create()
Service: OrderService.saveOrder()
DAO: OrderRepository.insert()
业务专家说:“下单” 。开发说:“调用 create() 接口,service.saveOrder(),repository.insert()。”
DDD 写法:
团队必须先和业务专家一起挖掘、定义业务概念。
然后这些词汇会 直接落到模型、聚合、实体、方法名 上。
Customer.placeOrder() (客户下单)
Order.markAsPendingPayment() (订单标记为待支付)
OrderRepository.save(order) (仓储保存订单)
业务专家说:“下单 → 待支付” 。开发说:“placeOrder() → markAsPendingPayment()。”
3)技术解耦与可演进性
1)领域层不依赖技术实现,领域模型只关心业务,不关心底层是 MySQL、Redis、ES 还是文件。
所有外部依赖都通过 接口 定义,比如 OrderRepository
。具体的存储实现交给 适配器,在基础设施层完成。
2)遵循依赖倒置原则,领域层依赖抽象接口,而不是依赖具体实现,当技术实现需要调整(如 MySQL → Redis),只需要改适配器。领域模型的变更只来自业务规则的变化,而不是技术变更。
**面试官可能追问:**你只是把service层中的逻辑移动到了实体类中,将臃肿的代码逻辑转移到了别处?
回答:
表面上看,DDD 确实是把 Service 里的逻辑挪到了实体,但本质不是搬家,而是职责重构。 在贫血模型里,实体只是数据容器,业务规则分散在不同 Service 里,导致代码臃肿、逻辑重复。 在充血模型里,规则和实体强绑定,代码语义更贴近业务:
- 规则归属清晰:订单的支付校验、发货校验都收拢在
Order
聚合里,不会分散在多个 Service。 - 统一语言:
order.pay()
就等于业务里的“订单支付”,减少沟通成本。 - 复杂度可控:领域服务负责编排,聚合/实体承载业务逻辑,基础设施负责实现,避免出现‘上帝 Service’。
- 更易维护扩展:新增优惠券逻辑只需在
Order
内扩展,而不是在庞大的 Service 里继续加 if-else。 所以 DDD 的意义在于让领域模型成为业务的表达中心,而不仅仅是逻辑搬家。
如何理解聚合根?
我把聚合理解为一组强相关的实体和值对象,它们必须作为一个整体来保证业务一致性。聚合根是这组对象对外唯一的入口。所有修改必须通过聚合根来进行,它负责维护不变式,并定义事务边界;仓储也以聚合根为单位。跨聚合我们通过 ID 引用与领域事件实现最终一致,避免大事务。例如在订单域,Order
是聚合根,OrderLine
、Address
在聚合内;总价计算、状态流转等不变式在一次事务里由 Order
保证;库存属于另一个聚合,通过“订单已提交”事件去扣减库存。这样既保证一致性,又降低耦合、便于扩展。
DDD概念理论
所有例子可以基于:
1.创建订单+扣减库存+用户。
2.聚合根:Order,内部有item实体,地址值对象
或者 用户实体+活动实体+优惠实体
充血模型 vs 贫血模型
定义
- 贫血模型:对象仅包含数据属性和简单的
getter/setter
,业务逻辑由外部服务处理。 - 充血模型:对象既包含数据,也封装相关业务逻辑,符合面向对象设计原则。
特点 | 贫血模型 | 充血模型 |
---|---|---|
封装性 | 数据和逻辑分离 | 数据和逻辑封装在同一对象内 |
职责分离 | 服务类负责业务逻辑,对象负责数据 | 对象同时负责数据和自身的业务逻辑 |
适用场景 | 简单的增删改查、DTO 传输对象 | 复杂的领域逻辑和业务建模 |
优点 | 简单易用,职责清晰 | 高内聚,符合面向对象设计思想 |
缺点 | 服务层臃肿,领域模型弱化 | 复杂度增加,不适合简单场景 |
面向对象原则 | 违反封装原则 | 符合封装原则 |
贫血模型:
// 1. “贫血”的订单实体 (Entity)
// 它只是一个数据袋子,没有行为,只有getter/setter
public class Order {
private Long id;
private String status;
private BigDecimal amount;
// ... 一堆getter和setter方法
}
// 2. “贫血”的商品实体 (Entity)
public class Product {
private Long id;
private String name;
private Integer stock; // 库存
// ... 一堆getter和setter方法
}
// 3. 庞大的“服务层” (Service) 包含所有业务逻辑
@Service
public class OrderService {
@Autowired
private ProductRepository productRepository;
public void decreaseStock(Long productId, Integer quantity) {
// 步骤1: 查询商品
Product product = productRepository.findById(productId);
// 步骤2: 检查库存(业务规则)
if (product.getStock() < quantity) {
throw new RuntimeException("库存不足");
}
// 步骤3: 计算并设置新库存(业务逻辑)
Integer newStock = product.getStock() - quantity;
product.setStock(newStock); // 对象的状态由外部服务来修改
// 步骤4: 保存回数据库
productRepository.save(product);
}
}
问题:所有业务逻辑(检查库存、计算新库存)都放在了 OrderService
这个外部服务里。Product
对象本身只是个“傻傻的”数据载体,它对自己的业务规则(如“库存不能为负”)一无所知,谁都可以随意setStock
,非常容易出错。这就是 “贫血模型”。
充血模型:
// 1. “充血”的商品实体 (Entity/Aggregate Root)
// 它不仅有数据,更有行为(方法),它对自己的业务规则负责
public class Product {
private Long id;
private String name;
private Integer stock; // 库存
// 核心业务行为:减少库存
// 这个方法是直接写在这个实体对象内部的!
public void decreaseStock(Integer quantity) {
// 守护业务规则:库存不能减少为负数
if (this.stock < quantity) {
throw new DomainException("商品库存不足,无法减少");
}
// 业务逻辑:修改自身状态
this.stock -= quantity;
}
// 其他行为,如增加库存...
public void increaseStock(Integer quantity) {
this.stock += quantity;
}
}
// 2. 变得很“薄”的服务层 (Service/Application Service)
// 它的职责不再是处理业务逻辑,而是协调事务、调用仓库、发布事件等
@Service
public class OrderApplicationService {
@Autowired
private ProductRepository productRepository;
public void decreaseStock(Long productId, Integer quantity) {
// 步骤1: 获取领域对象(聚合根)
Product product = productRepository.findById(productId);
// 步骤2: 调用领域对象自身的业务方法!
product.decreaseStock(quantity); // 逻辑在Product内部
// 步骤3: 保存这个发生了变化的对象
productRepository.save(product);
}
}
-
这样的方式可以在使用一个对象时,就顺便拿到这个对象的提供的一系列业务方法,所有使用对象的逻辑方法,都不需要自己再次处理同类逻辑。
-
但不要只是把充血模型,仅限于一个类的设计和一个类内的方法设计。充血还可以是整个包结构**(领域模型)**,一个包下包括了用于实现此包 Service 服务所需的各类零部件(模型、仓储、工厂),也可以被看做充血模型。
-
同时我们还会再一个同类的类下,提供对应的内部类,如用户实名,包括了通信类、实名卡、银行卡、四要素等。它们都被写进到一个用户类下的内部子类,这样在代码编写中也会清晰的看到子类的所属信息,更容易理解代码逻辑,也便于维护迭代。
我的实体类本身还是偏贫血模型,主要负责承载数据和基本的不变式校验。**但在领域层里,我会把仓储、领域服务和实体组合在一起,所有业务逻辑都在领域模型中闭环实现,不会散落到外层,这样整体上就是充血思想。**实体保证自身一致性,复杂逻辑交给领域服务来实现。
限界上下文
限界上下文是指一个明确的边界,规定了某个子领域的业务模型和语言,确保在该上下文内的术语、规则、模型不与其他上下文混淆。是一个 业务设计概念。
表达 | 语义环境 | 实际含义 |
---|---|---|
"我吃得很饱,现在不能动了" | 日常用餐 | 字面意思:吃到肚子很满 |
"我吃得很饱,今天的演讲让人充实" | 知识分享 | 比喻:得到了很大满足 |
限界上下文的作用
- 定义业务边界:类似于语义环境,为通用语言划定范围
- 消除歧义:确保团队对领域对象、事件的认知一致
- 领域转换:同一对象在不同上下文有不同名称(goods在电商称"商品",运输称"货物")
- 模型隔离:防止不同业务领域的模型相互干扰
在代码工程里,每个上下文拥有独立包结构
领域模型
指特定业务领域内,业务规则、策略以及业务流程的抽象和封装。在设计手段上,通过风暴模型拆分领域模块,形成界限上下文。最大的区别在于把原有的众多 Service + 数据模型
的方式,拆分为独立的有边界的领域模块。每个领域内创建自身所属的;领域对象(实体、聚合、值对象)、仓储服务(DAO 操作)、工厂、端口适配器Port(调用外部接口的手段)等。
- 在原本的 Service + 贫血的数据模型开发指导下,Service 串联调用每一个功能模块。这些基础设施(对象、方法、接口)是被相互调用的。这也是因为贫血模型并没有面向对象的设计,所有的需求开发只有详细设计。
- 换到充血模型下,现在我们以一个领域功能为聚合,拆分一个领域内所需的 Service 为领域服务,VO、Req、Res 重新设计为领域对象,DAO、Redis 等持久化操作为仓储等。举例:一套账户服务中的,授信认证、开户、提额降额等,每一个都是一个独立的领域,在每个独立的领域内,创建自身领域所需的各项信息。
- 领域模型还有一个特点,它自身只关注业务功能实现,不与外部任何接口和服务直连。如;不会直接调用 DAO 操作库,也不会调用缓存操作 Redis,更不会直接引入 RPC 连接其他微服务。而是通过仓库Repository和端口适配器port,定义调用外部数据的含有出入参对象的接口标准,让基础设施层做具体的调用实现——通过这样的方式让领域只关心业务实现,同时做好防腐。(依赖倒置)
领域服务
一组无状态的业务操作,封装那些“不属于任何单个实体/聚合”的领域逻辑。
职责
-
执行跨聚合、跨实体的业务场景——
处理一个订单支付时,可能需要处理与 订单、账户、支付信息 等多个实体的交互。
在这种情况下,领域服务负责协调这些实体之间的交互。
-
协调仓储接口、调用多个聚合根的方法,但本身不持有长期状态
领域服务自己不持有数据状态,它的职责是调度和协调。它通过调用聚合根(或实体)的方法来完成业务操作。它也不会涉及持久化(数据存储),这些通常是通过仓储层来管理的。
典型示例
订单支付功能: 涉及订单、用户账户、支付信息等多个实体,适合放在领域服务中实现
订单(Order):包含订单的详细信息。
账户(Account):用户的账户信息,包括余额。
支付信息(PaymentDetails):支付的具体信息,例如支付方式、金额等。
public class PaymentService {
public void processPayment(Order order, PaymentDetails paymentDetails, Account account) {
// 1. 检查账户余额
if (account.getBalance() < paymentDetails.getAmount()) {
throw new InsufficientFundsException();
}
// 2. 扣除账户余额
account.deductBalance(paymentDetails.getAmount());
// 3. 更新订单状态
order.setStatus(OrderStatus.PAID);
// 4. 记录支付信息或其他操作
paymentDetails.recordPayment(order);
}
}
领域对象
实体
实体是基于持久化层数据和领域服务功能目标设计的领域对象。与持久化的 PO(持久化对象)不同,PO 只是原子类对象,缺乏业务语义,而实体对象不仅具备业务语义,还具有唯一标识。实体对象与领域服务方法紧密结合,跟随其生命周期进行操作。
例如,用户的 PO 对象可能包括用户开户信息、授信信息和额度信息等多个方面,而订单则可能涉及多个实体,例如商品下单时的购物车实体。实体通常作为领域服务方法的入参对象。
在代码中,实体通常表现为具有唯一标识的业务对象,标识属性(如 ID)是其核心特征。例如:
- 订单实体:通过订单 ID 唯一标识
- 用户实体:通过用户 ID 唯一标识
核心特征:
- 实体的属性随着时间变化而变化。
- 唯一标识(ID)保持不变,确保实体的唯一性。
实体对象通常在代码中以实体类的形式存在,并且通常采用 充血模型 实现,即将与该实体相关的业务逻辑和行为写入实体类中,而不仅仅是存储数据。
值对象
值对象是没有唯一标识的业务对象,具有以下特征:
- 创建后不可修改(immutable)
- 只能通过整体替换来更新
- 通常用于描述实体的属性和特征
在开发值对象的时候,通常不会提供 setter 方法,而是提供构造函数或者 Builder 方法来实例化对象。这个对象通常不会独立作为方法的入参对象,但做可以独立作为出参对象使用。
聚合与聚合根
”高内聚、低耦合“,代码中直观的感受就是仓储层中,传入的如果是聚合根,意味着要对不同的表进行处理,因此对应方法上一般要加@Transactional
-------拼团中的锁单、退单都是如此!!!
锁单:同时操作拼团表和拼团明细表;退单拼团表+拼团明细表+消息通知表。
在领域驱动设计(DDD)中,聚合是一组紧密关联的 **实体 **和 值对象的组合,这些对象在业务上共同协作,形成一个统一的一致性与事务边界。聚合根 是聚合的唯一入口,负责对外提供操作接口,并维护聚合内部的一致性和业务规则。
1. 聚合(逻辑边界 设计概念)
- 一致性边界:聚合内的所有变更必须在同一事务中完成,要么全部成功,要么全部失败,确保内部业务不变式(Invariant)始终成立。
- 事务边界:一次事务只允许跨越一个聚合,避免分布式事务的复杂性。
- 访问约束:外部代码不得直接修改聚合内除聚合根之外的对象,所有操作都必须通过聚合根进行。
示例: 一个订单聚合可能包含:
- 订单实体(聚合根):
Order
,全局唯一ID,提供操作方法。 - 订单明细实体:
OrderItem
,描述商品项(数量、单价)。 - 收货地址值对象:
ShippingAddress
,不可变,包含地址信息。
在下单过程中,购物车实体(来自上游)会被转换为订单聚合内部的订单明细实体。
原则:聚合内保证事务一致性,聚合与聚合之间通过事件或服务实现最终一致性。
2. 聚合根(物理入口 具体的类)
- 唯一入口:对外唯一的访问点,聚合内的所有修改必须经由聚合根发起。
- 全局标识:聚合根是一个拥有全局唯一 ID 的实体。
- 规则守护者:负责封装聚合内部的业务逻辑、数据校验及不变式维护。
- 跨聚合交互:与其他聚合交互时,只传递 ID 或使用领域服务,不直接持有对方实体的引用,避免跨边界耦合。
public class Order { // 聚合根
private final OrderId id; // 根实体唯一标识
private List<OrderItem> items; // 聚合内实体
private ShippingAddress address; // 聚合内值对象
public Order(String orderId, ShippingAddress shippingAddress) {
this.orderId = orderId;
this.shippingAddress = shippingAddress;
this.items = new ArrayList<>();
}
public void addItem(String productId, int quantity, double price) {
OrderItem item = new OrderItem(productId, quantity, price);
items.add(item);
}
public double totalAmount() {
return items.stream().mapToDouble(OrderItem::totalPrice).sum();
}
}
// 使用示例
public class Main {
public static void main(String[] args) {
// 创建送货地址
ShippingAddress address = new ShippingAddress("123 Main St", "Cityville", "12345");
// 创建订单
Order order = new Order("1", address);
// 添加商品到订单
order.addItem("A001", 2, 100.0);
order.addItem("B002", 1, 50.0);
// 输出订单详情
System.out.println(order);
// 输出订单总金额
System.out.println("Total amount: " + order.totalAmount());
}
}
说明:
Order
是聚合根,对外暴露安全的操作方法。OrderItem
是聚合内的实体,不允许外部直接新增或修改,只能通过Order.addItem()
,即通过聚合根来操作。ShippingAddress
是不可变的值对象,每次修改需整体替换。
仓储服务
特征
- 封装持久化操作:Repository负责封装所有与数据源交互的操作,如创建、读取、更新和删除(CRUD)操作。这样,领域层的代码就可以避免直接处理数据库或其他存储机制的复杂性。
- 抽象接口:Repository定义了一个与持久化机制无关的接口,这使得领域层的代码可以在不同的持久化机制之间切换,而不需要修改业务逻辑。
职责分离
- 领域层 只定义 Repository 接口,关注“需要做哪些数据操作”(增删改查、复杂查询),不关心具体实现。
- 基础设施层 实现这些接口(ORM、JDBC、Redis、ES、RPC、HTTP、MQ 推送等),封装所有外部资源的访问细节。
仓储解耦的手段使用了依赖倒置的设计。

示例: 只定义接口,由基础设施层来实现。
public interface IActivityRepository {
GroupBuyActivityDiscountVO queryGroupBuyActivityDiscountVO(String source, String channel);
SkuVO querySkuByGoodsId(String goodsId);
}
使用:在应用程序中使用依赖注入(DI)来将具体的Repository实现注入到需要它们的领域服务或应用服务中。
聚合和领域服务和仓储服务的比较
有状态(Stateful):
一个订单(Order)聚合,它可能会记录订单的状态,比如“未支付”或“已支付”,以及订单项(OrderItem)的列表。在处理订单时,这些状态会发生变化(例如,当订单支付时,它的状态从“未支付”变为“已支付”)。
无状态:
一个计算价格的服务(PricingService
)是无状态的,它接收输入(例如商品数量、商品价格等),然后计算并返回结果。它不会记住上一次计算的结果,每次计算都是独立的。
特性 | 聚合(Aggregate) | 领域服务(Domain Service) | 仓储(Repository) |
---|---|---|---|
本质 | 相关实体和值对象的组合,以“聚合根”为唯一访问入口 | 无状态的业务逻辑单元,封装跨实体 / 跨聚合规则 | 抽象的数据访问接口,隐藏底层存储细节,为聚合提供持久化能力 |
状态 | 有状态——内部维护数据与不变式 | 无状态——仅暴露行为 | 无业务状态;实现层可能有缓存,但对外看作无状态 |
职责 | 1. 内部一致性2. 定义事务边界3. 提供领域行为(order.pay() 等) |
1. 承载跨实体规则2. 协调多个聚合完成业务动作 | 1. 加载 / 保存聚合根2. 把 PO ↔️ Entity 映射3. 屏蔽 SQL/ORM/缓存等技术细节 |
边界 | 聚合边界:内部操作要么全部成功要么全部失败 | 无一致性边界,仅调用聚合或仓储 | 持久化边界:一次操作针对一个聚合;不负责业务事务(由应用层控制) |
典型用法 | Order.addItem() ,Order.cancel() |
PricingService.calculate(...) ,InventoryService.reserveStock(...) |
orderRepository.findById(id) ,orderRepository.save(order) |
**自己总结:**领域服务纯编排流程并注入仓储服务;
仓储服务只写接口,规定一个具体的'动作';
然后基础设施层中子类实现该仓储接口,并注入若干Dao,一个'动作'可能调用多个Dao来实现;
Dao直接与数据库打交道,实现增删查改。
DDD架构设计
四层架构
- 用户接口层interface:处理用户交互和展示
- 应用层application:协调领域对象完成业务用例
- 领域层domain:包含核心业务逻辑和领域模型
- 基础设施层infrastructure:提供技术实现支持

如何从MVC架构映射到DDD架构?

六边形架构
领域模型设计
- 方式1;DDD 领域科目类型分包,类型之下写每个业务逻辑。
- 方式2;业务领域分包,每个业务领域之下有自己所需的 DDD 领域科目。(拼团营销系统是方式2)