目录

Go 语言实践 DDD 领域模型设计:从理论到落地的完整指南

为什么要在 Go 项目中引入 DDD

很多团队在项目初期采用"面条式代码"——所有逻辑堆在 handler 层,数据库操作散落各处。项目小的时候还能勉强维护,一旦业务复杂度上来,就会出现典型的"改一处、崩十处"的连锁反应。

DDD(Domain-Driven Design,领域驱动设计)的核心价值在于:它提供了一套将复杂业务逻辑结构化表达的方法论。对于 Go 语言这样强调简洁和组合的语言来说,DDD 并非要引入 Java 那套繁重的框架体系,而是借助其设计理念,让代码结构更贴近真实业务。

引入 DDD 之后,你会获得以下好处:

  • 业务语义清晰:代码本身就是业务文档,新人上手成本大幅降低
  • 变更影响可控:每次改动只需关注对应的领域边界,不会波及全局
  • 测试友好:领域逻辑纯粹,不依赖基础设施,单元测试覆盖率容易提升
  • 团队协作顺畅:开发与产品使用同一套"通用语言"(Ubiquitous Language),减少理解偏差

当然,DDD 不是银弹。如果你的项目只是简单的 CRUD 管理后台,硬套 DDD 反而增加了不必要的复杂度。DDD 最适合业务规则复杂、需求变化频繁的中大型系统。

DDD 的核心思想与分层架构

战略设计与战术设计的区别

DDD 分为两大块:战略设计和战术设计,它们解决的问题完全不同。

战略设计关注的是"大局"——如何拆分系统、如何划定边界。它的核心工具包括:

概念 作用 举例
限界上下文(Bounded Context) 定义一个模型适用的边界 订单上下文、支付上下文
通用语言(Ubiquitous Language) 团队内统一的业务术语 “下单"而非"创建 Order 记录”
上下文映射(Context Map) 描述不同上下文之间的关系 订单上下文依赖库存上下文

战术设计则聚焦"落地"——在某个限界上下文内部,如何组织代码。它的核心构件就是实体、值对象、聚合根、领域服务、仓储等,后面会逐一展开。

一个常见的误区是,很多人学 DDD 只学战术设计,跳过了战略设计。结果就是在一个划分不合理的系统里拼命建模,怎么建都别扭。先做好边界划分,再谈内部建模。

DDD 四层架构详解

经典的 DDD 分层架构由 4 层组成,每一层有明确的职责边界:

┌─────────────────────────────────────┐
│         Interface 接口层             │  ← HTTP/gRPC 入口,DTO 转换
├─────────────────────────────────────┤
│        Application 应用层            │  ← 编排用例流程,不含业务规则
├─────────────────────────────────────┤
│          Domain 领域层               │  ← 核心业务逻辑,纯领域模型
├─────────────────────────────────────┤
│      Infrastructure 基础设施层       │  ← 数据库、消息队列、外部 API
└─────────────────────────────────────┘

各层职责说明

  • 接口层(Interface):接收外部请求,做参数校验和格式转换,将 DTO 转为领域对象后交给应用层处理
  • 应用层(Application):负责流程编排,比如"先扣库存、再创建订单、最后发送通知",但它本身不包含业务规则
  • 领域层(Domain):系统的心脏,所有业务规则和领域逻辑都在这里。这一层不依赖任何外部基础设施
  • 基础设施层(Infrastructure):提供技术支撑,包括数据库实现、缓存、消息中间件等

关键原则是:依赖方向只能从外向内。领域层不能依赖应用层或基础设施层,而是通过接口反转让基础设施层来适配领域层。

Go 语言中的领域模型核心构件

实体(Entity)

实体是有唯一标识的领域对象,它的核心特征是通过 ID 来判断是否相同,而不是通过属性值。

比如两个同名同价的商品,只要 ID 不同,就是两个不同的实体。

// Product 商品实体
type Product struct {
	id        string
	name      string
	price     Money
	status    ProductStatus
	createdAt time.Time
	updatedAt time.Time
}

