DDD之我见:(二)DDD落地

2025-07-09 04:24
404
0

上一篇文章对DDD的相关概念进行了介绍。本篇文章聊聊DDD的战术设计,因为作为一个一线的程序员,最想知道的还是如何把DDD落地,而DDD的战术设计就是关注领域模型的具体实现,解决 “如何做” 的问题。本文主要通过一个示例工程,让初学者能快速明白DDD的项目该如何搭建,以及为什么要这么编写。源码地址

一、项目介绍

本示例项目基于领域驱动设计(Domain-Driven Design,DDD),采用六边形架构(也称为端口和适配器架构)并实现了CQRS(命令查询职责分离)模式,模拟实现了一个简单的订单管理模块。旨在帮助初学者理解如何在实践中应用DDD的核心概念和最佳实践。

本项目分为以下几个主要模块:

  • 启动模块:依赖所有其他模块,负责组装和启动
  • 接口层:依赖应用层,提供API接口
  • 应用层:依赖领域层,编排业务逻辑
  • 基础设施层:依赖领域层,提供技术实现
  • 领域层:不依赖其他层,保持纯粹性(视项目情况可以依赖一些工具层,不一定要保持绝对的纯粹)

架构效果:

  • 高内聚低耦合:通过事件实现模块间的松耦合
  • 可扩展性强:新功能可通过事件处理器无侵入添加
  • 可测试性好:各层职责清晰,便于单元测试
  • 可维护性高:业务逻辑集中在领域层,技术实现隔离在外层
  • 可演进性强:支持从单体到微服务的平滑迁移

设计原则:

  1. 按DDD分层架构组织
    • 每个模块对应DDD的一个层
    • 清晰的依赖关系:接口层 → 应用层 → 领域层 ← 基础设施层
    • 避免跨层依赖,保持架构的清晰性
  2. 按限界上下文组织
    • 每个限界上下文在领域层有独立的包结构
    • 相关的领域模型、事件、服务组织在一起
    • 便于理解和维护业务边界

1.1、领域层

领域层主要封装核心业务逻辑,不依赖任何外部技术,保持领域模型的纯粹性。

本项目以ddd-example-domain 模块作为领域层,是DDD架构的核心,包含所有业务逻辑和规则

  • 实体(Entity):具有唯一标识的业务对象,如 Order(订单)、OrderItem(订单项)
  • 值对象(Value Object):不可变的业务概念,如 Money(金额)、OrderStatus(订单状态)
  • 聚合根(Aggregate Root):聚合的入口点,确保业务一致性,如 Order 聚合根
  • 领域服务(Domain Service):处理跨聚合的业务逻辑,如 OrderDomainService
  • 仓储接口(Repository Interface):定义持久化契约,如 OrderRepository
  • 领域事件(Domain Event):记录重要的业务发生,如 OrderCreatedEvent

1.2、应用层

应用层职责为协调领域对象完成业务用例,处理事务边界,不包含业务规则。

本项目以ddd-example-application 模块作为应用层,负责业务流程的编排和用例实现

  • 应用服务(Application Service):编排领域对象完成业务用例,如 OrderApplicationService
  • 命令对象(Command):封装应用服务的输入参数,如 CreateOrderCommand、PayOrderCommand
  • DTO对象(Data Transfer Object):在层间传递数据,隔离领域对象,如 OrderDTO、OrderItemDTO
  • 装配器(Assembler):处理DTO与领域对象之间的转换,如 OrderAssembler
  • 事件处理器(Event Handler):处理领域事件,实现跨聚合的业务逻辑
  • 命令服务(Command Service):专门处理写操作,如创建、修改、删除订单
  • 查询服务(Query Service):专门处理读操作,如查询订单、统计信息等

1.3、基础设施层

基础设施层的职责是提供技术实现,隐藏技术细节,支持领域层和应用层的需求

本项目以 ddd-example-infrastructure 模块作为基础设施层,提供技术实现和外部系统集成:

  • 仓储实现(Repository Implementation):实现领域层定义的仓储接口,如 OrderRepositoryImpl
  • 数据对象(DO):对应数据库表结构的持久化对象,如 OrderDO、OrderItemDO
  • 对象转换器(Converter):处理DO与领域对象之间的转换,如 OrderConverter
  • 外部服务适配器:集成第三方系统,如支付网关、消息队列等
  • 配置管理:数据库连接、缓存配置等技术配置
  • 技术工具类:日志、监控、安全等基础设施组件

1.4、接口层

接口层的职责是对外提供标准化的API接口,处理用户交互,不包含业务逻辑

