3. 领域模型与贫血模型:充血模型 vs 贫血模型
在Go语言业务系统设计中,领域模型的组织方式直接决定了代码的可维护性与业务表达力。本章聚焦于充血模型与贫血模型的核心差异,并深入探讨Go结构体作为领域模型的正确实践,以及如何避免常见的Getter/Setter陷阱。
3.1 贫血模型:失血失语的业务对象
贫血模型(Anemic Domain Model)是指领域对象仅包含数据字段,而所有业务逻辑被剥离到Service层。这种模型在Go早期项目中非常普遍,但往往导致业务逻辑散落在各处,难以维护。
| 特征 | 贫血模型 | 充血模型 |
|---|---|---|
| 数据与行为 | 分离,对象仅存数据 | 聚合,对象包含行为 |
| 业务规则位置 | Service层 | 领域模型内部 |
| 可测试性 | 依赖Service,测试粒度粗 | 模型独立可测,粒度细 |
| 业务表达力 | 弱,需阅读Service代码 | 强,模型自解释 |
| Go典型实现 | 纯struct + 外部函数 | struct + 方法 |
关键认知:贫血模型并非“错误”,但在复杂业务场景下,它会导致领域知识流失,使代码难以应对需求变化。充血模型通过将行为内聚到模型,提升了代码的健壮性与可读性。
3.2 充血模型:让结构体承载业务语义
充血模型(Rich Domain Model)的核心思想是:数据与操作数据的业务逻辑应放在同一个结构体中。在Go中,这意味着为struct定义方法,而不是在外部编写游离的函数。
以下是一个典型的充血模型示例:
// Order 充血模型:订单聚合根
type Order struct {
ID string
items []OrderItem
status OrderStatus
totalPrice Money
}
// AddItem 添加商品,包含业务校验
func (o *Order) AddItem(item OrderItem) error {
if o.status != OrderStatusPending {
return ErrOrderCannotModify
}
if item.Quantity <= 0 {
return ErrInvalidQuantity
}
o.items = append(o.items, item)
o.recalculateTotal()
return nil
}
// Submit 提交订单,触发状态变更
func (o *Order) Submit() error {
if len(o.items) == 0 {
return ErrEmptyOrder
}
if o.status != OrderStatusPending {
return ErrInvalidStatusTransition
}
o.status = OrderStatusSubmitted
o.submittedAt = time.Now()
return nil
}
// recalculateTotal 私有方法:重新计算总价
func (o *Order) recalculateTotal() {
var total int64
for _, item := range o.items {
total += item.Price.Value() * int64(item.Quantity)
}
o.totalPrice = Money(total)
}
实践要点:充血模型的方法应遵循“命令-查询分离”(CQS)原则。修改状态的方法(如Submit)返回error;查询状态的方法(如IsSubmitted)返回bool或值对象,不产生副作用。
3.3 Go结构体作为领域模型:设计原则
在Go中,结构体是领域模型的主要载体。设计良好的领域结构体应遵循以下原则:
- 最小暴露原则:字段首字母小写,通过方法暴露业务操作,而非直接暴露字段。
- 值对象优先:将金额、日期、状态等封装为值对象类型,而非原始类型。
- 不变性保障:通过构造函数(NewXXX)确保对象创建时满足业务约束。
- 方法内聚:只包含与自身状态强相关的业务方法,不包含基础设施逻辑。
// Money 值对象:封装金额与币种
type Money struct {
amount int64
currency string
}
func NewMoney(amount int64, currency string) (Money, error) {
if amount < 0 {
return Money{}, ErrNegativeAmount
}
if currency == "" {
return Money{}, ErrInvalidCurrency
}
return Money{amount: amount, currency: currency}, nil
}
func (m Money) Add(other Money) (Money, error) {
if m.currency != other.currency {
return Money{}, ErrCurrencyMismatch
}
return NewMoney(m.amount+other.amount, m.currency)
}
func (m Money) Value() int64 {
return m.amount
}
常见误区:不要为了“充血”而把所有函数都塞进结构体。与领域无关的序列化、持久化、网络传输等基础设施逻辑应放在独立的Repository或DTO层,避免污染领域模型。
3.4 避免Getter/Setter陷阱
在Java等语言中,Getter/Setter是贫血模型的典型标志。Go语言没有隐式的getter/setter约定,但开发者仍可能陷入类似的陷阱:
| 陷阱 | 表现 | 正确做法 |
|---|---|---|
| 暴露字段 | 直接导出字段(首字母大写) | 字段小写,通过业务方法访问 |
| 无意义Getter | GetName() 直接返回 name | 仅在需要计算或转换时提供方法 |
| Setter破坏不变性 | SetStatus() 允许任意状态变更 | 提供状态变更方法,内含校验 |
| 贫血的DTO思维 | 结构体仅用于数据传输 | 区分DTO与领域模型 |
以下是一个反例与正例的对比:
// 反例:贫血模型 + Getter/Setter 陷阱
type User struct {
ID string
Name string
Status string
}
func (u *User) GetID() string { return u.ID }
func (u *User) SetName(n string) { u.Name = n }
func (u *User) SetStatus(s string) { u.Status = s }
// 正例:充血模型 + 业务方法
type User struct {
id UserID
name PersonName
status UserStatus
}
func (u *User) ChangeName(newName PersonName) error {
if u.status == UserStatusArchived {
return ErrUserArchived
}
u.name = newName
return nil
}
func (u *User) Archive() error {
if u.status == UserStatusArchived {
return ErrAlreadyArchived
}
u.status = UserStatusArchived
return nil
}
核心原则:不要问对象“你的数据是什么”,而是告诉对象“你要做什么”。领域模型应该通过行为暴露能力,而不是通过getter暴露数据。
3.5 领域模型与贫血模型对比图
下图展示了两种模型在架构层面的核心差异:
3.6 从贫血到充血:重构路线图
将现有贫血模型重构为充血模型,建议遵循以下步骤:
- 识别聚合根:找出业务中的核心实体(如Order、User、Product)。
- 封装字段:将导出字段改为小写,通过构造函数初始化。
- 迁移业务逻辑:将Service中与该实体强相关的方法迁移到结构体上。
- 引入值对象:将原始类型字段替换为值对象(如Money、Email、Phone)。
- 提取不变条件:在构造函数和变更方法中加入业务校验。
- 清理Service层:Service只保留编排逻辑,不再包含领域规则。
渐进式重构:不必一次性重构所有模型。选择业务最复杂、变更最频繁的聚合根先行试点,验证效果后再推广。Go的接口机制可以很好地隔离重构边界。
3.7 领域模型中的值对象与实体
在DDD中,领域模型分为实体(Entity)和值对象(Value Object)。理解二者的区别是设计充血模型的基础:
| 维度 | 实体(Entity) | 值对象(Value Object) |
|---|---|---|
| 标识 | 有唯一ID,可变更 | 无ID,由属性值定义 |
| 可变性 | 可变(有生命周期) | 不可变(替换而非修改) |
| 相等性 | ID相等即相等 | 所有属性相等即相等 |
| Go实现 | struct + 指针接收者方法 | struct + 值接收者方法 |
| 示例 | Order, User, Product | Money, Address, Email |
// 实体:有唯一标识,可变
type Product struct {
id ProductID
name string
price Money
}
func (p *Product) UpdatePrice(newPrice Money) error {
if newPrice.Value() <= 0 {
return ErrInvalidPrice
}
p.price = newPrice
return nil
}
// 值对象:不可变,无标识
type Address struct {
city string
street string
zipCode string
}
func NewAddress(city, street, zipCode string) (Address, error) {
if city == "" || street == "" {
return Address{}, ErrInvalidAddress
}
return Address{city: city, street: street, zipCode: zipCode}, nil
}
func (a Address) Equals(other Address) bool {
return a.city == other.city &&
a.street == other.street &&
a.zipCode == other.zipCode
}
设计决策:当不确定一个概念应该是实体还是值对象时,问自己:“如果两个对象的所有属性都相同,它们是否应该被视为同一个对象?” 如果是,则是值对象;否则是实体。
3.8 本章小结
充血模型与贫血模型的选择,本质上是业务复杂度与架构简洁性之间的权衡。对于CRUD为主的简单系统,贫血模型可能足够;但对于复杂业务系统,充血模型能显著提升代码的可维护性与业务表达力。Go语言的结构体与方法机制为充血模型提供了天然的支持,关键在于遵循最小暴露、值对象优先、不变性保障等设计原则,并警惕Getter/Setter陷阱。