// NewProduct 创建商品时进行业务规则校验
func NewProduct(id, name string, price Money) (*Product, error) {
	if name == "" {
		return nil, errors.New("商品名称不能为空")
	}
	if price.Amount() <= 0 {
		return nil, errors.New("商品价格必须大于零")
	}
	now := time.Now()
	return &Product{
		id:        id,
		name:      name,
		price:     price,
		status:    ProductStatusDraft,
		createdAt: now,
		updatedAt: now,
	}, nil
}

// Publish 上架商品 —— 业务行为封装在实体内部
func (p *Product) Publish() error {
	if p.status != ProductStatusDraft {
		return errors.New("只有草稿状态的商品才能上架")
	}
	p.status = ProductStatusOnSale
	p.updatedAt = time.Now()
	return nil
}

这里有几个值得注意的设计要点:

  1. 字段私有化:用小写字段 + 方法暴露,防止外部绕过业务规则直接修改状态
  2. 构造函数校验:在 NewProduct 中完成入参检查,保证创建出来的实体一定是合法的
  3. 行为内聚Publish() 这类业务操作直接挂在实体上,而不是写在某个 Service 里

值对象(Value Object)

值对象没有唯一标识,通过属性值来判断是否相等,并且一旦创建就不可变。

典型的值对象包括:金额(Money)、地址(Address)、手机号(PhoneNumber)等。

// Money 金额值对象,不可变
type Money struct {
	amount   int64  // 使用分为单位,避免浮点精度问题
	currency string
}

func NewMoney(amount int64, currency string) (Money, error) {
	if currency == "" {
		return Money{}, errors.New("币种不能为空")
	}
	return Money{amount: amount, currency: currency}, nil
}

func (m Money) Amount() int64    { return m.amount }
func (m Money) Currency() string { return m.currency }

// Add 金额相加,返回新的值对象而非修改原对象
func (m Money) Add(other Money) (Money, error) {
	if m.currency != other.currency {
		return Money{}, errors.New("不同币种不能直接相加")
	}
	return Money{amount: m.amount + other.amount, currency: m.currency}, nil
}

// Equals 值对象通过属性判等
func (m Money) Equals(other Money) bool {
	return m.amount == other.amount && m.currency == other.currency
}

为什么要用值对象而不是直接用 float64 表示金额?因为值对象可以封装业务规则(比如币种校验、精度处理),让这些逻辑不会散落在代码各处。

实体 vs 值对象的选择标准

维度 实体 值对象
唯一标识 有,通过 ID 区分 无,通过属性值区分
可变性 有生命周期,状态会变化 创建后不可变
举例 用户、订单、商品 金额、地址、日期范围

聚合与聚合根(Aggregate Root)

聚合是 DDD 中最容易被误解的概念之一。简单来说,聚合是一组紧密关联的实体和值对象的集合,对外只暴露一个入口——聚合根。

所有对聚合内部对象的操作,都必须通过聚合根来完成,聚合根负责维护整个聚合的业务一致性。

以订单为例:

// Order 订单聚合根
type Order struct {
	id         string
	customerID string
	items      []OrderItem
	status     OrderStatus
	totalPrice Money
	createdAt  time.Time
}

// OrderItem 订单项(聚合内部实体,不对外直接暴露)
type OrderItem struct {
	productID string
	name      string
	price     Money
	quantity  int
}

// NewOrder 创建订单
func NewOrder(id, customerID string) (*Order, error) {
	if customerID == "" {
		return nil, errors.New("客户 ID 不能为空")
	}
	return &Order{
		id:         id,
		customerID: customerID,
		items:      make([]OrderItem, 0),
		status:     OrderStatusPending,
		totalPrice: Money{amount: 0, currency: "CNY"},
		createdAt:  time.Now(),
	}, nil
}

// AddItem 通过聚合根添加订单项,维护总价一致性
func (o *Order) AddItem(productID, name string, price Money, quantity int) error {
	if o.status != OrderStatusPending {
		return errors.New("只有待支付状态的订单才能添加商品")
	}
	if quantity <= 0 {
		return errors.New("商品数量必须大于零")
	}

	item := OrderItem{
		productID: productID,
		name:      name,
		price:     price,
		quantity:  quantity,
	}
	o.items = append(o.items, item)

	// 重新计算总价,保证一致性
	o.recalculateTotal()
	return nil
}

