Go 中优雅的领域驱动设计对象

发布: (2026年1月19日 GMT+8 13:41)
8 min read
原文: Dev.to

Source: Dev.to

示例场景

“我们如何通过奖励重复购买来鼓励顾客回到我们的咖啡店?”

核心问题

  1. 顾客购买咖啡。
  2. 我们想为每次购买给予积分。
  3. 积分在 30 天后过期。
  4. 我们可以展示排行榜并提供奖励(例如免费咖啡)。

在与领域专家多次合作后,我们确定了:

  • 实体Customer(包含顾客信息及其咖啡订单)
  • 值对象CoffeeOrder(包含单个订单的信息)

1️⃣ 定义值对象(CoffeeOrder

值对象是 不可变 的对象,表示领域的描述性方面,仅由其属性(值)而非唯一标识来定义。

没有 IDCoffeeOrderID —— 值对象仅通过其字段来标识。

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{}),会产生一个无效的零值对象。

强制不可变性的步骤

  1. 将字段设为未导出 – 防止外部修改。
  2. 提供构造函数 – 验证输入并返回一个已完全初始化的对象。
  3. 通过接口公开行为 – 隐藏具体实现。
  4. 使用值接收者 – 避免意外修改底层数据。

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
}

💡 要点

建议
定义自定义类型(例如 CustomerIDCoffeeSize)为原始值赋予领域含义。
通过接口隐藏实现细节,防止外部代码构造无效对象。
永不导出值对象的字段;使用构造函数来强制不变式。
对不可变对象使用值接收者;仅在需要变更时才使用指针接收者。
当值对象接收可变输入(切片、映射等)时,需要防御性复制

遵循这些模式,你可以在 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 协调位于领域层之上的业务操作。例如,你可以创建一个服务来:

  1. 检索过去 30 天内下的所有 CoffeeOrder
  2. 获取与这些订单关联的 Customer
  3. 按客户积分对其进行排序,以生成排行榜。

强制实践的 Linter

许多 Go Linter 已经存在,但没有专门针对 DDD(领域驱动设计)模式的。为填补这一空白,我构建了一个 Linter —— godddlint —— 用来检查以下约定:

  • 值对象的不可变性
  • 实体使用 自定义类型 而非原始类型。
  • 实体和值对象的 指针接收者 vs. 非指针接收者 使用正确。
  • 方法返回语句中 不使用 errors.New(鼓励使用领域特定错误)。

该 Linter 仍在积极开发中,但它可以帮助你编写更具表现力和更健壮的领域模型。

godddlint – 一个可能对你有帮助的面向 DDD 的 Linter。

回顾

在本文中,我们介绍了若干 DDD 概念以及相关的最佳实践:

  • 不可变性。
  • 为编译时安全创建领域特定类型。
  • 定义自定义领域错误。

那么你呢?

在 Go 中,你发现哪些模式对 DDD 有效?
在评论中分享你的经验!

Back to Blog

相关文章

阅读更多 »