本项目以 ddd-example-interfaces 模块作为接口层,提供对外API接口和用户界面:

  • Facade:封装复杂的业务接口,提供简化的API,如 OrderFacade
  • 请求/响应对象:定义API的输入输出格式,如 CreateOrderRequest、OrderResponse
  • 统一响应对象:标准化API响应格式,如 Result<T>
  • 参数验证:API输入参数的校验和转换
  • API文档配置:Swagger配置,自动生成API文档
  • 异常处理:统一的异常处理和错误响应

1.5、启动模块

启动模块的职责是组装各个模块,配置运行环境,启动应用程序

本项目以 ddd-example-starter 模块作为启动模块,负责应用程序的组装、配置和启动:

  • 启动类:Spring Boot应用程序入口,如 DddExampleApplication
  • 全局异常处理:统一处理未捕获的异常,如 GlobalExceptionHandler
  • 配置文件:各环境的配置文件,如 application.yml、application-dev.yml
  • 依赖注入配置:Bean的装配和依赖关系配置
  • 中间件配置:数据库、缓存、消息队列等中间件配置
  • 监控和日志配置:应用监控、日志记录等配置

二、实践要点详解

上文介绍了几个模块的职责,现在继续进一步深入介绍DDD项目开发的具体实践。

2.1、六边形架构

六边形架构(又称 “端口与适配器架构”,Port and Adapter Architecture)由 Alistair Cockburn 于 2005 年提出,其核心思想是将系统划分为以领域逻辑为中心的层次,通过 “端口” 和 “适配器” 隔离外部依赖,使领域层独立于技术实现和外部交互方式。

  • 领域层(Domain Layer)在最内层,负责核心业务逻辑和规则,不依赖任何外部技术。一般会包含:实体、值对象、领域服务、仓储接口
  • 应用层(Application Layer)负责协调领域对象,实现用例。其依赖领域层,但不依赖基础设施。一般包含:应用服务、命令、查询、事件处理器
  • 接口层(Interface Layer)负责处理外部请求,转换为内部命令。其依赖应用层,实现具体的接口协议。一般包含:控制器、DTO、请求/响应转换
  • 基础设施层(Infrastructure Layer)负责实现技术细节,如数据库访问、消息队列等,其依赖领域层接口,提供具体实现。一般包含:仓储实现、消息队列、缓存、外部服务调用

依赖关系:接口层(ddd-example-interfaces) → 应用层(ddd-example-application) → 领域层(ddd-example-domain) ← 基础设施层(ddd-example-infrastructure)

2.2、限界上下文

通过划分边界确保上下文内的领域模型(实体、值对象、聚合等)语义一致、职责聚焦。

通俗点讲:战术设计阶段划分好上下文后,我们在开发阶段应该要设计好实体、值对象、聚合等类,让其没有二义性,并且这些定义只能在设定好的上下文中使用。

在本示例项目中,订单管理的限界上下文如下:

订单管理限界上下文包含:
├── 订单(Order):核心业务实体
├── 订单项(OrderItem):订单的组成部分
├── 订单状态(OrderStatus):订单的生命周期状态
├── 金额(Money):订单金额的表示
├── 订单仓储(OrderRepository):订单的持久化接口
└── 订单领域服务(OrderDomainService):跨实体的业务逻辑

2.2、聚合设计

聚合(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
}

2.3、领域事件

领域事件是发生在领域中的重要事情,用于跨聚合或上下文通信。事件是不可变的,并要按时间顺序执行。

领域事件的核心价值在于:以 “事件” 为纽带,将分散的业务环节串联起来,同时通过 “发布 - 订阅” 模式减少直接依赖,让领域模型更聚焦于自身业务规则,而非跨边界的协作逻辑。

为什么需要领域事件?因为在复杂的业务系统中,一个操作可能会触发多个后续动作。如:订单支付成功可能会触发发送通知、生成发票、开发发货等。

本示例项目中对领域事件的处理如下:

1、领域层的事件基础设施

  • DomainEvent接口定义了领域事件的基本属性,包括事件ID、发生时间、版本号和事件类型
  • DomainEventPublisher接口定义了事件发布的抽象方法,领域层只依赖接口不关心具体实现
  • AggregateRoot聚合根基类提供了事件收集和管理的能力,包括添加事件、清除事件等功能

2、具体的领域事件实现

  • 以OrderCreatedEvent为例,它实现了DomainEvent接口,事件中包含了必要的业务数据,如订单ID、用户ID、总金额等
  • 事件对象是不可变的,通过构造函数初始化所有属性
  • 也可以参考OrderPaidEvent和OrderCancelledEvent等其他订单相关事件

3、聚合根中的事件发布

  • 以Order类为例,继承自AggregateRoot,获得了事件管理能力
  • 在业务操作中(如创建订单、支付订单等)通过addDomainEvent方法添加相应事件
  • 事件暂存在聚合根中,等待统一发布