// Confirm 确认订单
func (o *Order) Confirm() error {
	if o.status != OrderStatusPending {
		return errors.New("只有待支付状态的订单才能确认")
	}
	if len(o.items) == 0 {
		return errors.New("订单中至少需要包含一个商品")
	}
	o.status = OrderStatusConfirmed
	return nil
}

func (o *Order) recalculateTotal() {
	var total int64
	for _, item := range o.items {
		total += item.price.Amount() * int64(item.quantity)
	}
	o.totalPrice = Money{amount: total, currency: "CNY"}
}

聚合设计有几条重要原则:

  1. 聚合尽量小:一个聚合只包含维护一致性真正需要的对象,不要把所有关联对象都塞进来
  2. 聚合间通过 ID 引用:订单聚合不要直接持有 Customer 实体,而是只存 customerID
  3. 一个事务只修改一个聚合:如果一个业务操作需要同时改多个聚合,考虑用领域事件来实现最终一致性

领域服务与应用服务的职责划分

不是所有业务逻辑都适合放在实体或值对象上。当一个操作涉及多个聚合,或者本身就不属于任何一个实体时,就需要用到领域服务。

// PricingService 定价领域服务
// 计算价格涉及商品、促销规则、用户会员等级等多个聚合,放在任何一个实体上都不合适
type PricingService struct{}

func (s *PricingService) CalculateOrderPrice(
	items []OrderItem,
	memberLevel MemberLevel,
	coupon *Coupon,
) (Money, error) {
	var total int64
	for _, item := range items {
		total += item.price.Amount() * int64(item.quantity)
	}

	// 会员折扣
	discount := s.getMemberDiscount(memberLevel)
	total = int64(float64(total) * discount)

	// 优惠券抵扣
	if coupon != nil && coupon.IsValid() {
		total -= coupon.DiscountAmount()
		if total < 0 {
			total = 0
		}
	}

	return NewMoney(total, "CNY")
}

func (s *PricingService) getMemberDiscount(level MemberLevel) float64 {
	switch level {
	case MemberLevelGold:
		return 0.9
	case MemberLevelPlatinum:
		return 0.85
	default:
		return 1.0
	}
}

领域服务 vs 应用服务的区别:

维度 领域服务 应用服务
所在层 Domain 领域层 Application 应用层
职责 封装跨聚合的业务规则 编排用例流程
是否包含业务逻辑 否,只做流程串联
举例 价格计算、库存校验 创建订单用例、退款流程

应用服务的典型写法是这样的:

// OrderAppService 订单应用服务
type OrderAppService struct {
	orderRepo    OrderRepository
	productRepo  ProductRepository
	pricingSvc   *PricingService
	eventPublish EventPublisher
}

// CreateOrder 创建订单用例 —— 纯流程编排,不含业务判断
func (s *OrderAppService) CreateOrder(ctx context.Context, cmd CreateOrderCommand) error {
	// 1. 查询商品信息
	products, err := s.productRepo.FindByIDs(ctx, cmd.ProductIDs)
	if err != nil {
		return fmt.Errorf("查询商品失败: %w", err)
	}

	// 2. 创建订单聚合
	order, err := NewOrder(cmd.OrderID, cmd.CustomerID)
	if err != nil {
		return err
	}

	// 3. 添加订单项(业务规则在聚合内部校验)
	for _, p := range products {
		if err := order.AddItem(p.ID(), p.Name(), p.Price(), cmd.QuantityMap[p.ID()]); err != nil {
			return err
		}
	}

	// 4. 确认订单
	if err := order.Confirm(); err != nil {
		return err
	}

	// 5. 持久化
	if err := s.orderRepo.Save(ctx, order); err != nil {
		return fmt.Errorf("保存订单失败: %w", err)
	}

	// 6. 发布领域事件
	s.eventPublish.Publish(ctx, OrderCreatedEvent{OrderID: order.ID()})

	return nil
}

注意看,应用服务里没有任何 if 条件判断业务规则,所有校验都委托给了领域对象。这就是"薄应用层、厚领域层"的设计理念。

仓储模式:隔离领域与数据持久化

