Go 中优雅的领域驱动设计对象
Source: Dev.to
示例场景
“我们如何通过奖励重复购买来鼓励顾客回到我们的咖啡店?”
核心问题
- 顾客购买咖啡。
- 我们想为每次购买给予积分。
- 积分在 30 天后过期。
- 我们可以展示排行榜并提供奖励(例如免费咖啡)。
在与领域专家多次合作后,我们确定了:
- 实体 –
Customer(包含顾客信息及其咖啡订单) - 值对象 –
CoffeeOrder(包含单个订单的信息)
1️⃣ 定义值对象(CoffeeOrder)
值对象是 不可变 的对象,表示领域的描述性方面,仅由其属性(值)而非唯一标识来定义。
没有
ID或CoffeeOrderID—— 值对象仅通过其字段来标识。
2️⃣ 定义实体(Customer)
实体是由唯一标识定义的对象,拥有生命周期并在状态变化中保持连续性。
type Customer struct {
// ID of the customer.
ID int
// Name of the customer.
Name string
// CoffeeOrders holds the orders of this customer.
CoffeeOrders []CoffeeOrder
}
3️⃣ 业务逻辑示例
// PointsAt returns the points of a customer at a given moment in time.
func (c *Customer) PointsAt(ref time.Time) int {
points := 0
for _, co := range c.CoffeeOrders {
points += co.Points(ref)
}
return points
}
虽然这样可以工作,但我们可以做出几项改进——尤其是在值对象的 immutability 方面。
Source: …
🔒 实现 CoffeeOrder 的不可变性
为什么当前实现不是不可变的
- 字段是导出的,调用者可以直接修改它们(例如
co.OrderBy = 5)。 - 结构体可以在没有验证的情况下实例化(
co := CoffeeOrder{}),会产生一个无效的零值对象。
强制不可变性的步骤
- 将字段设为未导出 – 防止外部修改。
- 提供构造函数 – 验证输入并返回一个已完全初始化的对象。
- 通过接口公开行为 – 隐藏具体实现。
- 使用值接收者 – 避免意外修改底层数据。
3.1 定义领域特定类型
使用自定义类型而不是原始类型可以添加语义意义,并让我们附加验证逻辑。
type CustomerID int
type CoffeeSize string
// IsValid 报告咖啡尺寸是否为允许的值之一。
func (cs CoffeeSize) IsValid() bool {
switch cs {
case "small", "medium", "large":
return true
default:
return false
}
}
3.2 定义接口和私有实现
type CoffeeOrder interface {
Size() CoffeeSize
OrderBy() CustomerID
OrderTime() time.Time
Points(ref time.Time) CoffeePoints
// x 是未导出的方法,保证实现只能在本包中定义。
x()
}
type coffeeOrder struct {
size CoffeeSize
orderBy CustomerID
orderTime time.Time
}
// 确保 coffeeOrder 实现了 CoffeeOrder 接口。
var _ CoffeeOrder = (*coffeeOrder)(nil)
// 未导出的方法以满足接口。
func (c *coffeeOrder) x() {}
3.3 带验证的构造函数
func NewCoffeeOrder(
size CoffeeSize,
orderBy CustomerID,
orderTime time.Time,
) (CoffeeOrder, error) {
if !size.IsValid() {
return nil, errors.New("invalid coffee size")
}
// 这里可以添加其他业务规则检查。
return &coffeeOrder{
size: size,
orderBy: orderBy,
orderTime: orderTime,
}, nil
}
3.4 实现接口方法
func (c *coffeeOrder) Size() CoffeeSize { return c.size }
func (c *coffeeOrder) OrderBy() CustomerID { return c.orderBy }
func (c *coffeeOrder) OrderTime() time.Time { return c.orderTime }
// Points 计算在给定参考时间下此订单获得的积分。
func (c *coffeeOrder) Points(ref time.Time) CoffeePoints {
// 示例逻辑:每杯咖啡 1 分,30 天后过期。
if ref.Sub(c.orderTime) > 30*24*time.Hour {
return 0
}
return 1
}
现在 CoffeeOrder 具备:
- 不可变 – 构造后字段无法被更改。
- 已验证 – 构造函数强制业务规则。
- 封装 – 调用者只能通过导出的接口进行交互。
4️⃣ 更新实体以使用新的值对象
type Customer struct {
ID CustomerID
Name string
// Store the interface to keep the entity decoupled from the concrete type.
CoffeeOrders []CoffeeOrder
}
// PointsAt returns the total points of a customer at a particular moment.
func (c *Customer) PointsAt(ref time.Time) CoffeePoints {
var total CoffeePoints
for _, order := range c.CoffeeOrders {
total += order.Points(ref)
}
return total
}
💡 要点
| ✅ | 建议 |
|---|---|
定义自定义类型(例如 CustomerID、CoffeeSize)为原始值赋予领域含义。 | |
| 通过接口隐藏实现细节,防止外部代码构造无效对象。 | |
| 永不导出值对象的字段;使用构造函数来强制不变式。 | |
| 对不可变对象使用值接收者;仅在需要变更时才使用指针接收者。 | |
| 当值对象接收可变输入(切片、映射等)时,需要防御性复制。 |
遵循这些模式,你可以在 Go 中对 DDD 概念进行建模,同时保持领域层 类型安全、表达力强、易于维护。祝编码愉快!
自定义错误用于领域验证
在验证用户提供的值(例如咖啡尺寸)时,应该返回 领域特定错误 而不是通用的 errors.New。该错误应携带足够的上下文信息,以便调用者了解到底出了什么问题。
// Declare that the custom error implements the error interface.
var _ error = new(WrongCoffeeSizeError)
type WrongCoffeeSizeError struct {
Size string
}
// Error implements the error interface.
func (e WrongCoffeeSizeError) Error() string {
return fmt.Sprintf("invalid coffee size: %s", e.Size)
}
然后可以这样使用自定义错误:
if !size.IsValid() {
return CoffeeOrder{}, WrongCoffeeSizeError{Size: string(size)}
}
DDD 中的服务
在领域驱动设计(Domain‑Driven Design)中,services 协调位于领域层之上的业务操作。例如,你可以创建一个服务来:
- 检索过去 30 天内下的所有
CoffeeOrder。 - 获取与这些订单关联的
Customer。 - 按客户积分对其进行排序,以生成排行榜。
强制实践的 Linter
许多 Go Linter 已经存在,但没有专门针对 DDD(领域驱动设计)模式的。为填补这一空白,我构建了一个 Linter —— godddlint —— 用来检查以下约定:
- 值对象的不可变性。
- 实体使用 自定义类型 而非原始类型。
- 实体和值对象的 指针接收者 vs. 非指针接收者 使用正确。
- 方法返回语句中 不使用
errors.New(鼓励使用领域特定错误)。
该 Linter 仍在积极开发中,但它可以帮助你编写更具表现力和更健壮的领域模型。
godddlint – 一个可能对你有帮助的面向 DDD 的 Linter。
回顾
在本文中,我们介绍了若干 DDD 概念以及相关的最佳实践:
- 不可变性。
- 为编译时安全创建领域特定类型。
- 定义自定义领域错误。
那么你呢?
在 Go 中,你发现哪些模式对 DDD 有效?
在评论中分享你的经验!