领域驱动设计(DDD)“老中医”治理订单

什么?有人说ddd不好懂!咱们今天就来一场DDD的"脱口秀",把这块硬骨头嚼碎了咽下去。

  • 咱们之前做的实惠惠商场开始那么令人崩溃,还记得吗?作为其中的首席架构师(兼背锅侠)。公司早期的系统是一个巨大的、令人窒息的"单体应用",就像一锅所有食材都混在一起的"东北乱炖"。销售说"产品"要打折,仓库说"产品"太重了搬不动,财务说"产品"的成本算不清。一个词,三种理解,天天开会吵架。

一般这种时候都会从天而降一位天才,so领域驱动设计(DDD)咱们这位"老中医"登场了,它开出的药方就是战略设计战术设计

第一部分:战略设计------划定地盘,统一语言

战略设计就像是给你的业务王国画地图,明确哪里是首都,哪里是边疆,大家各自说什么话。

1. 限界上下文 (Bounded Context) ------ "划江而治"

还记得那锅"乱炖"吗?问题的根源就在于没有边界。DDD说:"别吵了,咱们分家!"

  • 核心思想: 把一个庞大复杂的系统,按照业务职能划分成一个个独立的、有明确边界的区域。每个区域内部,模型和规则都是自洽的,互不干扰。
  • 人话解释: 这就是给你的业务"划地盘"。
    • 销售上下文 (Sales Context): 这里是"产品"的天堂。Product 类关心的是 价格促销活动商品描述
    • 物流上下文 (Shipping Context): 这里是"产品"的健身房。Product 类(可能叫 ShippableItem)只关心 重量尺寸是否易碎
    • 库存上下文 (Inventory Context): 这里是"产品"的仓库。它只关心 剩余数量存放货架号

看,同样是"产品",在不同地盘里,身份和职责完全不同。这样,销售改个价格,再也不用担心会搞崩仓库的秤。

2. 通用语言 (Ubiquitous Language) ------ "说同一种黑话"

划清了地盘,接下来要解决"鸡同鸭讲"的问题。DDD开出的第二剂药方是:建立通用语言

  • 核心思想: 在同一个限界上下文内,开发人员、产品经理、业务专家必须使用完全相同的词汇来描述业务。这些词汇不仅是口头上的,更要直接体现在你的代码里(类名、方法名、变量名)。
  • 人话解释: 告别"这个字段你懂我意思吧"。
    • 在销售上下文中,业务方说"提交订单",你的代码里就应该有一个 Order.submit() 方法,而不是 Order.create()Order.save()
    • 如果说"取消订单",那就该是 order.cancel(),而不是 order.setStatus(OrderStatus.CANCELLED)

这样做的好处是,当业务方说"下单时如果金额超过100块,就免运费",你和你的团队能立刻心领神会,并在 Order 聚合里实现这个逻辑,沟通成本大大降低。

3. 子域划分 (Subdomain) ------ "排兵布阵"

地盘划好了,但不能平均用力。你需要识别出哪些是"命根子",哪些是"螺丝钉"。

子域类型 角色定位 特点与策略 举例 (电商场景)
核心域 (Core Domain) 王牌部队 公司的核心竞争力所在,最复杂、最具差异化。投入最精锐的团队,采用最严谨的DDD设计。 交易链路:下单、支付、促销计算。这是电商的灵魂。
支撑域 (Supporting Domain) 后勤保障 为核心域提供必要的支持,很重要但并非独有。可以采用相对简单的架构,甚至外包开发。 用户管理、评论系统。
通用域 (Generic Domain) 水电煤 任何行业都需要的通用功能,毫无技术壁垒。坚决不自研,买现成的或用开源方案。 权限系统 (RBAC)、日志服务、邮件发送。

把最强的兵力投入到核心域,确保你的竞争优势牢不可破。

第二部分:战术设计------精兵强将,各司其职

战略定好了,现在该派兵遣将,在具体战场上打仗了。这就是战术设计,也叫"充血模型"的落地。

1. 实体 (Entity) vs 值对象 (Value Object) ------ "有身份证的" vs "没身份的"
  • 实体 (Entity): 有唯一标识,关注其生命周期。
    • 特点: 即使属性全变了,只要ID不变,它就是同一个东西。比如 User(用户)、Order(订单)。用户改了头像和昵称,还是他本人;订单从"待支付"变成"已完成",也还是那个订单。
  • 值对象 (Value Object): 没有唯一标识,通过属性值定义自身,且是不可变的。
    • 特点: 一旦创建就不能修改,要改只能换个新的。它通常作为实体的属性存在。比如 Address(地址)、Money(金额)。
    • 为什么? 想想看,你把收货地址从"北京"改成"上海",这本质上是创建了一个新地址,替换了旧地址。把它设计成不可变的值对象,可以避免很多并发修改带来的Bug,也更安全。