仓储(Repository)的核心作用是让领域层不关心数据存在哪里。在领域层只定义接口,具体实现放在基础设施层。

// domain/repository.go —— 领域层只定义接口
type OrderRepository interface {
	FindByID(ctx context.Context, id string) (*Order, error)
	Save(ctx context.Context, order *Order) error
	Delete(ctx context.Context, id string) error
}
// infrastructure/persistence/order_repo.go —— 基础设施层提供实现
type MySQLOrderRepository struct {
	db *sql.DB
}

func NewMySQLOrderRepository(db *sql.DB) *MySQLOrderRepository {
	return &MySQLOrderRepository{db: db}
}

func (r *MySQLOrderRepository) FindByID(ctx context.Context, id string) (*Order, error) {
	row := r.db.QueryRowContext(ctx,
		"SELECT id, customer_id, status, total_price, created_at FROM orders WHERE id = ?", id)

	var orderPO OrderPO
	if err := row.Scan(&orderPO.ID, &orderPO.CustomerID, &orderPO.Status,
		&orderPO.TotalPrice, &orderPO.CreatedAt); err != nil {
		if errors.Is(err, sql.ErrNoRows) {
			return nil, ErrOrderNotFound
		}
		return nil, fmt.Errorf("查询订单失败: %w", err)
	}

	// PO -> 领域对象的转换
	return orderPO.ToDomain()
}

func (r *MySQLOrderRepository) Save(ctx context.Context, order *Order) error {
	po := NewOrderPOFromDomain(order)
	_, err := r.db.ExecContext(ctx,
		`INSERT INTO orders (id, customer_id, status, total_price, created_at)
		 VALUES (?, ?, ?, ?, ?)
		 ON DUPLICATE KEY UPDATE status = ?, total_price = ?`,
		po.ID, po.CustomerID, po.Status, po.TotalPrice, po.CreatedAt,
		po.Status, po.TotalPrice,
	)
	return err
}

这里有个重要的细节——PO(Persistence Object)和领域对象的转换。数据库里的表结构和领域模型不一定一一对应,PO 充当了两者之间的适配器。

这种分离带来的好处是:如果未来要把 MySQL 换成 MongoDB 或 PostgreSQL,只需要新增一个 Repository 实现,领域层的代码一行都不用改。

领域事件驱动:让模块间通信更优雅

当一个业务动作发生后需要通知其他模块,与其直接调用,不如发布一个领域事件,让感兴趣的模块自行订阅处理。

// 领域事件定义
type DomainEvent interface {
	EventName() string
	OccurredAt() time.Time
}

type OrderCreatedEvent struct {
	OrderID    string
	CustomerID string
	TotalPrice int64
	OccurredOn time.Time
}

func (e OrderCreatedEvent) EventName() string    { return "order.created" }
func (e OrderCreatedEvent) OccurredAt() time.Time { return e.OccurredOn }

// 事件发布接口
type EventPublisher interface {
	Publish(ctx context.Context, event DomainEvent) error
}

// 事件处理器 —— 订单创建后发送通知
type OrderCreatedHandler struct {
	notificationSvc NotificationService
}

func (h *OrderCreatedHandler) Handle(ctx context.Context, event OrderCreatedEvent) error {
	return h.notificationSvc.SendOrderConfirmation(ctx, event.CustomerID, event.OrderID)
}

领域事件的几个典型应用场景:

  • 订单创建后 → 发送确认短信/邮件
  • 支付完成后 → 更新订单状态 + 扣减库存
  • 用户注册后 → 发放新人优惠券

使用领域事件的好处是降低了模块间的耦合度。订单模块不需要知道通知模块的存在,只管发事件,由事件总线负责分发。

Go 项目中 DDD 的目录结构实践

一个合理的目录结构能直观地反映 DDD 的分层思想。以下是经过多个实际项目验证过的目录组织方式:

myapp/
├── cmd/                          # 启动入口
│   └── server/
│       └── main.go
├── internal/
│   ├── order/                    # 订单限界上下文
│   │   ├── domain/               # 领域层
│   │   │   ├── order.go          # 订单聚合根
│   │   │   ├── order_item.go     # 订单项实体
│   │   │   ├── money.go          # 金额值对象
│   │   │   ├── repository.go     # 仓储接口
│   │   │   ├── event.go          # 领域事件
│   │   │   └── service.go        # 领域服务
│   │   ├── application/          # 应用层
│   │   │   ├── command.go        # 命令对象
│   │   │   └── order_service.go  # 应用服务
│   │   ├── infrastructure/       # 基础设施层
│   │   │   ├── persistence/      # 数据持久化
│   │   │   │   ├── order_repo.go
│   │   │   │   └── order_po.go   # 持久化对象
│   │   │   └── messaging/        # 消息通信
│   │   │       └── publisher.go
│   │   └── interfaces/           # 接口层
│   │       ├── http/
│   │       │   ├── handler.go
│   │       │   └── dto.go        # 数据传输对象
│   │       └── grpc/
│   │           └── server.go
│   ├── product/                  # 商品限界上下文
│   │   ├── domain/
│   │   ├── application/
│   │   ├── infrastructure/
│   │   └── interfaces/
│   └── shared/                   # 跨上下文共享内核
│       ├── event.go
│       └── types.go
├── pkg/                          # 公共工具库
│   ├── idgen/
│   └── logger/
├── go.mod
└── go.sum

这个结构的核心思路是按限界上下文而非技术层分包。每个上下文内部再按 DDD 四层组织,这样做的好处是:

  • 某个上下文的需求变更只影响自己的目录,不会波及其他模块
  • 未来如果要将某个上下文拆成独立微服务,直接搬走整个目录即可
  • 代码导航直觉清晰,找订单相关代码就去 internal/order/

一个完整的订单领域建模案例

前面分散讲了各个概念,现在把它们串起来,看看一个完整的订单领域是怎么建模的。

首先是领域层的类型定义:

package domain

import (
	"errors"
	"fmt"
	"time"
)

// 订单状态枚举
type OrderStatus int

const (
	OrderStatusPending   OrderStatus = iota // 待确认
	OrderStatusConfirmed                     // 已确认
	OrderStatusPaid                          // 已支付
	OrderStatusShipped                       // 已发货
	OrderStatusCompleted                     // 已完成
	OrderStatusCancelled                     // 已取消
)

// Order 订单聚合根
type Order struct {
	id         string
	customerID string
	items      []OrderItem
	status     OrderStatus
	totalPrice Money
	address    Address
	createdAt  time.Time
	updatedAt  time.Time
	events     []DomainEvent
}

func NewOrder(id, customerID string, addr Address) (*Order, error) {
	if id == "" {
		return nil, errors.New("订单 ID 不能为空")
	}
	if customerID == "" {
		return nil, errors.New("客户 ID 不能为空")
	}
	now := time.Now()
	return &Order{
		id:         id,
		customerID: customerID,
		items:      make([]OrderItem, 0),
		status:     OrderStatusPending,
		totalPrice: Money{amount: 0, currency: "CNY"},
		address:    addr,
		createdAt:  now,
		updatedAt:  now,
		events:     make([]DomainEvent, 0),
	}, nil
}

// AddItem 添加订单项
func (o *Order) AddItem(productID, name string, price Money, qty int) error {
	if o.status != OrderStatusPending {
		return fmt.Errorf("当前状态 %d 不允许添加商品", o.status)
	}
	if qty <= 0 {
		return errors.New("数量必须大于 0")
	}

	// 检查是否已存在相同商品,合并数量
	for i, item := range o.items {
		if item.productID == productID {
			o.items[i].quantity += qty
			o.recalculateTotal()
			o.updatedAt = time.Now()
			return nil
		}
	}

	o.items = append(o.items, OrderItem{
		productID: productID,
		name:      name,
		price:     price,
		quantity:  qty,
	})
	o.recalculateTotal()
	o.updatedAt = time.Now()
	return nil
}

// Confirm 确认订单
func (o *Order) Confirm() error {
	if o.status != OrderStatusPending {
		return errors.New("只有待确认的订单才能确认")
	}
	if len(o.items) == 0 {
		return errors.New("空订单无法确认")
	}
	o.status = OrderStatusConfirmed
	o.updatedAt = time.Now()

	// 记录领域事件
	o.events = append(o.events, OrderConfirmedEvent{
		OrderID:    o.id,
		CustomerID: o.customerID,
		OccurredOn: time.Now(),
	})
	return nil
}

