05-DDD:对象建模第一课:实体和值对象到底怎么分

📅 2026/7/13 20:54:02
05-DDD:对象建模第一课:实体和值对象到底怎么分
一、大白话靠身份还是靠内容区分判断一个领域概念是实体还是值对象就问一个问题“我关心它是哪一个”还是只关心它是什么样的关心是哪一个有唯一身份要追踪它的变化→实体Entity只关心是什么样的只看属性值谁是谁无所谓→值对象Value Object用钱举例最经典你钱包里有两张 100 元。你会在乎这一张具体是哪张 100 元吗不会。只要面额是 100 元哪张都一样可以随意交换。钱的金额就是值对象——只看值不看身份。用订单举例“优选门店的一笔订单就是实体。即使两笔订单金额、商品完全一样它们也是两笔不同的订单各有各的订单号要分别追踪状态。你绝不能说这两笔订单一样就合成一笔”。订单靠身份订单号区分是实体。二、先补一个前置概念身份、属性、生命周期新手常把数据库有没有 ID当成判断标准这会误导你。DDD 里判断实体和值对象先看业务语义概念大白话例子身份这个东西是不是同一个很重要订单号、会员 ID属性这个东西长什么样金额、币种、省市区生命周期它会不会经历状态变化订单从待支付到已支付判断顺序建模边界单个限界上下文内控制流开始判断关键判断是控制流否关键判断是不确定补充业务语境重新建模错误路径只看数据库 ID1. 识别领域概念如订单/金额/地址2. 是否追踪身份?3. 实体 Entity用 ID 判断同一性4. 是否只关心属性值?5. 值对象 Value Object用全部属性判断相等6. 回到上下文确认概念职责7. 风险点脱离上下文硬分类关键同一个词在不同上下文里可能判断不同。地址簿里的地址要增删改查、有 ID像实体订单里的收货地址只是下单快照像值对象。三、实体Entity特征有唯一标识ID靠 ID 判断是否是同一个。有生命周期属性会随时间变化订单从待支付变成已支付再变成已发货。即使所有属性都变了只要 ID 不变它还是它。优选门店里的实体订单Order、商品Product、会员Member、库存项StockItem。publicclassOrder{privatefinalOrderIdid;// 唯一标识创建后不变privateOrderStatusstatus;// 会变化的状态privateListOrderItemitems;privateMoneytotalAmount;// 实体的相等性只看 IDOverridepublicbooleanequals(Objecto){if(thiso)returntrue;if(!(oinstanceofOrder))returnfalse;returnthis.id.equals(((Order)o).id);}OverridepublicinthashCode(){returnid.hashCode();}}关键实体的equals/hashCode只比较 ID不比较其他属性。因为身份才是它的本质。四、值对象Value Object特征没有独立 ID靠全部属性的值来判断相等。不可变Immutable——一旦创建就不能改要变就整个换一个新的。可以自由复制、共享、替换。优选门店里的值对象金额Money、收货地址Address、商品规格Specification、数量Quantity。为什么值对象要不可变想象Money如果可变两笔订单不小心共享了同一个Money对象一笔订单改了金额另一笔的金额跟着变了——灾难。不可变就杜绝了这种共享带来的诡异 bug。要改造一个新的Money替换老的原封不动。代码把金额做成值对象这是 DDD 里最值得学的一个模式。看传统写法的弊端先// 传统写法金额到处用 BigDecimal 裸奔BigDecimaltotalprice.multiply(newBigDecimal(qty));// 问题1币种呢人民币和美元能直接相加吗没人管。// 问题2四舍五入规则每个人写一套不一致。// 问题3金额校验不能为负散落各处漏一处就出 bug。用值对象封装publicfinalclassMoney{privatefinalBigDecimalamount;privatefinalCurrencycurrency;privateMoney(BigDecimalamount,Currencycurrency){if(amountnull||currencynull){thrownewIllegalArgumentException(金额和币种不能为空);}// 业务规则1金额不能为负在这里统一守护if(amount.compareTo(BigDecimal.ZERO)0){thrownewIllegalArgumentException(金额不能为负);}// 业务规则2统一保留2位小数、四舍五入this.amountamount.setScale(2,RoundingMode.HALF_UP);this.currencycurrency;}publicstaticfinalCurrencyCNYCurrency.getInstance(CNY);publicstaticfinalMoneyZERO_CNYMoney.of(BigDecimal.ZERO,CNY);// 工厂方法后续章节统一用 of(amount, currency) / ofYuan(yuan)publicstaticMoneyof(BigDecimalamount,Currencycurrency){returnnewMoney(amount,currency);}publicstaticMoneyofYuan(BigDecimalyuan){returnnewMoney(yuan,CNY);}// 运算返回新对象自己不变不可变publicMoneyadd(Moneyother){requireSameCurrency(other);returnnewMoney(this.amount.add(other.amount),this.currency);}publicMoneymultiply(intquantity){returnnewMoney(this.amount.multiply(BigDecimal.valueOf(quantity)),this.currency);}publicMoneysubtract(Moneyother){requireSameCurrency(other);returnnewMoney(this.amount.subtract(other.amount),this.currency);}privatevoidrequireSameCurrency(Moneyother){// 业务规则3不同币种不能直接运算从根上杜绝错误if(!this.currency.equals(other.currency)){thrownewIllegalArgumentException(币种不一致不能运算);}}publiclongtoCents(){// 给支付防腐层用returnamount.movePointRight(2).longValueExact();}// 金额比较后续章节促销满减等会用到publicbooleanisGreaterOrEqual(Moneyother){requireSameCurrency(other);returnthis.amount.compareTo(other.amount)0;}// 值对象的相等性比较全部属性的值Overridepublicbooleanequals(Objecto){if(thiso)returntrue;if(!(oinstanceofMoney))returnfalse;Moneymoney(Money)o;returnamount.equals(money.amount)currency.equals(money.currency);}OverridepublicinthashCode(){returnObjects.hash(amount,currency);}}好处一眼可见“金额不能为负”“币种必须一致”保留 2 位四舍五入这些规则集中在一个地方守护全项目自动统一不可能漏。业务代码从total.add(itemPrice.multiply(...))裸 BigDecimal变成total total.add(item.subtotal())可读性和安全性都大幅提升。Money是不可变的可以随便传递、共享不用担心被偷偷改掉。再来一个收货地址做成值对象publicfinalclassAddress{privatefinalStringprovince;privatefinalStringcity;privatefinalStringdistrict;privatefinalStringdetail;privatefinalStringreceiver;privatefinalStringphone;publicAddress(Stringprovince,Stringcity,Stringdistrict,Stringdetail,Stringreceiver,Stringphone){// 在构造时统一校验保证任何一个 Address 对象都是合法的if(phonenull||!phone.matches(\\d{11})){thrownewIllegalArgumentException(手机号格式不正确);}// ... 其他校验this.provinceprovince;this.citycity;this.districtdistrict;this.detaildetail;this.receiverreceiver;this.phonephone;}publicStringfullAddress(){returnprovincecitydistrictdetail;}// equals/hashCode 比较全部字段略}注意收货地址在会员上下文里可能是实体用户有多个地址要增删改查、有 ID但在订单上下文里它是值对象下单时把地址内容快照进订单订单只关心送到哪不关心这是地址簿里的第几条。同一个概念在不同上下文里可以是不同类型——这正是第 03 章限界上下文的体现。五、为什么要费劲区分实体和值对象好处总结对比传统贫血写法一切都是带 ID 的表对应的 Entity属性全是基本类型值对象带来业务规则有家可归金额校验、地址校验不再散落各处而是收敛进值对象的构造函数。代码更安全不可变 自动校验从根上消灭一大类 bug共享被改、非法值、币种混算。模型更贴近业务Money、Address这些概念直接出现在代码里而不是一堆BigDecimal、String代码读起来就是业务语言。实体更聚焦把金额怎么算地址怎么校验这些细节交给值对象后实体如 Order只需关心自己的核心职责和状态流转。六、怎么验证你的建模是否正确给实体和值对象各写一个最小单元测试比口头讨论更可靠。classMoneyTest{TestvoidsameAmountAndCurrencyMeansSameValue(){assertEquals(Money.ofYuan(newBigDecimal(10.00)),Money.ofYuan(newBigDecimal(10.00)));}TestvoidcannotAddDifferentCurrencies(){MoneycnyMoney.of(newBigDecimal(10.00),Currency.getInstance(CNY));MoneyusdMoney.of(newBigDecimal(10.00),Currency.getInstance(USD));assertThrows(IllegalArgumentException.class,()-cny.add(usd));}}classOrderTest{TestvoidentityEqualityUsesIdentityOnly(){OrderIdidnewOrderId(1001L);OrderaOrder.restore(id,OrderStatus.PENDING_PAYMENT);OrderbOrder.restore(id,OrderStatus.PAID);assertEquals(a,b);// 状态不同但同一个订单 ID 就是同一个实体}}如果测试写不出来通常说明模型还没想清楚现象可能问题值对象需要 setter 才好用你把可变状态塞进了值对象实体 equals 需要比较很多字段你还没找到真正的身份每个值对象都想单独建表你被数据库思维带偏了业务里经常问这是哪一个它更可能是实体七、容易踩的坑把什么都做成实体。新手习惯一切都建表、都带 ID。问问自己我真的需要追踪它的身份和变化吗不需要就做成值对象。值对象用得越多模型通常越健康。值对象写了 setter。值对象必须不可变不能有 setter。要改就 new 一个新的。实体的 equals 比较所有字段。错。实体只比 ID。纠结持久化。“值对象没 ID 怎么存数据库”——存的时候它就是实体表里的几个字段JPA 的Embeddable或 MyBatis 里直接映射成几列。实体和值对象是单个积木。但真实业务里一堆实体和值对象要协同守护一组业务规则比如订单总额必须等于所有订单项之和。谁来统一管理它们、保证一致性这就是 DDD 最核心的难点——聚合。