2. 聚合与聚合根 (Aggregate & Aggregate Root) ------ "家族族长"

这是DDD战术设计的灵魂!

  • 聚合 (Aggregate): 一组相关对象的集合,被视为一个数据修改的最小单元。你可以把它想象成一个"家族"。
  • 聚合根 (Aggregate Root): 家族的"族长",是外部世界访问这个家族的唯一入口。
  • 核心规则:
    1. 外人不能私闯民宅: 外部代码只能通过聚合根的方法来操作聚合内部的成员,不能直接引用内部的其他实体或值对象。
    2. 族长维护家规: 聚合根负责保证整个"家族"的数据一致性。所有业务规则都由它来守护。
    3. 一族之长,唯我独尊: 一个事务只能修改一个聚合,保证聚合内的强一致性。

举个例子:订单 (Order) 就是一个典型的聚合根。

它包含 订单项 (OrderItem)收货地址 (Address)订单金额 (Money) 等。

你不能绕过 Order 直接去修改 OrderItem 的数量。你必须调用 order.addItem(product, quantity),由 Order 自己来更新 OrderItem 列表,并重新计算总金额。这样就保证了"订单总金额 = 所有订单项小计之和"这个业务规则永远不会被破坏。

3. 仓储 (Repository) ------ "家族档案管理员"
  • 职责: 负责聚合的持久化和检索。它就像一个档案管理员,你告诉他"我要找ID为123的订单家族",他就去数据库里把整个 Order 聚合取出来给你。
  • 关键点: 仓储的接口定义在领域层,但具体实现(比如是用MySQL还是MongoDB)在基础设施层。这样领域层就和具体的数据库技术解耦了。
4. 领域服务 (Domain Service) ------ "跨部门协调员"
  • 职责: 当一个业务逻辑涉及多个聚合,不适合放在任何一个聚合内部时,就交给领域服务。
  • 举例: "订单金额计算"可能需要结合 订单优惠券会员等级 等多个聚合的信息。这个复杂的计算逻辑就可以放在 PricingDomainService 中。注意,它处理的是核心业务逻辑,不是简单的流程编排。

第三部分:贫血 vs 充血 ------ MVC的痛与DDD的药

传统MVC为什么是"贫血模型"?

在经典的MVC三层架构中,我们通常是这样干的:

  • Controller层: 接收请求,参数校验。
  • Service层: 业务逻辑的大本营,各种 if-else 都在这里。
  • DAO/Model层: 纯粹的数据载体,只有 getter/setter,没有任何行为。

这就好比一个公司,所有的决策都由一个庞大的"服务中心"做出,底下的员工(实体对象)全是只会听指令干活的木偶,没有任何主观能动性。这种模型被称为贫血模型 (Anemic Model)

它的致命弱点是:

  • 业务逻辑泄露: 本该属于 Order 的"取消"逻辑,却散落在 OrderService 里,导致代码难以理解和维护。
  • 违反封装原则: 对象的内部状态可以被随意修改,业务规则形同虚设。
DDD如何实现"充血模型"?

DDD要把权力下放,让每个对象都"活"起来,为自己的行为和状态负责。这就是充血模型 (Rich Domain Model) 这块设计yyds;其实之前也有过这么设计,现在取百家之长!

对比一下:

❌ 贫血模型 (传统MVC):

复制代码
// Order.java - 纯粹的"数据袋"
public class Order {
    private Long id;
    private String status;
    // ... 只有一堆 getter/setter
}

// OrderService.java - 臃肿的"上帝服务"
public class OrderService {
    public void cancelOrder(Long orderId) {
        Order order = orderRepository.findById(orderId);
        if ("PAID".equals(order.getStatus())) {
            throw new RuntimeException("已支付订单不能取消");
        }
        order.setStatus("CANCELLED");
        orderRepository.save(order);
        // ... 可能还有其他通知逻辑
    }
}

✅ 充血模型 (DDD):

复制代码
// Order.java - 有血有肉的"聚合根"
public class Order {
    private OrderId id;
    private OrderStatus status; // 使用值对象或枚举

