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
}这里有几个值得注意的设计要点:
- 字段私有化:用小写字段 + 方法暴露,防止外部绕过业务规则直接修改状态
- 构造函数校验:在
NewProduct中完成入参检查,保证创建出来的实体一定是合法的 - 行为内聚:
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"}
}聚合设计有几条重要原则:
- 聚合尽量小:一个聚合只包含维护一致性真正需要的对象,不要把所有关联对象都塞进来
- 聚合间通过 ID 引用:订单聚合不要直接持有 Customer 实体,而是只存
customerID - 一个事务只修改一个聚合:如果一个业务操作需要同时改多个聚合,考虑用领域事件来实现最终一致性
领域服务与应用服务的职责划分
不是所有业务逻辑都适合放在实体或值对象上。当一个操作涉及多个聚合,或者本身就不属于任何一个实体时,就需要用到领域服务。
// 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,关键要把握以下几点:
- 先做战略设计:通过事件风暴、领域分析等手段理清限界上下文和通用语言,这是一切的基础
- 实体要有行为:避免贫血模型,让实体自己保护自己的业务一致性
- 善用值对象:金额、地址等概念用值对象封装,消除原始类型散落带来的维护隐患
- 聚合要够小:只包含强一致性要求的对象,聚合间用 ID 关联 + 领域事件通信
- 仓储做好隔离:领域层定义接口,基础设施层提供实现,让领域逻辑不受技术选型影响
- 应用层要够薄:只做流程编排,不做业务判断,业务规则全部下沉到领域层
DDD 不是一天学会的,它需要在实践中不断磨合和调整。建议从一个业务复杂度适中的模块开始试点,积累经验后再逐步推广到整个系统。
如果大家对 Go 语言中 DDD 领域模型设计还有哪些不清楚的地方,或者在实际项目落地过程中遇到了什么困难,欢迎大家在评论区交流讨论~~~
版权声明
未经授权,禁止转载本文章。
如需转载请保留原文链接并注明出处。即视为默认获得授权。
未保留原文链接未注明出处或删除链接将视为侵权,必追究法律责任!
本文原文链接: https://fiveyoboy.com/articles/golang-ddd-domain-model-design-guide/
备用原文链接: https://blog.fiveyoboy.com/articles/golang-ddd-domain-model-design-guide/