命令中能否使用值对象?值对象验证逻辑位置指导请求
解决方案:值对象验证时机与命令/事件处理的协同
这是DDD结合事件溯源时非常常见的痛点——既要复用值对象的验证逻辑,又要保证验证时机落在命令处理阶段(而不是事件重放时)。下面给你两个实用的方案,结合你的代码示例来讲解:
方案1:拆分值对象的验证逻辑,实现复用
核心思路是把Address的验证逻辑从构造函数中抽离成独立的静态方法,这样命令处理器可以提前调用验证,而事件溯源handler创建值对象时依然能复用相同的逻辑(作为兜底)。
修改Address类,抽离验证逻辑
public class Address { private String city; private String country; private String zipCode; // 私有构造函数,强制通过工厂方法创建 private Address(String city, String country, String zipCode) { this.city = city; this.country = country; this.zipCode = zipCode; } // 静态工厂方法,内部调用验证逻辑 public static Address create(String city, String country, String zipCode) { validateAddress(city, country, zipCode); return new Address(city, country, zipCode); } // 独立的公开验证方法,供命令处理器调用 public static void validateAddress(String city, String country, String zipCode) { if (city == null || city.isBlank()) { throw new IllegalArgumentException("City cannot be empty or null"); } if (country == null || country.isBlank()) { throw new IllegalArgumentException("Country cannot be empty or null"); } if (zipCode == null || zipCode.isBlank()) { throw new IllegalArgumentException("Zip code cannot be empty or null"); } // 其他自定义验证规则(比如邮编格式匹配) } // Getters... }
命令处理器中提前执行验证
在CreateShopCmd的处理器里,先调用Address的验证方法,确保无效请求在发布事件前被拦截:
@CommandHandler public Shop(CreateShopCmd cmd) { // 1. 先验证地址信息 Address.validateAddress(cmd.getCity(), cmd.getCountry(), cmd.getZipCode()); // 2. 验证其他命令字段(比如店铺名称) if (cmd.getShopName() == null || cmd.getShopName().isBlank()) { throw new IllegalArgumentException("Shop name cannot be empty"); } // 3. 发布已验证的事件 ShopRegistredEvt shopRegistredEvt = new ShopRegistredEvt(); shopRegistredEvt.setShopId(cmd.getShopId()); shopRegistredEvt.setShopName(cmd.getShopName()); shopRegistredEvt.setCity(cmd.getCity()); shopRegistredEvt.setCountry(cmd.getCountry()); shopRegistredEvt.setZipCode(cmd.getZipCode()); AggregateLifecycle.apply(shopRegistredEvt); }
事件溯源handler中使用工厂方法创建值对象
事件是已经确认的领域事实,这里的验证更多是兜底(防止事件被意外篡改),但因为命令阶段已经验证过,不会触发异常:
@EventSourcingHandler public void on(ShopRegistredEvt evt) { this.shopName = evt.getShopName(); // 用工厂方法创建Address,复用验证逻辑 this.address = Address.create(evt.getCity(), evt.getCountry(), evt.getZipCode()); }
方案2:命令处理器中直接创建值对象,验证时机前置
这种方案更符合DDD的实践:值对象的验证逻辑由自身负责,命令处理器通过创建值对象的方式触发验证,事件中直接携带已验证的值对象(或其原始字段)。
命令处理器中创建Address值对象
@CommandHandler public Shop(CreateShopCmd cmd) { try { // 创建Address时直接触发构造函数中的验证 Address address = new Address(cmd.getCity(), cmd.getCountry(), cmd.getZipCode()); // 验证其他命令字段 if (cmd.getShopName() == null || cmd.getShopName().isBlank()) { throw new IllegalArgumentException("Shop name cannot be empty"); } // 发布事件,事件可以携带Address对象(如果支持序列化)或拆分字段 ShopRegistredEvt shopRegistredEvt = new ShopRegistredEvt(); shopRegistredEvt.setShopId(cmd.getShopId()); shopRegistredEvt.setShopName(cmd.getShopName()); // 方式A:直接传递值对象(需要确保事件序列化支持值对象) shopRegistredEvt.setAddress(address); // 方式B:传递拆分后的字段(如果需要更灵活的存储/查询) // shopRegistredEvt.setCity(cmd.getCity()); // shopRegistredEvt.setCountry(cmd.getCountry()); // shopRegistredEvt.setZipCode(cmd.getZipCode()); AggregateLifecycle.apply(shopRegistredEvt); } catch (IllegalArgumentException e) { // 把验证异常包装成命令执行异常,返回给调用方 throw new CommandExecutionException("Failed to create shop: " + e.getMessage(), e); } }
事件溯源handler中直接赋值
因为事件中的数据已经是命令阶段验证过的,这里不需要再执行验证:
@EventSourcingHandler public void on(ShopRegistredEvt evt) { this.shopName = evt.getShopName(); // 如果事件携带值对象,直接赋值 this.address = evt.getAddress(); // 如果事件携带拆分字段,直接创建(此时验证是兜底,但不会失败) // this.address = new Address(evt.getCity(), evt.getCountry(), evt.getZipCode()); }
关键注意点
- 验证必须在命令处理阶段完成:事件是已经发生的事实,不应该在事件重放(事件溯源handler)时进行验证——否则如果后续修改了验证规则,旧事件重放会失败,破坏事件溯源的可靠性。
- 值对象的验证逻辑应该内聚:尽量让值对象自己负责自身的合法性,避免在外部重复编写验证代码,符合单一职责原则。
- 事件的可信性:事件中的数据应该是经过验证的干净数据,不要把未验证的原始数据放进事件里。
内容的提问来源于stack exchange,提问作者Ashwani Tiwari
相关产品推荐
相关产品推荐