    // 核心业务行为封装在实体内部
    public void cancel() {
        if (this.status == OrderStatus.PAID) {
            throw new IllegalStateException("已支付订单不能取消");
        }
        this.status = OrderStatus.CANCELLED;
        // 可以触发领域事件,如 OrderCancelledEvent
    }
    // ... 其他行为,如 addItem(), calculateTotal()
}

// OrderApplicationService.java - 瘦小的"流程编排者"
public class OrderApplicationService {
    @Autowired
    private OrderRepository orderRepository;

    @Transactional
    public void cancelOrder(Long orderId) {
        // 1. 从仓储获取聚合根
        Order order = orderRepository.findById(new OrderId(orderId));
        // 2. 调用聚合根的业务方法
        order.cancel();
        // 3. 仓储保存变更 (或直接提交事务)
    }
}

看到了吗?在DDD的充血模型里,cancel 这个动作以及它所附带的业务规则("已支付不能取消"),被完整地封装在了 Order 对象内部。应用服务 (OrderApplicationService) 的角色变得非常清晰:它只负责编排流程(找到订单 -> 让它取消 -> 保存),而不包含任何核心业务逻辑。

这样一来,你的代码就不再是一盘散沙,而是变成了高度内聚、职责分明、易于测试和维护的精锐部队。

第四部分正面教材:DDD 的"充血模型"设计

为了更好的理解DDD,我们选取一个最经典、最容易翻车的场景:电商下单 。我们将使用 java来演示;什么语言都一样,重点在于:把逻辑还给对象,Service 只负责流程编排。

1. 值对象 (Value Objects) ------ 让数据有规矩

首先,我们定义不可变的值对象。这能防止"脏数据"进入系统。

复制代码
import java.math.BigDecimal;

// 使用 record (Java 14+) 或者 final class 实现不可变性
public record Money(BigDecimal amount) {
    public Money {
        if (amount.compareTo(BigDecimal.ZERO) < 0) {
            throw new IllegalArgumentException("金额不能为负数");
        }
    }

    public Money add(Money other) {
        return new Money(this.amount.add(other.amount));
    }
}
2. 聚合根 (Aggregate Root) ------ 核心主角

这是最关键的部分。注意看 Order 类如何通过方法保护内部状态。

复制代码
import java.util.ArrayList;
import java.util.List;

public class Order {
    private Long id;
    private OrderStatus status; // 使用枚举代替 String,更安全
    private Money totalAmount;
    private final List<OrderItem> items = new ArrayList<>(); // 封装集合

    // --- 构造函数私有化,强制通过工厂方法或特定行为创建 ---
    protected Order() {} // 供 JPA/Hibernate 反射使用

    public Order(Long id) {
        this.id = id;
        this.status = OrderStatus.PENDING;
        this.totalAmount = new Money(BigDecimal.ZERO);
    }

    // --- 核心业务行为 (充血的核心) ---

    /**
     * 添加商品
     * 外部无法直接操作 items 列表,必须通过这个方法来保证逻辑闭环
     */
    public void addItem(Product product, int quantity) {
        // 1. 校验库存或数量逻辑
        if (quantity <= 0) throw new IllegalArgumentException("数量必须大于0");
        
        // 2. 添加到内部列表
        OrderItem item = new OrderItem(product, quantity);
        this.items.add(item);
        
        // 3. 【关键点】自动触发重算,保证"总金额=子项之和"这个不变量
        // 调用addItem必须调用这一方法,自动的来源;order.addItem(productA, 1);调用这个方法总价会自动变
        this.recalculateTotal();
    }

    /**
     * 取消订单
     * 业务规则被封装在这里,谁调用都有效
     */
    public void cancel() {
        // 1. 状态机检查 (Guard Clause)
        if (this.status == OrderStatus.PAID) {
            throw new IllegalStateException("订单已支付,无法取消");
        }
        if (this.status == OrderStatus.SHIPPED) {
            throw new IllegalStateException("订单已发货,无法取消");
        }
        
        // 2. 变更状态
        this.status = OrderStatus.CANCELLED;
        
        // 3. 发布领域事件 (可选,用于解耦后续流程,如发送通知)
        // DomainEventPublisher.publish(new OrderCancelledEvent(this.id));
    }

    // --- 内部辅助方法 ---
    
    // 私有方法,只允许内部调用,确保计算逻辑不外泄且必然执行
    private void recalculateTotal() {
        Money sum = new Money(BigDecimal.ZERO);
        for (OrderItem item : items) {
            sum = sum.add(item.getSubTotal());
        }
        this.totalAmount = sum;
    }

