domain/包只允许放聚合根结构体(如*Order)、值对象(如Money)、repository接口(如OrderRepository);禁止infra依赖,ID公开、状态私有,构造函数校验ID,状态变更方法返回error,事件同步暂存、事务提交后发布。

Go 里没有 DDD 框架,也不存在开箱即用的领域建模支持——你得靠包结构、接口定义和依赖流向自己把边界立住,否则很快就是“DDD 名义下的大泥球”。
domain/ 包里只能放什么?
domain/ 是整个 DDD 结构的锚点,它一旦被 infra 或 transport 层污染,分层就塌了。这个包只允许出现三类东西:
- 聚合根结构体(如
*Order、*Cargo),ID 字段公开(id string),内部状态字段小写(items []OrderItem) - 值对象(如
Money、SKU、Location),必须不可变,无 ID,相等性按字段全等判断 - repository 接口(如
OrderRepository、CargoRepository),方法要窄:只提供Save()、FindByID()这类原子能力
禁止出现:database/sql、gin.Context、logrus、time.Now()(应由上层传入时间戳)、任何 infra 层 struct 或 client。用 go list -f '{{.ImportPath}} -> {{join .Imports "\n"}}' ./domain 定期检查是否违规。
聚合根怎么写才不破界?
聚合根不是靠继承或框架标记出来的,是靠构造函数 + 私有字段 + 方法封装共同守住的。关键动作都在代码纪律里:
立即学习“go语言免费学习笔记(深入)”;
- 构造函数必须返回指针并校验 ID:
if id == "" { return nil, errors.New("id required") } - 所有状态变更方法(
Confirm()、Cancel()、AssignToRoute())返回error,不静默失败 - 禁止外部直接改字段:
order.Status = "confirmed"是错的;order.Confirm()才是对的 - 跨聚合引用只存 ID(
customerID string),不存*Customer指针或嵌入结构体
参考 GoDDD 项目中 Cargo 的设计:Origin 和 Destination 是 Location 值对象,Route 是另一个聚合的 ID 引用,不是嵌套结构体。
Repository 接口放哪?实现怎么写?
接口定义在 domain/,实现必须在 infrastructure/repository/(或更细粒度如 infrastructure/repository/mysql/)。这是 Go 实践 DDD 最常翻车的一环:
- 接口不能带业务语义查询:
FindAllByStatusAndDateRange()是错的;FindByID()和Save()才是 domain 层该提供的原子能力 - infra 层实现时,可用
ent或sqlc生成扫描逻辑,但生成的类型(如ent.Order)绝不能出现在domain/中 - DTO 转换(
ToEntity()/FromEntity())放在 infra 层,不在 domain 内做Scan()或Value() - 事务控制由 application 层协调,domain 层不感知
tx或context.Context—— 如果接口需要 ctx,说明已越界
错误示例:func (r *mysqlRepo) Save(o *domain.Order) error 是对的;func (o *domain.Order) Save() error 是绝对禁止的。
领域事件怎么发才不会丢?
别用 goroutine + channel 在聚合内部异步广播事件——事务没提交就发,下游查不到上下文;handler panic,事件直接消失。Go 里安全的做法是同步暂存、应用层统一发布:
- 聚合根内用切片暂存:
events []DomainEvent,每个变更方法(Confirm())返回新事件列表 - application 层调用后拿到事件切片,在 DB 事务成功提交后,再显式调用
eventbus.Publish(events) - eventbus 实现放在 infra 层,接收类型为
interface{}或统一pubsub.Event,不 import domain 的具体 event 类型 - 测试时注入
eventbus.NoopBus{},确保事件逻辑不干扰单元测试隔离性
GoDDD 项目中 HandlingEvent 就是典型值对象,它不包含业务逻辑,只承载事实;真正触发它的 Cargo.AssignToRoute() 方法才负责生成并返回该事件。
最易被忽略的其实是「限界上下文」的物理落地——它不是命名空间或子包名,而是独立的 go.mod、独立的 API 边界、独立的数据库 schema。一个 booking/ 目录下如果 import 了 tracking/ 的具体 struct,哪怕只是 TrackingID,就已经在侵蚀上下文边界了。



