4、应用层Service中的事件发布

  • OrderEventPublisher实现了DomainEventPublisher接口,使用Spring的事件机制发布事件
  • 在OrderCommandServiceImpl中,每个命令操作完成后都会调用publishDomainEvents方法
  • 该方法会发布聚合根中收集的所有事件,然后清空事件列表
  • 发布过程是在事务中进行的,确保事件发布和业务操作的一致性

5、应用层的事件处理

  • OrderEventHandler使用@EventListener和@TransactionalEventListener注解处理各类事件
  • 事件处理采用了事务性发布(AFTER_COMMIT),确保事务提交后才处理事件
  • 支持异步处理(@Async注解)和错误处理机制

当前项目使用了Spring的事件机制(ApplicationEventPublisher),这种方式适合单体应用或简单的分布式场景。

如果要改造成为MQ方案,新增一个MessageQueueEventPublisher实现DomainEventPublisher接口,并增加MQ的消费者即可。

2.4、仓储模式

在领域驱动设计(DDD)中,仓储模式(Repository) 是连接领域模型(尤其是聚合根)与数据存储的中间层,它通过抽象数据访问细节,让领域模型专注于业务逻辑,而无需关心数据如何存储或读取。

实现步骤:

  1. 在领域层定义仓储接口,可参考示例代码中的OrderRepository。
  2. 在基础设施层完成仓储的具体实现,可以参考示例代码中的OrderRepositoryImpl。

仓储模式的核心作用:

  • 隔离技术细节:领域模型只需调用仓储接口(如orderRepository.save(order)),无需关心数据是存储在 MySQL、Redis 还是 MongoDB 中,也无需编写 SQL 语句。
  • 保持领域模型纯净:领域模型不直接依赖数据库访问代码,避免领域逻辑被数据访问逻辑污染(如贫血模型的问题)。例如,订单聚合根只需关注业务规则(如 “订单状态流转”),而不是 “如何将订单数据存入数据库”。
  • 支持聚合根完整性:仓储操作的对象是聚合根(而非单个实体),确保整个聚合的一致性。例如,保存订单时,仓储会自动保存订单下的所有订单项(聚合内的其他实体),保证数据完整性。
  • 简化测试:单元测试时可使用内存实现的仓储(如InMemoryOrderRepository),避免依赖真实数据库,提高测试效率。

注意:DDD中的仓储(Repository)和JPA中的Repository是不同的概念。

2.5、CQRS模式

CQRS是"命令查询职责分离"的缩写,它将系统中的操作分为命令(Command)和查询(Query)两类。

CQRS把查询分离出来的目的是为了应对高性能查询和复杂查询。

为什么需要CQRS?

在传统的CRUD模式中,读写操作使用同一个模型,但随着业务复杂度增加,这种模式会遇到以下问题:

  • 读写性能需求不同(读多写少,需要高性能查询的情况)
  • 查询需求复杂(多表关联、统计报表等,需要复杂查询的情况)
  • 数据一致性要求不同
  • 扩展性限制

具体实践:

  • 查询模块和命令模块的数据在存储上可以共享,也可以分离,甚至异构
  • 命令模块用DDD实现,查询模块不用DDD实现,以降低了领域模型的设计难度
  • 可以根据需要选择是否使用CQRS,在不同的限界上下文使用不同的实现策略。

在本示例项目在application层中分别建立了command和query包,用于创建命令和查询的服务。

为什么CQRS在application层中?

CQRS主要解决的是数据读写分离的问题,这种分离更多是针对应用场景的优化,而不是业务领域。

三、应对业务变化

DDD如何优雅地应对各种业务变化场景?在此基于本示例项目代码,列举几个场景进行说明。

3.1、业务规则变化

假设原始需求是"已支付订单不可取消",现在需求变更为"已支付订单在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));
    }
}

3.2、业务流程扩展

假设订单创建时需要增加库存检查,原始流程只需创建订单,现在需要增加库存检查步骤(库存和订单是不同领域)。

修改步骤:

  1. 在领域层使用领域服务处理跨聚合的业务逻辑,增加库存检查的方法。
  2. 在应用服务层的创建订单逻辑中调用领域层的库存检查方法进行校验。
// 应用服务层
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) {
        // 库存检查逻辑
        // 若库存不足则抛出领域异常
    }
}

3.3、外部系统集成

假设订单支付接入新的支付渠道,需要支持新的支付方式,如微信支付。

