3. 需求分析入门

上一节我们聊了需求分析怎么从模糊的业务描述落到功能列表。但说实话,光有功能列表还不够——你拿到的很可能是一堆散落的「需求碎片」。怎么把这些碎片拼成一张可落地的架构蓝图?我个人习惯的做法,是引入领域驱动设计(DDD)的思路。

别被「领域驱动」这四个字吓到。说白了,DDD 就是一套帮我们把业务语言翻译成代码语言的方法论。我最早接触 DDD 是在一个电商中台项目里,当时团队被各种「订单」、「子订单」、「父订单」的概念绕得晕头转向。后来我们用 DDD 重新梳理了一遍,才发现很多所谓的「订单」根本不在同一个业务上下文里。

为什么需要限界上下文?

先问一个问题:一个电商系统里,「商品」这个词在不同模块里含义一样吗?

在库存模块,「商品」关心的是 SKU、库存数量、仓库位置。在营销模块,「商品」关心的是价格、促销标签、活动分组。在物流模块,「商品」关心的是重量、体积、易碎等级。你看,同一个词,在不同场景下关注的点完全不同。

这就是限界上下文要解决的问题——给每个概念划定一个清晰的边界,边界内保持概念的一致性,边界外允许概念有不同的含义。

核心原则: 限界上下文是 DDD 的基石。没有清晰的边界,你的模型就会变成一个大泥球。

我在一个金融项目中遇到过这样的坑:团队把「客户」这个概念放在一个全局模型里,结果风控、客服、销售三个部门对「客户」的定义完全不一样。风控要的是「黑名单客户」,客服要的是「投诉客户」,销售要的是「潜在客户」。强行用一个模型去覆盖所有场景,最后代码里全是 if-else 判断,维护成本直线飙升。

如何识别限界上下文?

识别限界上下文,我一般分三步走:

  1. 找业务事件——梳理业务流程中发生了哪些关键事件。比如「订单已创建」、「支付已确认」、「库存已扣减」。
  2. 找业务角色——谁在操作?谁在决策?比如「采购员」、「仓管员」、「客服专员」。
  3. 找业务规则——不同角色对同一数据的处理规则是否一致?如果不一致,大概率需要拆成不同的上下文。

举个例子,一个简单的在线教育系统,我通常会识别出这几个限界上下文:

限界上下文 核心职责 关键概念
课程管理 课程创建、章节编排、内容上传 课程、章节、课件
学习中心 学员选课、学习进度、作业提交 学员、选课记录、学习进度
考试系统 试卷生成、在线考试、自动判分 试卷、试题、答题卡
支付结算 订单生成、支付对接、退款处理 订单、支付单、退款单

你看,同样是「课程」,在课程管理上下文里它是被编辑的对象,在学习中心上下文里它是被消费的内容。分开建模,各自演进,互不干扰。

实体 vs 值对象:别再傻傻分不清

识别出限界上下文之后,下一步就是建模。建模时最基础也最容易搞混的两个概念,就是实体和值对象。

实体(Entity):有唯一标识,且会随着时间变化。比如一个「用户」,不管他改了多少次名字、换了多少次手机号,他还是那个用户——因为他的用户 ID 没变。

值对象(Value Object):没有唯一标识,只描述某个属性的值。比如「地址」,如果两个地址的省、市、区、街道完全一样,那它们就是同一个地址,不需要区分谁是谁。

判断小技巧: 问自己一个问题——如果两个对象的所有属性都一样,它们还能被认为是不同的吗?如果能,就是实体;如果不能,就是值对象。

我曾经在一个项目中看到有人把「金额」设计成实体,给每个金额配了一个 ID。这明显不合理——100 块钱就是 100 块钱,不需要区分「100 块钱 A」和「100 块钱 B」。金额应该是值对象,不可变,直接比较数值即可。

实战建模:一个订单系统的例子

我们拿一个简化版的订单系统来练练手。假设我们在「交易上下文」里建模:

实体:

  • 订单(Order)——有订单号(唯一标识),状态会从「待支付」变成「已支付」再变成「已发货」。
  • 买家(Buyer)——有用户 ID,个人信息可以修改。

值对象:

  • 订单项(OrderItem)——描述买了什么商品、数量多少、单价多少。两个订单项如果商品 ID、数量、单价都一样,那它们就是相同的。
  • 收货地址(ShippingAddress)——省市区街道+收件人+电话,完全一致就是同一个地址。
  • 金额(Money)——金额+币种,不可变。
注意: 值对象一定要设计成不可变的。如果你允许修改值对象的属性,那引用它的地方就会出现数据不一致。我见过有人把地址设计成可变的,结果订单发货后地址被改了,货发到了错误的地方。

代码示例(Java 风格,仅做示意):

// 实体:订单
public class Order {
    private OrderId orderId;      // 值对象作为 ID
    private Buyer buyer;          // 实体引用
    private List<OrderItem> items; // 值对象列表
    private ShippingAddress address; // 值对象
    private OrderStatus status;   // 枚举,也是值对象
    
    public void changeAddress(ShippingAddress newAddress) {
        // 直接替换,不修改原对象
        this.address = newAddress;
    }
}

// 值对象:金额
public final class Money {
    private final BigDecimal amount;
    private final String currency;
    
    public Money(BigDecimal amount, String currency) {
        this.amount = amount;
        this.currency = currency;
    }
    
    // 只提供 getter,不提供 setter
    // 运算返回新对象
    public Money add(Money other) {
        // 校验币种一致
        return new Money(this.amount.add(other.amount), this.currency);
    }
}

注意看,Order 的 changeAddress 方法直接替换了整个地址对象,而不是修改地址内部的属性。这就是值对象不可变性的体现。

限界上下文与实体/值对象的关系

最后,我用一张图来总结这三者的关系:

限界上下文与建模元素关系图 课程管理上下文 实体:课程 ID: courseId 值对象:章节 标题 + 序号 + 视频URL 值对象:课件 类型 + 文件URL 学习中心上下文 实体:学员 ID: userId 实体:选课记录 ID: enrollmentId 值对象:学习进度 章节ID + 完成百分比 支付结算上下文 实体:订单 ID: orderId 实体:支付单 ID: paymentId 值对象:金额 数值 + 币种 关键原则 同一概念在不同上下文中可以是不同的建模元素 「课程」在课程管理中是实体,在学习中心中可能只是值对象引用

这张图展示了三个限界上下文各自维护自己的实体和值对象。注意看,不同上下文之间通过虚线连接,表示它们之间存在协作关系,但各自的数据模型是独立的。这就是 DDD 的核心思想——每个上下文内部保持高内聚,上下文之间通过事件或接口进行低耦合通信。