// Pay 支付订单
func (o *Order) Pay() error {
	if o.status != OrderStatusConfirmed {
		return errors.New("只有已确认的订单才能支付")
	}
	o.status = OrderStatusPaid
	o.updatedAt = time.Now()

	o.events = append(o.events, OrderPaidEvent{
		OrderID:    o.id,
		TotalPrice: o.totalPrice.Amount(),
		OccurredOn: time.Now(),
	})
	return nil
}

// Cancel 取消订单
func (o *Order) Cancel(reason string) error {
	if o.status == OrderStatusShipped || o.status == OrderStatusCompleted {
		return errors.New("已发货或已完成的订单不能取消")
	}
	if o.status == OrderStatusCancelled {
		return errors.New("订单已经处于取消状态")
	}
	o.status = OrderStatusCancelled
	o.updatedAt = time.Now()

	o.events = append(o.events, OrderCancelledEvent{
		OrderID:    o.id,
		Reason:     reason,
		OccurredOn: time.Now(),
	})
	return nil
}

// PullEvents 获取并清空领域事件
func (o *Order) PullEvents() []DomainEvent {
	events := o.events
	o.events = make([]DomainEvent, 0)
	return events
}

func (o *Order) recalculateTotal() {
	var total int64
	for _, item := range o.items {
		total += item.price.Amount() * int64(item.quantity)
	}
	o.totalPrice = Money{amount: total, currency: o.totalPrice.Currency()}
}

// Getter 方法
func (o *Order) ID() string            { return o.id }
func (o *Order) CustomerID() string     { return o.customerID }
func (o *Order) Status() OrderStatus    { return o.status }
func (o *Order) TotalPrice() Money      { return o.totalPrice }
func (o *Order) Items() []OrderItem     { return o.items }
func (o *Order) ItemCount() int         { return len(o.items) }

然后是地址值对象:

// Address 地址值对象
type Address struct {
	province string
	city     string
	district string
	street   string
	zipCode  string
}

func NewAddress(province, city, district, street, zipCode string) (Address, error) {
	if province == "" || city == "" {
		return Address{}, errors.New("省份和城市不能为空")
	}
	return Address{
		province: province,
		city:     city,
		district: district,
		street:   street,
		zipCode:  zipCode,
	}, nil
}

func (a Address) FullAddress() string {
	return fmt.Sprintf("%s%s%s%s", a.province, a.city, a.district, a.street)
}

func (a Address) Equals(other Address) bool {
	return a.province == other.province &&
		a.city == other.city &&
		a.district == other.district &&
		a.street == other.street &&
		a.zipCode == other.zipCode
}

这个案例展示了几个关键的建模技巧:

  • 聚合根内部维护事件列表,通过 PullEvents() 方法在持久化之后统一发布
  • 相同商品添加时自动合并数量,这类业务规则封装在聚合根内部
  • 状态流转有严格的校验,不允许非法的状态跳转

常见的 DDD 落地误区与避坑指南

在实际项目中推行 DDD 时,我踩过不少坑,这里把最常见的几个误区分享出来。

误区一:贫血模型伪装成 DDD

最常见的问题。表面上有 Entity、Repository 的结构,但实体只有 getter/setter,所有逻辑都写在 Service 里。这本质上还是传统的三层架构,只是换了个 DDD 的壳。

// 反面教材 —— 贫血模型
type Order struct {
	ID     string
	Status int
	Price  float64
}

// 逻辑全在 Service 里,Order 只是数据容器
func (s *OrderService) ConfirmOrder(order *Order) {
	order.Status = 1  // 直接改状态,没有业务校验
}

正确做法是让实体自己保护自己的状态一致性,业务规则内聚在实体方法中。

误区二:聚合设计得太大

有人恨不得把整个系统塞进一个聚合。订单聚合里包含用户信息、商品详情、物流信息……结果每次加载一个订单都要连带查一堆数据,性能急剧下降。

记住原则:聚合边界应该尽量小,只包含必须保持强一致性的对象。 聚合之间通过 ID 引用。

