4. 实体(Entity)设计:唯一标识、生命周期、Go中实体的实现模式、实体与数据库解耦
在领域驱动设计中,实体(Entity) 是拥有唯一标识且在其生命周期内状态持续变化的对象。与值对象不同,实体的核心不是“它是什么”,而是“它是谁”。本章将深入探讨如何在 Go 语言中设计健壮的实体,确保业务逻辑的完整性与持久化层的解耦。
4.1 实体的核心三要素
一个合格的领域实体必须同时满足以下三个条件:
| 要素 | 说明 | Go 实现要点 |
|---|---|---|
| 唯一标识 | 在系统中唯一且不可变,用于区分不同实体实例 | 使用值对象封装 ID 类型,避免原始类型(如 string)直接暴露 |
| 生命周期 | 实体从创建、变更到归档/删除的完整过程 | 通过工厂方法创建,仓储负责持久化,领域事件记录关键状态变更 |
| 业务行为 | 实体内部封装业务规则,通过方法改变自身状态 | 贫血模型反模式:避免 Getter/Setter 泛滥,将行为内聚到实体方法中 |
设计提示: 实体的唯一标识一旦创建就不应被修改。在 Go 中,建议将 ID 字段设为私有(小写),并通过构造函数或工厂方法在初始化时赋值。
4.2 唯一标识:从原始类型到值对象
很多 Go 项目直接使用 int64 或 string 作为实体 ID,这会导致类型混淆和业务语义丢失。正确的做法是将 ID 封装为值对象:
// 错误做法:原始类型暴露,容易传错参数
type Order struct {
ID int64
UserID int64
ProductID int64
}
// 正确做法:使用值对象封装唯一标识
type OrderID struct {
value string // 内部使用 UUID 或 Snowflake
}
func NewOrderID(value string) (OrderID, error) {
if !isValidUUID(value) {
return OrderID{}, errors.New("invalid order ID format")
}
return OrderID{value: value}, nil
}
func (id OrderID) String() string {
return id.value
}
type Order struct {
id OrderID
userID UserID
productID ProductID
status OrderStatus
createdAt time.Time
}
func NewOrder(id OrderID, userID UserID, productID ProductID) (*Order, error) {
if id.IsZero() {
return nil, errors.New("order ID must not be zero")
}
return &Order{
id: id,
userID: userID,
productID: productID,
status: OrderStatusPending,
createdAt: time.Now(),
}, nil
}
避坑指南: 不要将 ID 生成逻辑放在实体内部。ID 生成策略(UUID、Snowflake、数据库自增)属于基础设施层,应通过工厂或仓储注入。实体只负责接收已生成的合法 ID。
4.3 生命周期管理:状态机与领域事件
实体的生命周期通常表现为一个有限状态机。以订单实体为例,其状态转换必须遵循业务规则:
type OrderStatus string
const (
OrderStatusPending OrderStatus = "pending"
OrderStatusConfirmed OrderStatus = "confirmed"
OrderStatusShipped OrderStatus = "shipped"
OrderStatusDelivered OrderStatus = "delivered"
OrderStatusCancelled OrderStatus = "cancelled"
)
var validTransitions = map[OrderStatus][]OrderStatus{
OrderStatusPending: {OrderStatusConfirmed, OrderStatusCancelled},
OrderStatusConfirmed: {OrderStatusShipped, OrderStatusCancelled},
OrderStatusShipped: {OrderStatusDelivered},
OrderStatusDelivered: {},
OrderStatusCancelled: {},
}
func (o *Order) Confirm() error {
if !o.canTransitionTo(OrderStatusConfirmed) {
return fmt.Errorf("cannot confirm order in status %s", o.status)
}
o.status = OrderStatusConfirmed
o.events = append(o.events, OrderConfirmedEvent{
OrderID: o.id,
Time: time.Now(),
})
return nil
}
func (o *Order) canTransitionTo(target OrderStatus) bool {
for _, allowed := range validTransitions[o.status] {
if allowed == target {
return true
}
}
return false
}
生命周期中的关键节点应通过领域事件向外通知,而非直接调用外部服务。这保持了实体的纯净性。
4.4 Go 中实体的实现模式
在 Go 中实现实体时,应遵循以下模式来保证封装性和可测试性:
4.4.1 私有字段 + 公开方法
实体内部状态对外部完全隐藏,所有变更必须通过公开方法进行:
type User struct {
id UserID
name string
email Email
role UserRole
updatedAt time.Time
events []DomainEvent // 领域事件列表
}
func (u *User) ChangeEmail(newEmail Email) error {
if u.role == RoleAdmin && newEmail.Domain() != "company.com" {
return errors.New("admin users must use company email domain")
}
u.email = newEmail
u.updatedAt = time.Now()
u.events = append(u.events, EmailChangedEvent{
UserID: u.id,
OldEmail: u.email,
NewEmail: newEmail,
ChangedAt: time.Now(),
})
return nil
}
func (u *User) PullEvents() []DomainEvent {
events := u.events
u.events = nil
return events
}
4.4.2 工厂方法替代直接构造
使用 NewXxx 函数作为实体的唯一创建入口,确保不变量在创建时即被满足:
func NewUser(id UserID, name string, email Email, role UserRole) (*User, error) {
if name == "" {
return nil, errors.New("user name must not be empty")
}
if role == RoleAdmin && email.Domain() != "company.com" {
return nil, errors.New("admin must use company email")
}
return &User{
id: id,
name: name,
email: email,
role: role,
updatedAt: time.Now(),
}, nil
}
4.5 实体与数据库解耦:仓储模式
实体不应知道任何关于数据库的细节。通过仓储接口在领域层定义持久化契约,具体实现在基础设施层完成:
// 领域层:定义仓储接口
type OrderRepository interface {
Save(ctx context.Context, order *Order) error
FindByID(ctx context.Context, id OrderID) (*Order, error)
FindByUserID(ctx context.Context, userID UserID, page, size int) ([]*Order, int64, error)
Delete(ctx context.Context, id OrderID) error
}
// 基础设施层:实现仓储接口
type PostgresOrderRepository struct {
db *sql.DB
}
func (r *PostgresOrderRepository) Save(ctx context.Context, order *Order) error {
// 使用事务保证一致性
tx, err := r.db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer tx.Rollback()
// 保存订单主表
_, err = tx.ExecContext(ctx, `INSERT INTO orders (...) VALUES (...) ON CONFLICT DO UPDATE`)
if err != nil {
return err
}
// 保存订单项
for _, item := range order.Items() {
_, err = tx.ExecContext(ctx, `INSERT INTO order_items (...) VALUES (...)`...)
if err != nil {
return err
}
}
return tx.Commit()
}
关键原则: 仓储接口属于领域层,仓储实现属于基础设施层。领域层代码(实体、值对象、领域服务)不应 import 任何 ORM 或数据库驱动包。
4.6 实体设计检查清单
- 唯一标识:是否使用值对象封装 ID?ID 生成是否与实体解耦?
- 封装性:所有字段是否私有?状态变更是否通过公开方法进行?
- 不变量:构造函数是否校验所有业务规则?状态转换是否受状态机约束?
- 领域事件:关键业务操作是否产生事件?事件是否在实体内部收集?
- 持久化解耦:实体是否不依赖任何 ORM 或数据库类型?仓储接口是否定义在领域层?
- 贫血模型检查:实体中是否有业务逻辑?还是只有 Getter/Setter?
常见反模式: 将 JSON 标签、GORM 注解或数据库字段映射直接写在实体结构体上。这会导致领域层与基础设施层耦合,违反 DDD 的分层原则。正确的做法是使用单独的 DTO 或持久化模型进行转换。