Files
junhong_cmp_fiber/docs/7月迭代/DDD规范.md
2026-07-16 15:07:59 +08:00

17 KiB
Raw Blame History

项目 DDD 设计规范

状态:待评审 务实型 DDD——不是学院派不强制 Event Sourcing不追求完美追求可维护、可扩展、AI 辅助友好。 基准文档:docs/改造方向.md(绞杀者模式)


一、为什么用 DDD / 什么不是 DDD

现状问题

  • order/service.go 3031 行:订单创建、支付、佣金计算、代购、库存扣减全混一起
  • Model 只是 GORM 映射,没有业务行为(贫血模型)
  • 新增业务联动必须修改原有 Service牵一发动全身

目标

不是重写,是渐进替换

  1. 复杂写功能:进入 Application + Domain不继续堆入旧 Service
  2. 简单写功能:使用 Application 事务脚本,不强行创建聚合
  3. 读取功能:进入 Query 读取模型,不通过聚合根
  4. 旧功能迁移:只在需求触碰时迁移一个完整用例,不做模块级重写
  5. AI 辅助结构清晰AI 生成代码时自动落入正确位置

二、什么时候进入 DDD

DDD 是解决复杂业务边界的手段,不是所有功能的默认目录模板。开始设计前,先判断该需求属于“局部数据操作”还是“独立业务能力”。

2.1 优先使用 DDD 的场景

当前需求正在修改的用例满足以下任一条件时,应优先建立或扩展领域模块:

  1. 有明确生命周期、状态机、状态转换限制或并发不变量。
  2. 一个操作包含多个业务规则、跨模块协作、事务边界或可靠事件。
  3. 同一业务能力会被多个业务场景复用,需要稳定的领域接口。
  4. 需要策略扩展、规则配置、版本快照、审计追溯或幂等处理。
  5. 继续向旧 Service 增加分支,会导致职责继续膨胀或修改相互影响。

审批流、钱包额度、订单支付、佣金结算等属于典型 DDD 场景。

2.2 通常不迁移的场景

以下需求通常保持原有 Handler → Service → Store → Model

  1. 单表 CRUD、字段增删、简单列表筛选和格式转换。
  2. 局部缺陷修复,且不改变业务边界或核心规则。
  3. 没有独立领域语言、生命周期或跨模块不变量。
  4. 引入聚合、仓储接口和领域事件后只有样板代码,没有业务收益。

2.3 触碰式迁移

  • 默认不迁移当前需求未触碰的旧代码,禁止借功能修改之名扩大为模块级重构。
  • 迁移单位是完整用例,不是整个模块、文件或数据表。
  • 简单字段、筛选、格式转换和局部修复默认沿用旧结构。
  • 触碰复杂写逻辑时,只迁移完成当前需求所需的最小完整业务边界。
  • 一旦迁移某个用例,其状态规则和业务不变量必须完整收口,禁止一半留在旧 Service、一半进入 Domain。
  • 旧 Service 可以暂时作为内部代码迁移门面调用新 UseCase调用方迁完后再删除。该规则不代表必须保留旧 HTTP 接口,七月迭代审批相关接口按停机方案直接切换。
  • 新旧模块通过 Application 接口、领域事件或防腐层交互,禁止绕过边界直接修改聚合数据。

2.4 查询侧规则

复杂查询不通过聚合根,统一采用轻量 CQRS 读取模型:

Handler → Query → GORM/DTO
  • 列表、详情、联表、统计、报表和导出属于 Query。
  • Query 可以直接使用 GORM、CTE、子查询和批量查询并负责读取权限与分页。
  • Query 返回专用 DTO/Projection禁止返回后用于业务写入。
  • 只有查询结果参与写操作判定时Domain 必须重新校验,不能信任读取快照。
  • 当前需求未触碰的旧查询不迁移;复杂度明显增加时,只迁移该查询到 internal/query/<context>
  • Aggregate Repository 只负责加载和保存聚合,不承载多表列表、报表或导出查询。

三、目录结构