// 领域层定义支付接口
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中的应用不是机械的教条式遵守,而是通过领域模型的合理设计自然地体现出来。在实际开发中,这些原则不是孤立的,而是相互配合、彼此支持,共同构建起健壮的领域模型。

  • 单一职责原则:设计目的单一的类
  • 开放-封闭原则:对扩展开放,对修改封闭
  • 李氏(Liskov)替换原则:子类可以替换父类
  • 依赖倒置原则:要依赖抽象,而不是具体实现;正对接口编程,不要针对现实编程
  • 接口隔离原则:使用多个专门的接口比使用单一的总接口要好
  • 组合重用原则:要尽量使用组合,而不是继承关系达到重用目的
  • 迪米特(Demeter)原则(最少知识法则):一个对象应当对其他对象有尽可能少的了解

4.1、单一职责

含义:一个类 / 对象应仅负责一个业务职责,当职责变化时,仅有一个原因导致其修改。(一个对象只做一件事

单一职责在项目中多个层面都有体现:

  • 实体 / 值对象:每个实体或值对象只聚焦自身的核心业务属性与行为。
  • 聚合(Aggregate):聚合根的职责是 “维护聚合内的业务一致性”,不应承担聚合外的职责。
  • DDD的分层架构:
    • 领域层:专注于业务规则和领域逻辑
    • 应用层:专注于用例编排
    • 基础设施层:专注于技术实现
    • 接口层:专注于API暴露

4.2、开闭原则

含义:软件实体(类、模块)应允许扩展新功能,但不允许修改原有代码。(对扩展开放,对修改关闭

在DDD应用中的体现:

  • 领域行为的扩展:通过抽象类或接口定义领域行为,用子类 / 实现类扩展新场景。
  • 领域事件的多态处理:领域事件(Domain Event)通过接口定义,不同事件类型对应不同处理器(EventHandler)。

4.3、李氏替换原则

含义:子类对象应能替换父类对象在程序中出现的任何位置,且不改变程序的正确性。(子类可替换父类,不破坏原逻辑)

这一原则在实体和值对象中体现:

// 基础实体类
public abstract class Entity<ID> {
    protected ID id;
    
    @Override
    public boolean equals(Object obj) {
        // 基于ID的相等性比较
    }
}

// 具体实体类
public class Order extends Entity<String> {
    // 订单特有的属性和行为
}

 

4.4、接口隔离原则

含义:不应强迫客户端依赖其不需要的接口,应将大接口拆分为多个小接口,每个接口服务于特定场景。(接口应小而专,避免冗余

这一原则在DDD中有如下体现:

  • 仓储接口的拆分:仓储接口应根据业务操作场景拆分,避免 “大而全”。
  • 限界上下文的接口隔离:每个限界上下文的对外接口应仅暴露本上下文需对外提供的能力,避免暴露内部实现细节。

4.5、依赖倒置原则

含义:高层模块不应依赖低层模块,两者都应依赖抽象;抽象不应依赖细节,细节应依赖抽象。(依赖抽象,而非具体)

这一原则在DDD中有如下体现:

  • 领域层依赖抽象,基础设施层实现细节:领域层定义仓储接口(如OrderRepository),基础设施层(如JpaOrderRepository)实现接口。领域层的业务逻辑(如OrderService)只依赖OrderRepository接口,不依赖具体的 JPA 或 MyBatis 实现,便于替换数据库技术(如从 MySQL 迁移到 MongoDB)。
  • 应用层依赖领域抽象:应用服务(Application Service)协调领域对象完成业务流程,依赖的是领域层的实体、聚合根或领域服务的抽象(而非具体实现)。

4.6、组合重用原则

含义:通过 “对象组合” 实现功能复用,而非过度依赖继承(避免继承带来的紧耦合和复杂性)。

这一原则在 DDD 的 “聚合” 设计中体现得尤为明显。

  • 聚合的本质是 “组合”:聚合通过将多个实体(Entity)和值对象(Value Object)组合成一个整体,实现业务上的 “强关联”。例如,订单聚合(Order)组合了订单项(OrderItem,实体)、收货地址(Address,值对象)、支付信息(PaymentInfo,值对象)等,这些对象无法脱离订单独立存在,通过组合形成 “订单” 这一完整业务概念。
  • 避免用继承实现聚合关系:若用继承(如Order继承OrderItem),会导致订单与订单项的职责混淆,且无法表达 “一个订单包含多个订单项” 的多对一关系。而组合通过关联关系(如List<OrderItem>)清晰表达这种业务关系,更灵活且符合真实业务场景。

4.7、迪米特法则

含义:一个对象应当对其他对象有尽可能少的了解。其实就是信息屏蔽。

这一原则通过聚合根和限界上下文的边界控制的一体现,如:

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();
    }
}

 

 

 

 

 

全部评论