优化Entity,类型改为值对象
前面我们已经定义了2个聚合根,定义了2个聚合根之间的关系,并且自动生成了表结构。
在实现具体的业务前,优化一下我们的Entity。
@Column(name = "product_no", length = 32, nullable = false, unique = true)
private String productNo;
@Column(name = "name", length = 64, nullable = false)
private String name;
@Column(name = "price", precision = 10, scale = 2)
private BigDecimal price;
@Column(name = "category_id", nullable = false)
private Integer categoryId;
@Column(name = "product_status", nullable = false)
private Integer productStatus;
咦?是不是有点眼熟?跟之前三层架构写的entity类有啥区别?没有区别,因为都是一些简单的字段跟DB对应一下就完事了。
这正是我们需要优化的地方,在实现DDD的时候我们应该尽量多使用值对象。
- 比如
productNo
这个字段,生成商品码这个方法放在哪里比较合适?放在Product
里? - 比如
price
这个字段,假如我们希望加一个币种字段怎么办? 直接再加一个@Column
? - 比如
productStatus
这个字段,它应该是一个枚举对不对?定义成Integer类型我们看代码根本就不知道这个数字代表什么对不对?
把它们定义成值对象问题就迎刃而解了。解决问题的同时还收获了额外的好处:
我们的代码更加OO(面向对象)
了。Entity类不再是一个简单的ORM类了,它是一个真正的模型对象了。
生成商品编码的方法放在ProductNumber里再适合不过了。
①新建domain.model.product.ProductNumber
:
@Getter
@EqualsAndHashCode
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@AllArgsConstructor(access = AccessLevel.PRIVATE)
public class ProductNumber implements Serializable {
private String value;
public static ProductNumber of(Integer categoryId) {
checkArgument(categoryId != null, "商品类目不能为空");
checkArgument(categoryId > 0, "商品类目id不能小于0");
return new ProductNumber(generateProductNo(categoryId));
}
public static ProductNumber of(String value) {
checkArgument(!StringUtils.isEmpty(value), "商品编码不能为空");
return new ProductNumber(value);
}
private static String generateProductNo(Integer categoryId) {
String prefix = "PRODUCT";
String typeStr = String.format("%04d", categoryId);
SimpleDateFormat sdf = new SimpleDateFormat("yyyyMMddHHmmssSSS");
String currentTime = sdf.format(new Date());
int randomNum = (int) (Math.random() * 9999 + 1);
String randomNumStr = String.format("%04d", randomNum);
return prefix + typeStr + currentTime + randomNumStr;
}
}
四个注意点(非常重要):
-
商品编码是业务主键,它应该是用户可读的,并且本身包含了一些有用信息。
我们定义商品码的生成规则为:PRODUCT + 4位类目 + 当前时间 + 4位随机数 共32位。 -
检查参数的时候,我们全部使用guava包的
checkArgument
方法,而不是checkNotNull
方法。因为我们这是业务代码,不能把空指针异常返回给客户端。
我们要提供用户可读的错误信息。 -
值对象是不可修改的,是不可修改的,是不可修改的。只提供getter就行了
-
值对象的
equals
和hashCode
方法,与实体有唯一标识不同,值对象没有唯一标识,两个值对象所有的属性值相等才能判定相等。
然后将private String productNo;
替换成 private ProductNumber productNo;
。
②新建domain.model.product.ProductStatusEnum
:
@AllArgsConstructor
public enum ProductStatusEnum {
// 新建
DRAFTED(1000111, "草稿"),
// 待审核
AUDIT_PENDING(1000112, "待审核"),
// 已上架
LISTED(1000113, "已上架"),
// 已下架
UNLISTED(1000114, "已下架"),
// 已失效
EXPIRED(1000115, "已失效");
@Getter
// @JsonValue
private Integer code;
@Getter
private String remark;
public static ProductStatusEnum of(Integer code) {
ProductStatusEnum[] values = ProductStatusEnum.values();
for (ProductStatusEnum val : values) {
if (val.getCode().equals(code)) {
return val;
}
}
// throw new InvalidParameterException(String.format("【%s】无效的产品状态", code));
return null;
}
}
为什么是枚举而不是字典?
个人觉得符合以下特征才应该使用字典,否则就应该用枚举:
- 子项可动态修改,而且修改比较频繁
- 修改子项不影响现有业务逻辑,也就是说代码不用动
像商品状态这种字段,每个状态都很业务密切相关。如果你把它放在字典里,只在字典里新加了一个状态没有用,因为代码里还得修改相关业务逻辑。
将private Integer productStatus;
替换成private ProductStatusEnum productStatus;
调整一下of
工厂方法:
public static Product of(String productNo, String name, BigDecimal price, Integer categoryId, Integer productStatus, String remark,
Boolean allowAcrossCategory, Set<ProductCourseItem> productCourseItems) {
ProductNumber newProductNo = ProductNumber.of(categoryId);
ProductStatusEnum defaultProductStatus = ProductStatusEnum.DRAFTED;
return new Product(null, newProductNo, name, price, categoryId, defaultProductStatus, remark, allowAcrossCategory,
productCourseItems);
}
③新建Price值对象
商品和课程明细都有价格,我们可以把Price放在一个公共的地方。
在domain下新建common.model.Price
, 内容如下:
@Embeddable
@Getter
@EqualsAndHashCode
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@AllArgsConstructor(access = AccessLevel.PRIVATE)
public class Price implements Serializable {
//@Convert(converter = CurrencyConverter.class)
@Column(name = "currency_code", length = 3)
private Currency currency;
@Column(name = "price", nullable = false, precision = 10, scale = 2)
private BigDecimal value;
public static Price of(String currencyCode, BigDecimal value) {
checkArgument(!StringUtils.isEmpty(currencyCode), "币种不能为空");
Currency currency;
try {
currency = Currency.getInstance(currencyCode);
} catch (IllegalArgumentException e) {
throw new InvalidParameterException(String.format("【%s】不是有效的币种", currencyCode));
}
checkArgument(value != null, "价格不能为空");
checkArgument(value.compareTo(BigDecimal.ZERO) > 0, "价格必须大于0");
return new Price(currency, value);
}
}
在值对象里验证币种的有效性很合理对不对?否则每次用到币种的时候都得判断一下是否有效。一个处理业务逻辑的方法里到处都是if判断,不雅观不说,
还影响看代码的思路。
注意这里我故意加了一行代码:
checkArgument(value.compareTo(BigDecimal.ZERO) > 0, "价格必须大于0");
大家想想加在这里是否合理? 我的理解,如果你的系统所有用到价格的地方都必须是正价格,可以加这句代码。虽然大多数场景价格都是正的,
哪儿有倒赔钱的道理? 但是保不准有些系统就是有“负价格”这个概念,那样的话就不能加这个判断了。
将Product
的
@Column(name = "price", precision = 10, scale = 2)
private BigDecimal price;
替换成
@Embedded
private Price price;
④自定义异常
定义一个通用的运行时异常:
@NoArgsConstructor
@AllArgsConstructor
@Setter
@Getter
public class BusinessException extends RuntimeException {
private String code;
private String message;
}
具体的业务异常:
public class InvalidParameterException extends BusinessException {
private static final String CODE = "invalid-parameter";
public InvalidParameterException(String message) {
super(CODE, message);
}
}
异常code定义成String类型,这样看到异常编码就能知道是哪种异常,如果定义成int类型,还得查表之后才能知道是哪种异常。
CourseItem类同理,这里就不再重复了。
demo地址: productcenter4.zip
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· Linux系列:如何用 C#调用 C方法造成内存泄露
· AI与.NET技术实操系列(二):开始使用ML.NET
· 记一次.NET内存居高不下排查解决与启示
· 探究高空视频全景AR技术的实现原理
· 理解Rust引用及其生命周期标识(上)
· 阿里最新开源QwQ-32B,效果媲美deepseek-r1满血版,部署成本又又又降低了!
· 单线程的Redis速度为什么快?
· SQL Server 2025 AI相关能力初探
· 展开说说关于C#中ORM框架的用法!
· AI编程工具终极对决:字节Trae VS Cursor,谁才是开发者新宠?