    // --- 只读访问器 ---
    // 返回不可修改的列表,防止外部偷偷 add/remove
    public List<OrderItem> getItems() {
        return new ArrayList<>(items); 
    }
    
    public OrderStatus getStatus() { return status; }
    public Money getTotalAmount() { return totalAmount; }
}

✨ 亮点解析:

  • 高内聚cancel() 方法里写了死规则。不管是在 Controller、定时任务还是脚本里调用 order.cancel(),规则都会被强制执行。
  • 数据一致性 :你没法绕过 addItem 去修改列表,也没法绕过 recalculateTotal 去直接设金额。
  • 无 Setter :你会发现 totalAmount 没有 set 方法,它是根据业务行为自动算出来的。
  • 在任何时刻,只要你拿到 order 对象,它的 totalAmount 永远等于 items 的总和。这个规则(不变量)被代码结构死死地保护住了,神仙也破坏不了。
3. 仓储 (Repository) ------ 基础设施

接口定义在领域层,实现在基础设施层。

复制代码
// domain/repository/OrderRepository.java (接口)
public interface OrderRepository {
    Order findById(Long id);
    void save(Order order);
}

// infrastructure/persistence/JpaOrderRepository.java (实现 - Spring Data JPA)
@Repository
public interface JpaOrderRepository extends JpaRepository<Order, Long>, OrderRepository {
    // Spring Data JPA 会自动生成实现
}
4. 应用服务 (Application Service) ------ 指挥官

最后看 Controller 调用的地方。它变得非常干净,只负责:取数据 -> 调行为 -> 存数据。

复制代码
@Service
@RequiredArgsConstructor // Lombok 注解,自动注入构造器参数
public class OrderAppService {

    private final OrderRepository orderRepository;

    /**
     * 创建订单用例
     */
    @Transactional
    public Order createOrder(CreateOrderCmd cmd) {
        // 1. 实例化聚合根
        Order order = new Order(cmd.getUserId());
        
        // 2. 指挥聚合根干活 (把具体的计算逻辑委托给 Order)
        for (OrderItemDTO itemDto : cmd.getItems()) {
             // 这里假设 Product 也是充血模型,或者直接传 ID 和价格
             order.addItem(itemDto.getProduct(), itemDto.getQuantity());
        }
        
        // 3. 持久化
        return orderRepository.save(order);
    }

    /**
     * 取消订单用例
     */
    @Transactional
    public void cancelOrder(Long orderId) {
        // 1. 获取聚合根
        Order order = orderRepository.findById(orderId);
        
        // 2. 执行业务动作 (逻辑全在 Order 类里,Service 根本不需要知道具体规则)
        order.cancel(); 
        
        // 3. 保存 (JPA 会检测脏数据并更新,或者手动 save)
        orderRepository.save(order);
    }
}
特征 贫血模型 (Anemic) 充血模型 (Rich/Rich Domain Model)
类的方法 全是 getXXX(), setXXX() addItem(), pay(), cancel() 等动词方法
属性修改 任意位置都可以 obj.setXxx() 只能通过特定的业务方法修改,字段通常是 private final
Service 层 包含大量的 if-else 和业务计算 只有简单的流程编排(获取->执行->保存)
安全性 容易造出"非法对象"(如金额为负的订单) 构造函数和方法保证了对象一旦创建就是合法的

用面向对象的多态和封装特性,去对抗复杂业务的熵增。

相关推荐
张元清1 小时前
React 浏览器标签页 UX:用标题、Favicon 和通知把用户拉回来
前端·javascript·面试
YF02111 小时前
Protobuf与 gRPC 的关系:从理论到 Android + Go 实战通信全解析
android·后端·grpc
Lkstar1 小时前
读完红宝书和YDKJS,我终于搞懂了原型链、闭包和this
javascript·面试
ZJY1321 小时前
2-1:在NestJS中使用mikro-orm
后端·nestjs
Cosolar1 小时前
大模型量化技术全景深度解析:从FP16到INT4的完整演进与实战落地
人工智能·面试·架构
亚空间仓鼠1 小时前
Docker容器化高可用架构部署方案
docker·容器·架构
贺国亚1 小时前
Kafka 调优与运维实战
后端·kafka
何陋轩1 小时前
Spring AI + RAG实战:打造企业级智能问答系统
后端·算法·设计模式
Mahir1 小时前
面试被问 MySQL 慢 SQL 怎么排查?看完这篇直接给面试官讲明白
面试