误区三:忽视通用语言

开发说"创建一条 record",产品说"提交一笔订单",测试说"生成一个 order"。大家说的都是同一件事,但用了不同的词,沟通成本就会倍增。

花时间和团队统一术语表,并且让代码中的命名与业务术语一致,长期来看这笔投入非常值得。

误区四:不做战略设计就直接写代码

没搞清楚限界上下文就开始建模,结果一个 Order 实体在不同场景下承载了完全不同的含义——下单时它是购物车,支付时它是付款单,发货时它是物流单。

正确的做法是先画上下文映射图,明确每个上下文里相同名词的不同含义。

常见问题

Q1:Go 语言没有继承,怎么实现 DDD 中的实体基类?

Go 推崇组合而非继承。可以定义一个 BaseEntity 结构体,然后在各实体中嵌入它:

type BaseEntity struct {
	id        string
	createdAt time.Time
	updatedAt time.Time
}

func (b BaseEntity) ID() string           { return b.id }
func (b BaseEntity) CreatedAt() time.Time { return b.createdAt }

type Order struct {
	BaseEntity
	customerID string
	status     OrderStatus
	// ...
}

Q2:DDD 适合所有项目吗?

不适合。如果项目的核心价值就是简单的数据增删改查,比如内部管理后台、简单的信息展示系统,使用 DDD 反而增加不必要的复杂度。DDD 最适合业务逻辑复杂、规则多变的核心业务系统。

Q3:一个聚合根里可以包含多少个实体?

没有硬性限制,但经验上来说,一个聚合内部的对象数量最好控制在 3-5 个以内。如果发现一个聚合越来越膨胀,大概率是需要拆分成多个聚合。

Q4:领域事件是同步处理还是异步处理?

看场景。同一个限界上下文内的事件通常同步处理,保证事务一致性;跨上下文的事件一般走消息队列异步处理,实现最终一致性。Go 中可以用 channel 或者接入 Kafka、RabbitMQ 等消息中间件。

Q5:仓储接口应该定义哪些方法?

遵循"只定义领域需要的方法"原则。不要一上来就定义一堆通用的 CRUD 方法。比如如果业务上没有按状态查询订单的需求,就不要在接口中加 FindByStatus 方法。让业务场景驱动接口设计。

Q6:DDD 和微服务是什么关系?

DDD 的限界上下文天然是微服务拆分的最佳边界。但 DDD 不等于微服务——你完全可以在单体应用中使用 DDD 来组织代码。等到业务规模增长后,再沿着限界上下文的边界把单体拆分成微服务,成本会非常低。

总结

DDD 领域模型设计的本质,是让代码结构忠实地反映业务逻辑。在 Go 语言项目中落地 DDD,关键要把握以下几点:

  1. 先做战略设计:通过事件风暴、领域分析等手段理清限界上下文和通用语言,这是一切的基础
  2. 实体要有行为:避免贫血模型,让实体自己保护自己的业务一致性
  3. 善用值对象:金额、地址等概念用值对象封装,消除原始类型散落带来的维护隐患
  4. 聚合要够小:只包含强一致性要求的对象,聚合间用 ID 关联 + 领域事件通信
  5. 仓储做好隔离:领域层定义接口,基础设施层提供实现,让领域逻辑不受技术选型影响
  6. 应用层要够薄:只做流程编排,不做业务判断,业务规则全部下沉到领域层

DDD 不是一天学会的,它需要在实践中不断磨合和调整。建议从一个业务复杂度适中的模块开始试点,积累经验后再逐步推广到整个系统。


如果大家对 Go 语言中 DDD 领域模型设计还有哪些不清楚的地方,或者在实际项目落地过程中遇到了什么困难,欢迎大家在评论区交流讨论~~~

版权声明

未经授权,禁止转载本文章。
如需转载请保留原文链接并注明出处。即视为默认获得授权。
未保留原文链接未注明出处或删除链接将视为侵权,必追究法律责任!

本文原文链接: https://fiveyoboy.com/articles/golang-ddd-domain-model-design-guide/

备用原文链接: https://blog.fiveyoboy.com/articles/golang-ddd-domain-model-design-guide/