Go中聚合根是ID公开、状态私有的struct,通过构造函数和方法控制状态变更,跨聚合仅存ID,领域逻辑限于domain包且不可依赖基础设施。

Go 里怎么定义聚合根,而不是随便塞个 struct
聚合根不是“带 ID 的结构体”,而是承担一致性边界职责的领域对象。很多团队一上来就 type Order struct { ID string; Items []OrderItem },结果订单状态变更时没法保证 Items 同步校验或版本控制。
真正有效的聚合根要满足三点:唯一标识、内聚行为、禁止外部直接修改内部子对象。比如电商订单,Order 是聚合根,OrderItem 必须通过 Order.AddItem() 方法进入,且所有状态变更(如 Confirm()、Cancel())都封装在 Order 内部实现。
- 聚合根必须定义明确的不变量(invariant),例如“已支付订单不可再添加商品”,这个逻辑不能散落在 handler 或 service 层
- 避免把数据库字段直接映射为聚合字段——
CreatedAt和UpdatedAt属于基础设施层,不应出现在Order结构体中 - 聚合内不依赖外部服务或仓储接口;它只暴露方法,不暴露字段
为什么 Go 没有继承,但依然能做充血模型
充血模型的关键不是语法糖,而是把业务规则和状态变更逻辑收拢到领域对象里。Go 用组合+方法集替代继承,完全可行。
常见错误是写一堆 func UpdateOrderStatus(o *Order, status string) error 这类工具函数,把领域逻辑外置。正确做法是让 Order 自己负责状态流转:
立即学习“go语言免费学习笔记(深入)”;
func (o *Order) Confirm() error {
if o.Status != OrderCreated {
return errors.New("only created order can be confirmed")
}
o.Status = OrderConfirmed
o.ConfirmedAt = time.Now()
return nil
}
- 所有状态变更入口必须是聚合根的方法,而非外部赋值:
order.Status = OrderConfirmed是反模式 - 方法返回 error 而不是 bool,便于上层统一处理失败路径
- 如果需要异步动作(如发通知),应在应用层调用领域方法后触发,聚合根本身不耦合事件发布逻辑
仓储接口该放哪,Repository 不是 DAO
Repository 接口必须定义在 domain/ 或 internal/domain/ 下,实现放在 infra/。这是 DDD 分层最常被破坏的一环——很多人把 GetByID 直接写在 MySQL 实现里,然后让 service 层 import mysql 包。
典型错误示例:service.OrderService 依赖 mysql.OrderRepo,导致领域层被技术细节污染。
- 接口签名应使用领域类型,而非数据库类型:参数是
ctx.Context和id string,返回*Order,而不是*sql.Row或map[string]interface{} - 不要在仓储接口里暴露分页、排序等基础设施语义;这些应由应用层组装查询条件后传入
- 一个聚合对应一个仓储接口,不要出现
UserOrderRepository这种跨限界上下文的混搭
限界上下文落地时,internal/user/ 和 internal/order/ 之间怎么通信
两个限界上下文之间不能直接 import 对方的 domain 类型或调用其 service。Go 微服务里最常见的硬编码耦合,就是 order-service 直接 new 一个 user.UserClient 然后同步调用。
正确通信方式只有两种:命令式(gRPC / HTTP)或事件驱动(消息队列)。前者用于强一致性场景(如创建订单前校验用户余额),后者用于最终一致性(如订单完成触发用户积分更新)。
- 跨上下文调用必须走 API 层或事件总线,不能穿透 domain 层
- 事件 payload 必须是值对象,不含任何对方领域的实体引用——例如发
UserVerifiedEvent{UserID: "u123"},而不是User{ID: "u123", Name: "xxx"} - gRPC 接口定义(.proto)应放在各自服务的
api/目录下,由 consumer 生成 client,不共享 Go 类型
真正难的不是写对一个 Order 结构体,而是守住上下文之间的那条线。一旦越界,DDD 就退化成带分层目录的 CRUD 工程。



















