# 项目 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 读取模型: ```text Handler → Query → GORM/DTO ``` - 列表、详情、联表、统计、报表和导出属于 Query。 - Query 可以直接使用 GORM、CTE、子查询和批量查询,并负责读取权限与分页。 - Query 返回专用 DTO/Projection,禁止返回后用于业务写入。 - 只有查询结果参与写操作判定时,Domain 必须重新校验,不能信任读取快照。 - 当前需求未触碰的旧查询不迁移;复杂度明显增加时,只迁移该查询到 `internal/query/`。 - 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 核心原则 业务规则住在聚合根里,外部只通过方法操作,不能直接改字段。 ```go // ❌ 贫血模型(禁止)——所有判断在 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 聚合根必须包含 ```go 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,减少一次性重构成本: ```go // 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,靠值来判断相等,不可变。 ```go // 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。 ```go // 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 } ``` **事务边界**: ```text 数据库事务 ├── 保存聚合 ├── 保存操作日志 └── 保存 Outbox 事件 提交事务 ↓ Outbox Relay → Asynq → 事件处理器 ``` **Application UseCase 保存事件**: ```go 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 后续重试。 - 消费者必须幂等;涉及余额、退款、充值时使用业务键或状态条件更新。 --- ## 八、仓储接口 ```go // 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。 ```go // 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 拼装。 ```go // 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` 领域目录、聚合和应用用例;后续独立立项时再按本规范判断领域边界。