Python 值对象在业务建模中的应用

10次阅读

业务中不用 dataclass 直接当值对象,因其默认可变、无值语义、不校验输入;应手写__init__+__eq__+__hash__,确保不可变、构造即校验、相等性仅依赖可哈希字段内容,并将校验逻辑抽离为独立方法。

Python 值对象在业务建模中的应用

为什么不用 dataclass 直接当业务实体?

因为 dataclass 默认可变、无值语义、不校验输入——业务里一个订单对象被意外修改字段,或两个相同订单判断不等,都会引发隐蔽逻辑错误。

值对象的核心诉求是:相等性只看字段内容,不可变,构造即校验。Python 原生没内置值对象类型,得自己控住边界。

  • @dataclass(frozen=True) 是最简起点,但冻结后连 __post_init__ 里的字段修正都报错,不适合需要规范化输入的场景(比如把 "2024-01-01" 自动转成 date
  • 真正可控的做法是手写 __init__ + __eq__ + __hash__,显式声明哪些字段参与比较,且只在初始化时做转换和校验
  • 别依赖 __dict__vars() 做序列化,它们会暴露内部实现细节;统一走 asdict() 或自定义 to_dict()

__eq____hash__ 必须同步定义

只重写 __eq__ 不加 __hash__,对象会自动变成不可哈希(TypeError: unhashable type),导致无法放进 set 或当 dict 的 key——这在去重订单项、缓存聚合根状态时很常见。

更麻烦的是,如果字段里含可变类型(如 listdict),即使写了 __hash__,运行时也可能抛 TypeError: unhashable type

立即学习 Python 免费学习笔记(深入)”;

  • 确保所有参与 __eq__ 判断的字段本身可哈希(优先用 tuplestrintdate 等)
  • 若必须含列表(如地址行),先转成 tuple 再参与比较:tuple(self.lines)
  • 别在 __hash__ 里调用耗时操作(如数据库查询),它可能被频繁调用

业务字段校验不能只靠 __post_init__

很多人以为加个 @dataclass 再写个 __post_init__ 就万事大吉,但这里有两个硬伤:一是异常堆栈指向 __init__ 而非具体字段,二是校验逻辑和领域规则混在一起,难测试、难复用。

比如「手机号必须是中国大陆 11 位数字」这种规则,应该独立成方法,而不是塞进初始化流程里。

  • 把校验逻辑拆到类方法(如 validate_phone(phone: str) -> str),返回标准化后的值,或抛出带字段名的 ValueError
  • __init__ 中调用它,但不要捕获异常——让错误冒泡,业务层才能决定是提示用户还是拒绝创建
  • 避免在 __init__ 中做 I/O 或远程调用,值对象应是纯内存结构

和 Pydantic BaseModel 混用时的坑

不少项目用 Pydantic 做 API 入参校验,再把解析后的 BaseModel 实例直接当业务值对象用——这会导致行为不一致:Pydantic 默认可变、支持字段动态赋值、__eq__ 比较的是所有字段(包括未设置的 None),而业务上往往只关心“有意义的字段”。

更危险的是,Pydantic 的 model_copy() 默认浅拷贝,嵌套模型修改会影响原对象。

  • 别直接继承 BaseModel 当值对象;要么用 BaseModel 只做 DTO,再手动映射到纯 Python 值对象;要么用 BaseModel__slots__ = True + 自定义 __eq__ + __hash__
  • 如果用 Pydantic v2,可用 model_config = ConfigDict(frozen=True, extra='forbid'),但仍需重写 __eq__ 控制相等逻辑
  • 注意 BaseModeldict() 方法默认包含 None 字段,而值对象通常要过滤掉未设置项

值对象真正的复杂点不在代码怎么写,而在“哪些字段算本质属性”。比如「金额」要不要带币种?「地址」要不要标准化到四级行政区?这些不是技术问题,是业务语义的落地——写完代码后,得拉着产品一起对字段定义,否则后面改一次等于重构整个值对象链。

text=ZqhQzanResources