DDD领域驱动设计

zy123
2025-06-27 /  0 评论 /  0 点赞 /  12 阅读 /  7053 字
最近更新于 09-08

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 是聚合根,OrderLineAddress 在聚合内;总价计算、状态流转等不变式在一次事务里由 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 服务所需的各类零部件(模型、仓储、工厂),也可以被看做充血模型。

  • 同时我们还会再一个同类的类下,提供对应的内部类,如用户实名,包括了通信类、实名卡、银行卡、四要素等。它们都被写进到一个用户类下的内部子类,这样在代码编写中也会清晰的看到子类的所属信息,更容易理解代码逻辑,也便于维护迭代。

我的实体类本身还是偏贫血模型,主要负责承载数据和基本的不变式校验。**但在领域层里,我会把仓储、领域服务和实体组合在一起,所有业务逻辑都在领域模型中闭环实现,不会散落到外层,这样整体上就是充血思想。**实体保证自身一致性,复杂逻辑交给领域服务来实现。

限界上下文

限界上下文是指一个明确的边界,规定了某个子领域的业务模型和语言,确保在该上下文内的术语、规则、模型不与其他上下文混淆。是一个 业务设计概念

表达 语义环境 实际含义
"我吃得很饱,现在不能动了" 日常用餐 字面意思:吃到肚子很满
"我吃得很饱,今天的演讲让人充实" 知识分享 比喻:得到了很大满足

限界上下文的作用

  1. 定义业务边界:类似于语义环境,为通用语言划定范围
  2. 消除歧义:确保团队对领域对象、事件的认知一致
  3. 领域转换:同一对象在不同上下文有不同名称(goods在电商称"商品",运输称"货物")
  4. 模型隔离:防止不同业务领域的模型相互干扰

在代码工程里,每个上下文拥有独立包结构

领域模型

指特定业务领域内,业务规则、策略以及业务流程的抽象和封装。在设计手段上,通过风暴模型拆分领域模块,形成界限上下文。最大的区别在于把原有的众多 Service + 数据模型的方式,拆分为独立的有边界的领域模块。每个领域内创建自身所属的;领域对象(实体、聚合、值对象)、仓储服务(DAO 操作)、工厂、端口适配器Port(调用外部接口的手段)等。

image-20250625153340701

  • 在原本的 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)保持不变,确保实体的唯一性。

实体对象通常在代码中以实体类的形式存在,并且通常采用 充血模型 实现,即将与该实体相关的业务逻辑和行为写入实体类中,而不仅仅是存储数据。

值对象

值对象是没有唯一标识的业务对象,具有以下特征:

  1. 创建后不可修改(immutable)
  2. 只能通过整体替换来更新
  3. 通常用于描述实体的属性和特征

在开发值对象的时候,通常不会提供 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 推送等),封装所有外部资源的访问细节。

仓储解耦的手段使用了依赖倒置的设计。

image-20250625162115367

示例: 只定义接口,由基础设施层来实现。

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架构设计

四层架构

  1. 用户接口层interface:处理用户交互和展示
  2. 应用层application:协调领域对象完成业务用例
  3. 领域层domain:包含核心业务逻辑和领域模型
  4. 基础设施层infrastructure:提供技术实现支持
image-20250623170005859

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

image-20250623170403189

六边形架构

image-20250625163146809

领域模型设计

image-20250625163456525

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

image-20250625164007931

© 版权声明
THE END
喜欢就支持一下吧
点赞 0 分享 收藏
评论 抢沙发
取消