11 KiB
11 KiB
项目 DDD 设计规范
务实型 DDD——不是学院派,不强制 Event Sourcing,不追求完美,追求可维护、可扩展、AI 辅助友好。
基准文档:docs/改造方向.md(绞杀者模式)
一、为什么用 DDD / 什么不是 DDD
现状问题
order/service.go3031 行:订单创建、支付、佣金计算、代购、库存扣减全混一起- Model 只是 GORM 映射,没有业务行为(贫血模型)
- 新增业务联动必须修改原有 Service,牵一发动全身
目标
不是重写,是渐进替换:
- 新功能按新结构写,不往旧 Service 加代码
- 旧功能迁移:每次迁移一个用例,跑通后再下一个
- AI 辅助:结构清晰,AI 生成代码时自动落入正确位置
二、目录结构
internal/
├── domain/ ← 领域层(纯业务逻辑,不依赖 Fiber/GORM)
│ ├── wallet/
│ │ ├── wallet.go ← 聚合根(含业务方法)
│ │ ├── events.go ← 领域事件定义
│ │ ├── repository.go ← 仓储接口(interface)
│ │ └── vo.go ← 值对象(Money, CreditLimit)
│ ├── approval/ ← 新:审批流领域
│ ├── notification/ ← 新:通知领域
│ ├── distribution/ ← 新:分销领域
│ └── 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
│
├── infrastructure/ ← 基础设施层(实现 domain 里的 interface)
│ └── persistence/ ← 原来的 store/postgres/(保持不动)
│
├── handler/ ← 原有 handler(保持不动)
├── service/ ← 原有 service(保持不动,新模块不在这里)
└── store/ ← 原有 store(保持不动)
过渡期规则:
- 旧模块:
internal/service/xxx+internal/store/postgres/xxx - 新模块:
internal/domain/xxx+internal/application/xxx - Handler 层统一调 Application 层(新)或 Service 层(旧)
三、领域模块划分
| 领域 | 聚合根 | 核心业务规则 | 状态 |
|---|---|---|---|
| Asset(资产) | IotCard, Device |
停复机条件、实名策略 | 迁移中 |
| Order(订单) | Order |
支付、退款、佣金触发 | 迁移中 |
| Package(套餐) | Package, PackageUsage |
生效条件、临期计算 | 迁移中 |
| Wallet(钱包) | AgentWallet |
余额扣减、信用额度、负余额 | 新功能用 DDD |
| Shop(代理) | Shop |
层级关系、信用策略 | 部分迁移 |
| Approval(审批) | ApprovalFlow |
多级审批、推进、驳回 | 全新 DDD |
| Notification(通知) | Notification |
分发、已读状态 | 全新 DDD |
| Distribution(分销) | DistributionRelation |
发展人体系、二维码注册 | 全新 DDD |
四、聚合根设计原则(富模型)
4.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 }
return uc.repo.Save(ctx, wallet)
}
4.2 聚合根必须包含
type AggregateRoot struct {
// 内嵌 GORM model(过渡期)
gorm.Model
// 业务字段...
// 未发布领域事件(内存中,Save 后由框架分发)
domainEvents []DomainEvent `gorm:"-" json:"-"`
}
// 获取并清空领域事件(由 Repository.Save 调用)
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)
}
4.3 与现有 GORM 的共存
过渡期,聚合根 就是 现有的 GORM Model(加业务方法),不需要单独建领域对象:
// internal/domain/wallet/wallet.go
// AgentWallet 钱包聚合根
// 直接复用并扩展现有 model.AgentWallet
type AgentWallet struct {
model.AgentWallet // 内嵌原有 GORM 模型
domainEvents []DomainEvent `gorm:"-"`
}
// 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
}
五、值对象设计
值对象:没有 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 实现)
领域事件不用消息队列,直接复用现有 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 }
Repository.Save 发布事件:
// internal/infrastructure/persistence/wallet_repo.go
func (r *WalletRepository) Save(ctx context.Context, wallet *domainWallet.AgentWallet) error {
err := r.db.WithContext(ctx).Save(&wallet.AgentWallet).Error
if err != nil {
return err
}
// 发布领域事件
for _, evt := range wallet.PopEvents() {
if err := r.publishEvent(ctx, evt); err != nil {
r.logger.Error("领域事件发布失败", zap.Error(err), zap.String("type", evt.EventType()))
// 事件发布失败不回滚业务(异步,可补偿)
}
}
return nil
}
七、仓储接口
// 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
SaveInTx(ctx context.Context, tx *gorm.DB, wallet *AgentWallet) error
}
实现在 internal/infrastructure/persistence/wallet_repo.go,复用现有 Store 逻辑。
八、应用服务(用例)
一个文件 = 一个用例。用例只做编排,不含业务判断。
// 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
txManager TxManager
}
// Execute 执行扣款
func (uc *DebitWalletUseCase) Execute(ctx context.Context, cmd DebitCommand) error {
return uc.txManager.RunInTx(ctx, func(tx *gorm.DB) error {
wallet, err := uc.walletRepo.GetByID(ctx, cmd.WalletID)
if err != nil {
return err
}
if err := wallet.Debit(cmd.Amount); err != nil {
return err
}
return uc.walletRepo.SaveInTx(ctx, tx, wallet)
})
}
九、Handler 层调用方式
新模块:Handler → Application UseCase(不经过 Service 层)
// 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 | 聚合根不依赖仓储 |
十一、本次迭代 DDD 落地计划
| 新模块 | 领域路径 | 用例路径 |
|---|---|---|
| 信用额度(需求17) | internal/domain/wallet/ |
internal/application/wallet/ |
| 审批流(需求18/20/21) | internal/domain/approval/ |
internal/application/approval/ |
| 站内消息(需求18/22) | internal/domain/notification/ |
internal/application/notification/ |
| 分销码(需求16) | internal/domain/distribution/ |
internal/application/distribution/ |
| 批量订购(需求19) | 复用 Order 领域 | internal/application/order/bulk_purchase.go |