internal/
├── domain/                     ← 领域层(不依赖 Fiber/Redis/GORM
│   ├── wallet/
│   │   ├── wallet.go           ← 聚合根(含业务方法)
│   │   ├── events.go           ← 领域事件定义
│   │   ├── repository.go       ← 仓储接口interface
│   │   └── vo.go               ← 值对象Money, CreditLimit
│   ├── approval/               ← 新:审批流领域
│   ├── notification/           ← 新:通知领域
│   ├── distribution/           ← 后续预留需求16独立立项后再创建
│   └── package/                ← 套餐领域(迁移中)
│
├── application/                ← 应用层(用例,只做编排,不含业务判断)
│   ├── wallet/
│   │   ├── debit_wallet.go     ← 一个文件 = 一个用例
│   │   ├── credit_wallet.go
│   │   └── grant_credit.go     ← 新:授信
│   ├── approval/
│   │   ├── submit_approval.go
│   │   ├── advance_step.go
│   │   └── reject_approval.go
│   └── notification/
│       └── send_notification.go
│
├── query/                      ← 读取侧(可直接使用 GORM返回 DTO/Projection
│   ├── order/
│   ├── refund/
│   ├── approval/
│   ├── dashboard/
│   └── report/
│
├── infrastructure/             ← 基础设施层(实现 domain 里的 interface
│   ├── persistence/            ← GORM Repository可委托现有 Store
│   ├── messaging/              ← Outbox Relay、Asynq 发布与消费
│   └── adapter/                ← 外部系统和旧模块防腐层
│
├── handler/                    ← 原有 handler保持不动
├── service/                    ← 原有 service保持不动新模块不在这里
└── store/                      ← 原有 store保持不动

过渡期规则

  • 旧模块:internal/service/xxx + internal/store/postgres/xxx
  • 复杂写用例:internal/domain/xxx + internal/application/xxx
  • 简单写用例:internal/application/xxx
  • 读取用例:internal/query/xxx
  • Handler 按用例调用 Application、Query 或尚未迁移的旧 Service
  • 全新领域优先使用纯领域对象;迁移旧模块时可临时复用现有 GORM Model但不得让 Fiber、Redis 或 GORM 查询进入业务方法

四、领域模块划分

领域 聚合根 核心业务规则 状态
Asset资产 IotCard, Device 停复机条件、实名策略 迁移中
Order订单 Order 支付、退款、佣金触发 迁移中
Package套餐 Package, PackageUsage 生效条件、临期计算 迁移中
Wallet钱包 AgentWallet 余额扣减、信用额度、负余额 新功能用 DDD
Shop代理 Shop 层级关系、信用策略 部分迁移
Approval审批 ProcessDefinition, ProcessInstance 流程版本、审批人解析、状态推进、驳回 全新 DDD
Notification通知 Notification 分发、已读状态 全新 DDD
Distribution分销 DistributionRelation 发展人体系、二维码注册 后续独立立项,本期不创建

五、聚合根设计原则(富模型)

5.1 核心原则

业务规则住在聚合根里,外部只通过方法操作,不能直接改字段。

// ❌ 贫血模型(禁止)——所有判断在 Service 里
func (s *WalletService) Debit(ctx context.Context, walletID uint, amount int64) error {
    wallet, _ := s.store.Get(ctx, walletID)
    if wallet.Balance-wallet.FrozenBalance < amount {
        return errors.New(errors.CodeInsufficientBalance)
    }
    wallet.Balance -= amount
    s.store.Update(ctx, wallet)
}

// ✅ 富模型(推荐)——判断逻辑在聚合根里
func (w *AgentWallet) Debit(amount Money) error {
    available := w.Balance - w.FrozenBalance + w.CreditLimit  // 含信用额度
    if available < amount.Cents() {
        return ErrInsufficientBalance
    }
    w.Balance -= amount.Cents()
    w.recordEvent(WalletDebitedEvent{Amount: amount, BalanceAfter: w.Balance})
    return nil
}

// Application 层只做编排
func (uc *DebitWalletUseCase) Execute(ctx context.Context, cmd DebitCommand) error {
    wallet, err := uc.repo.GetByID(ctx, cmd.WalletID)
    if err != nil { return err }
    if err := wallet.Debit(cmd.Amount); err != nil { return err }
    // 完整事务和 Outbox 写入方式见“应用服务(用例)”章节
    return uc.repo.Save(ctx, wallet)
}

5.2 聚合根必须包含

type AggregateRoot struct {
    // 业务字段...

    // 未发布领域事件,由 Application 在事务内写入 Outbox
    domainEvents []DomainEvent
}

// PopEvents 获取并清空领域事件
func (a *AggregateRoot) PopEvents() []DomainEvent {
    events := a.domainEvents
    a.domainEvents = nil
    return events
}

func (a *AggregateRoot) recordEvent(e DomainEvent) {
    a.domainEvents = append(a.domainEvents, e)
}

5.3 与现有 GORM 的共存

迁移旧模块时,聚合根可以临时包装现有 GORM Model减少一次性重构成本

// internal/domain/wallet/wallet.go
// AgentWallet 钱包聚合根
// 直接复用并扩展现有 model.AgentWallet
type AgentWallet struct {
    model.AgentWallet // 迁移期复用持久化字段
    domainEvents []DomainEvent
}

// Debit 从钱包扣款(含信用额度)
func (w *AgentWallet) Debit(amount Money) error {
    available := w.Balance - w.FrozenBalance + w.CreditLimit
    if available < amount.Cents() {
        return ErrInsufficientBalance
    }
    w.Balance -= amount.Cents()
    w.recordEvent(WalletDebitedEvent{...})
    return nil
}

这只是迁移期折中,不是新领域的默认形式。审批流等全新领域应优先使用纯领域对象,由 Infrastructure 负责领域对象与 GORM Model 的转换。


六、值对象设计

值对象:没有 ID靠值来判断相等不可变。

// internal/domain/wallet/vo.go

// Money 金额值对象(分为单位,防止浮点数精度问题)
type Money struct {
    cents int64
}

func NewMoney(cents int64) (Money, error) {
    if cents < 0 {
        return Money{}, ErrNegativeMoney
    }
    return Money{cents: cents}, nil
}

func (m Money) Cents() int64    { return m.cents }
func (m Money) Yuan() float64   { return float64(m.cents) / 100 }
func (m Money) Add(o Money) Money { return Money{cents: m.cents + o.cents} }

// CreditLimit 信用额度值对象
type CreditLimit struct {
    maxCents     int64  // 最大授信额度(分)
    allowNegative bool  // 是否允许负余额
}

七、领域事件与可靠投递

领域对象只记录已经发生的业务事实,不直接调用 Asynq。Application 在保存聚合的同一数据库事务中写入 Outbox再由 Relay 异步投递到 Asynq。

// internal/domain/wallet/events.go

// DomainEvent 领域事件接口
type DomainEvent interface {
    EventType() string
    OccurredAt() time.Time
}

// WalletDebitedEvent 钱包扣款事件
type WalletDebitedEvent struct {
    WalletID     uint
    ShopID       uint
    Amount       Money
    BalanceBefore int64
    BalanceAfter  int64
    RefType      string
    RefID        uint
    occurredAt   time.Time
}

func (e WalletDebitedEvent) EventType() string    { return "wallet.debited" }
func (e WalletDebitedEvent) OccurredAt() time.Time { return e.occurredAt }

事务边界

数据库事务
  ├── 保存聚合
  ├── 保存操作日志
  └── 保存 Outbox 事件
提交事务
  ↓
Outbox Relay → Asynq → 事件处理器

Application UseCase 保存事件

func (uc *DebitWalletUseCase) Execute(ctx context.Context, cmd DebitCommand) error {
    return uc.txManager.RunInTx(ctx, func(txCtx context.Context) error {
        wallet, err := uc.walletRepo.GetByID(txCtx, cmd.WalletID)
        if err != nil {
            return err
        }
        if err := wallet.Debit(cmd.Amount); err != nil {
            return err
        }
        if err := uc.walletRepo.Save(txCtx, wallet); err != nil {
            return err
        }
        return uc.outboxRepo.Append(txCtx, wallet.PopEvents())
    })
}
  • Outbox 写入失败:事务回滚,避免“业务成功但关键事件永久丢失”。
  • Asynq 暂时不可用不回滚已提交业务Relay 后续重试。
  • 消费者必须幂等;涉及余额、退款、充值时使用业务键或状态条件更新。

八、仓储接口

// internal/domain/wallet/repository.go

// WalletRepository 钱包仓储接口
type WalletRepository interface {
    GetByShopID(ctx context.Context, shopID uint, walletType string) (*AgentWallet, error)
    GetByID(ctx context.Context, id uint) (*AgentWallet, error)
    Save(ctx context.Context, wallet *AgentWallet) error
}

实现在 internal/infrastructure/persistence/wallet_repo.go,可以复用现有 Store 逻辑。事务由 Application 注入的 TxManager 管理Repository 从事务上下文获取 GORM tx,领域接口不得暴露 *gorm.DB


九、应用服务(用例)

一个文件 = 一个用例。

  • 复杂写用例Application 只做事务和跨聚合编排,业务不变量位于 Domain。
  • 简单写用例Application 可以使用事务脚本完成基础校验和单表写入,不要求创建聚合。
  • Application 不负责列表、报表和复杂 DTO 拼装,这些职责属于 Query。
// internal/application/wallet/debit_wallet.go

// DebitCommand 扣款命令
type DebitCommand struct {
    WalletID  uint
    Amount    domainWallet.Money
    RefType   string
    RefID     uint
    OperatorID uint
}

// DebitWalletUseCase 扣款用例
type DebitWalletUseCase struct {
    walletRepo domainWallet.WalletRepository
    outboxRepo OutboxRepository
    txManager  TxManager
}

// Execute 执行扣款
func (uc *DebitWalletUseCase) Execute(ctx context.Context, cmd DebitCommand) error {
    return uc.txManager.RunInTx(ctx, func(txCtx context.Context) error {
        wallet, err := uc.walletRepo.GetByID(txCtx, cmd.WalletID)
        if err != nil {
            return err
        }
        if err := wallet.Debit(cmd.Amount); err != nil {
            return err
        }
        if err := uc.walletRepo.Save(txCtx, wallet); err != nil {
            return err
        }
        return uc.outboxRepo.Append(txCtx, wallet.PopEvents())
    })
}

十、Handler 层调用方式

  • 复杂写、简单写Handler → Application UseCase。
  • 读取Handler → Query。
  • 尚未迁移的旧用例Handler → Service。
  • Handler 不直接访问 GORM也不承载业务规则或复杂 DTO 拼装。
// internal/handler/admin/wallet_handler.go
func (h *WalletHandler) GrantCredit(c *fiber.Ctx) error {
    var req dto.GrantCreditRequest
    // ... 参数解析

    cmd := walletApp.GrantCreditCommand{
        ShopID:    req.ShopID,
        MaxCredit: domainWallet.NewCreditLimit(req.MaxCreditCents),
        OperatorID: middleware.GetUserID(c),
    }
    if err := h.grantCreditUseCase.Execute(c.UserContext(), cmd); err != nil {
        return err
    }
    return response.OK(c, nil)
}

十一、禁止事项

禁止 原因
在 Application 层实现复杂业务不变量 状态机、金额、库存等规则必须在领域对象里
跨聚合直接访问另一聚合的字段 只能通过 ID 引用,运行时通过 Repository 加载
在领域层 import Fiber/GORM/Redis 领域层不依赖基础设施
在一个用例文件里实现多个业务流程 一文件一用例
Repository 保存后直接发布关键事件 数据已提交但消息可能丢失,必须事务写 Outbox
Outbox 未落库仍提交业务事务 会造成业务成功但关键回调永久缺失
在聚合根里调用 Repository 聚合根不依赖仓储
Query 执行写操作 Query 只负责读取模型,写入必须进入 Application
使用 Query 快照替代写侧校验 查询结果可能已过期Domain 必须重新验证不变量
在 Aggregate Repository 中堆叠报表联查 聚合仓储负责加载和保存聚合,复杂读取属于 Query
只创建 domain/application 目录但业务规则仍在旧 Service 这是目录搬迁,不是 DDD 迁移

十二、本次迭代 DDD 落地计划

新模块 领域路径 用例路径
信用额度需求17 internal/domain/wallet/ internal/application/wallet/
审批流需求18/20/21 internal/domain/approval/ internal/application/approval/,使用版本快照和 Outbox
站内消息需求18/22 internal/domain/notification/ internal/application/notification/
批量订购需求19 复用 Order 领域 internal/application/order/bulk_purchase.go

需求 16 已于 2026-07-14 移出 7 月迭代,本期不创建 distribution 领域目录、聚合和应用用例;后续独立立项时再按本规范判断领域边界。