26.4 洋葱架构

一句话

洋葱架构把领域模型放在正中心,向外依次是领域服务、应用服务、基础设施/UI/测试;所有依赖指向圆心,仓储接口定义在领域侧——它和整洁架构同构,差别是把"领域服务"显式成一层,让跨实体的规则有地方放。

四层,从圆心往外

        ┌───────────────────────────────────────────────┐
        │  ④ Infrastructure / UI / Tests(最外圈)        │
        │   MySQL、Redis、HTTP、CLI、消息队列、测试代码    │
        │  ┌─────────────────────────────────────────┐  │
        │  │  ③ Application Services(应用服务)      │  │
        │  │   一次用例的编排:取、调、存、发通知      │  │
        │  │  ┌───────────────────────────────────┐  │  │
        │  │  │  ② Domain Services(领域服务)     │  │  │
        │  │  │   跨实体规则 + 仓储接口            │  │  │
        │  │  │  ┌─────────────────────────────┐  │  │  │
        │  │  │  │  ① Domain Model(领域模型)  │  │  │  │
        │  │  │  │   实体、值对象、不变量       │  │  │  │
        │  │  │  └─────────────────────────────┘  │  │  │
        │  │  └───────────────────────────────────┘  │  │
        │  └─────────────────────────────────────────┘  │
        └───────────────────────────────────────────────┘
              依赖箭头全部指向圆心;外圈可整体替换
层 放什么 允许依赖 判断"该不该放这里"
① Domain Model 实体、值对象、不变量、状态迁移 只有标准库 把数据库和框架删掉,这段代码还成立吗?
② Domain Services 跨实体/跨聚合的规则、仓储接口 ① 这条规则是否同时需要两个以上实体?
③ Application Services 用例编排、事务边界、鉴权、通知 ①② 这是"一次用户动作"的步骤列表吗?
④ Infrastructure / UI / Tests 具体技术实现、入口、测试 所有内层 换掉它,业务是否不受影响?

🔑 圆心的判据不是"重要",而是无依赖:领域模型的 import 应该只有标准库(context 这类语言级依赖),加上 time 之类的基础类型;一旦它开始认识 gorm、encoding/json 标签、任何框架类型,圆心就漏了。

为什么必须有"领域服务"这一层

这是洋葱区别于"随便画几个圈"的关键。看一个转账:

  • Account.Debit(amount) / Account.Credit(amount) 属于单个实体的规则 → 放 ① 领域模型
  • "不能给自己转账"、"扣款额 = 本金 + 手续费"、"转出方冻结则整体失败" → 同时涉及两个账户,放不进任何一个实体

这类规则只有三个去处:

放哪 结果 评价
放实体里(from.TransferTo(to, amount)) 实体之间直接引用,聚合边界被打穿 ❌ 一个聚合不该操作另一个聚合
放应用服务里 应用层开始写业务规则,领域模型退化成数据袋 ❌ 贫血模型,规则分散到每个入口
放领域服务 规则集中、无状态、可被任何入口复用 ✅ 洋葱的选择

💡 领域服务是无状态的:它拿实体当参数、算完返回,不持有数据库连接、不发 HTTP。名字通常就是业务动词(Transfer、Pricing、Allocation),不是 XxxManager、XxxHelper。

接口放在领域侧:洋葱最硬的一条

洋葱的原始描述里,仓储接口在 Domain Services 层,实现在 Infrastructure 层:

// ② Domain Services:领域需要"取账户/存账户",于是定义这份需求
// 目录:domain/account_repo.go
package domain

import "context"

type AccountRepo interface {
	Find(ctx context.Context, id string) (*Account, error)
	Save(ctx context.Context, a *Account) error
}
// ④ Infrastructure:实现方反过来 import 领域,并在边界处完成数据形状转换
// 目录:infra/mysql/account_repo.go
package mysql

import (
	"context"
	"database/sql"

	"shop/domain"
)

type accountRepo struct{ db *sql.DB }

func (r *accountRepo) Find(ctx context.Context, id string) (*domain.Account, error) {
	var a domain.Account
	err := r.db.QueryRowContext(ctx,
		"SELECT id, balance, frozen FROM accounts WHERE id = ?", id,
	).Scan(&a.ID, &a.Balance, &a.Frozen)
	if err != nil {
		return nil, err
	}
	return &a, nil // 返回领域对象,不返回 *sql.Rows
}

func (r *accountRepo) Save(ctx context.Context, a *domain.Account) error {
	_, err := r.db.ExecContext(ctx,
		"UPDATE accounts SET balance = ?, frozen = ? WHERE id = ?",
		a.Balance, a.Frozen, a.ID)
	return err
}

这比"接口放应用层"更严格:连"应用层需要什么"都不作数,领域需要什么才是接口的形状。

⚠️ 与 26.3 整洁架构 的唯一实质差别就在这里:Clean 把端口放在 Use Case 层(因为它的中心是"用例"),Onion 把端口放在领域侧(因为它的中心是"领域模型")。两边都能跑通;同一条系统里两套都行,但必须选一套并写进规范,否则新人会把 AccountRepo 放到 adapter 包里。

洋葱 vs 整洁:三个真实差异

关注点 洋葱(Onion) 整洁(Clean)
圆心是什么 Domain Model:聚合、不变量 Entities:企业级业务规则
第二圈是什么 Domain Services:跨实体规则 + 接口 Use Cases:应用业务规则
用例层厚度 薄:应用服务主要负责编排、事务、通知 厚:用例本身承载应用级规则
接口默认位置 领域/领域服务层 用例层
最外圈 Infrastructure / UI / Tests 三者在同一圈 Frameworks & Drivers
最适合 DDD 落地、领域模型复杂(29 章) 用例多、入口多(26.5 六边形架构)

🔑 面试标准答案不是"圈数不同",而是:洋葱让领域服务中心化,整洁让用例中心化。 如果你的系统里"跨实体规则"很多(账务、库存分配、计价),洋葱的表达更自然;如果"一次动作的步骤"很多(下单要查库存、锁券、建单、发事件),整洁的用例层更自然。

代码:一个能跑的洋葱

四层各就各位,go run main.go 直接执行:

package main

import (
	"context"
	"errors"
	"fmt"
)

// ========== ① Domain Model:最内层,只依赖标准库 ==========
type Account struct {
	ID      string
	Balance int64 // 分
	Frozen  bool
}

var (
	ErrInsufficient = errors.New("余额不足")
	ErrFrozen       = errors.New("账户已冻结")
	ErrInvalidAmt   = errors.New("金额必须大于 0")
	ErrSelfTransfer = errors.New("不能给自己转账")
)

// Debit / Credit 是领域不变量:任何入口都绕不过去
func (a *Account) Debit(amount int64) error {
	if amount <= 0 {
		return ErrInvalidAmt
	}
	if a.Frozen {
		return ErrFrozen
	}
	if a.Balance < amount {
		return ErrInsufficient
	}
	a.Balance -= amount
	return nil
}

func (a *Account) Credit(amount int64) {
	a.Balance += amount
}

// ========== ② Domain Services:跨实体规则 + 仓储接口在这里 ==========
type AccountRepo interface {
	Find(ctx context.Context, id string) (*Account, error)
	Save(ctx context.Context, a *Account) error
}

type FeePolicy struct{ BPS int64 } // 万分比

func (p FeePolicy) Fee(amount int64) int64 { return amount * p.BPS / 10000 }

// Transfer 跨两个聚合,放不进单个 Account,落在领域服务
// 规则:转出方扣「本金 + 手续费」,收款方只收到本金
// (手续费在本例中不落账;真实系统要记入平台收入账户)
func (p FeePolicy) Transfer(from, to *Account, amount int64) (fee int64, err error) {
	if from.ID == to.ID {
		return 0, ErrSelfTransfer
	}
	fee = p.Fee(amount)
	if err := from.Debit(amount + fee); err != nil {
		return 0, err // 校验失败时双方都没被修改
	}
	to.Credit(amount)
	return fee, nil
}

// ========== ③ Application Services:用例编排、事务、通知 ==========
type Notifier interface {
	Notify(ctx context.Context, userID, msg string) error
}

type TransferCommand struct {
	FromID, ToID string
	Amount       int64
}

type TransferResult struct {
	FromBalance, ToBalance, Fee int64
}

type TransferUseCase struct {
	repo     AccountRepo
	fee      FeePolicy
	notifier Notifier
}

func NewTransferUseCase(repo AccountRepo, fee FeePolicy, n Notifier) *TransferUseCase {
	return &TransferUseCase{repo: repo, fee: fee, notifier: n}
}

func (uc *TransferUseCase) Execute(ctx context.Context, cmd TransferCommand) (TransferResult, error) {
	from, err := uc.repo.Find(ctx, cmd.FromID)
	if err != nil {
		return TransferResult{}, err
	}
	to, err := uc.repo.Find(ctx, cmd.ToID)
	if err != nil {
		return TransferResult{}, err
	}

	fee, err := uc.fee.Transfer(from, to, cmd.Amount) // 业务规则全部在领域层
	if err != nil {
		return TransferResult{}, err
	}

	// 真实项目:两次 Save 必须落在同一个事务里(UnitOfWork / 单库事务),
	// 否则会出现「扣了款没入账」。这里用内存实现,Save 不会失败。
	if err := uc.repo.Save(ctx, from); err != nil {
		return TransferResult{}, err
	}
	if err := uc.repo.Save(ctx, to); err != nil {
		return TransferResult{}, err
	}

	// 通知是附属流程:失败不回滚转账
	if err := uc.notifier.Notify(ctx, from.ID, fmt.Sprintf("转出 %d 分,手续费 %d 分", cmd.Amount, fee)); err != nil {
		return TransferResult{from.Balance, to.Balance, fee}, fmt.Errorf("转账成功但通知失败: %w", err)
	}
	return TransferResult{from.Balance, to.Balance, fee}, nil
}

// ========== ④ Infrastructure:最外层,可整体替换 ==========
type memoryRepo struct{ data map[string]*Account }

func (r *memoryRepo) Find(_ context.Context, id string) (*Account, error) {
	a, ok := r.data[id]
	if !ok {
		return nil, fmt.Errorf("账户 %s 不存在", id)
	}
	return a, nil
}

func (r *memoryRepo) Save(_ context.Context, a *Account) error {
	r.data[a.ID] = a
	return nil
}

type logNotifier struct{}

func (logNotifier) Notify(_ context.Context, userID, msg string) error {
	fmt.Printf("  [通知] %s: %s\n", userID, msg)
	return nil
}

func main() {
	repo := &memoryRepo{data: map[string]*Account{
		"A": {ID: "A", Balance: 90000},
		"B": {ID: "B", Balance: 5000},
		"C": {ID: "C", Balance: 100, Frozen: true},
		"D": {ID: "D", Balance: 50},
	}}
	uc := NewTransferUseCase(repo, FeePolicy{BPS: 100}, logNotifier{})

	for _, cmd := range []TransferCommand{
		{FromID: "A", ToID: "B", Amount: 10000},
		{FromID: "C", ToID: "B", Amount: 100},
		{FromID: "D", ToID: "B", Amount: 100},
		{FromID: "A", ToID: "A", Amount: 100},
	} {
		got, err := uc.Execute(context.Background(), cmd)
		if err != nil {
			fmt.Printf("%s → %s 失败:%v\n", cmd.FromID, cmd.ToID, err)
			continue
		}
		fmt.Printf("%s → %s 成功:%s 余额 %d,%s 余额 %d,手续费 %d\n",
			cmd.FromID, cmd.ToID, cmd.FromID, got.FromBalance, cmd.ToID, got.ToBalance, got.Fee)
	}
}

输出:

  [通知] A: 转出 10000 分,手续费 100 分
A → B 成功:A 余额 79900,B 余额 15000,手续费 100
C → B 失败:账户已冻结
D → B 失败:余额不足
A → A 失败:不能给自己转账

四条路径覆盖了三种失败与一种成功,全部由领域层判定:应用层一条 if 都没写。这就是洋葱的验收口径——把 TransferUseCase 里的所有 if 数一遍,它们应该全在"取不到数据""保存失败"这类技术问题上,而不是业务判断上。

事务边界在应用服务层,不在领域层

一个用例 = 一个事务。领域层拿到 tx 是灾难(它会让领域依赖事务 API),正确做法是由应用层开事务、把仓储变成"事务内仓储":

// 应用服务依赖一个工作单元抽象(实现在基础设施层)
// 目录:app/transfer.go
package app

import "context"

type UnitOfWork interface {
	Do(ctx context.Context, fn func(ctx context.Context) error) error
}

func (uc *TransferUseCase) Execute(ctx context.Context, cmd TransferCommand) (TransferResult, error) {
	var out TransferResult
	err := uc.uow.Do(ctx, func(txCtx context.Context) error {
		// 事务内:取账户、调领域服务、存账户,全程使用 txCtx
		// 任一步失败,uow 回滚,不会出现"扣了款没入账"
		return nil
	})
	return out, err
}

⚠️ 常见翻车:repo.Save(from) 成功、repo.Save(to) 失败,钱扣了没到账。两个 Save 之间没有任何一致性保证——事务边界是架构决策,不是 DAO 的细节。

代价:洋葱不是免费的

代价 具体量级 什么时候值得付
类/文件数 一个用例 ≈ 实体 + 领域服务 + 应用服务 + 仓储接口 + 仓储实现 + 入口,5–8 个文件 领域规则多、系统寿命 ≥ 2 年
映射代码 领域对象 ↔ 持久化对象一次转换,10–30 行/实体 需要独立演进 DB 与领域模型
学习曲线 新人要理解"为什么转账规则不在 Service 里" 团队有交接、多人协作
领域服务易腐化 XxxManager 变成什么都装的垃圾桶 需要 code review 守规则
小项目收益为负 CRUD 下领域层是空的 不要用

决策表:什么时候画这个洋葱

你的情况 选择
有跨实体规则(转账、库存分配、计价、优惠叠加) 洋葱,领域服务有真实内容
一次用例的步骤多、入口多(HTTP + MQ + 定时任务) 洋葱可行,但 整洁架构 的用例层更顺手
纯 CRUD、表单直映射 不要,三层足够
领域模型只是数据库表的镜像(贫血) 先补领域行为,再谈分层;否则洋葱只剩文件夹
团队没做过 DDD、工期紧 先用整洁架构的四层,等跨实体规则多起来再细分领域服务

反模式

反模式 后果
Domain Services 变垃圾桶(OrderManager 什么都装) 领域规则与应用编排再次混合,分层失效
应用服务写成透传(每个方法一行 return domain.X()) 说明这一层没有编排职责,该合并(26.1 的 sinkhole)
贫血模型:实体只有 getter/setter,规则全在服务 不变量可被绕过,任何入口都能造出非法状态
领域层出现 gorm / json tag,或 sql.Tx 参数 圆心依赖外层,洋葱破功
领域服务里注入 repo 后自己开事务 领域层依赖事务 API,无法脱离 DB 单测
只测应用服务,领域规则靠集成测试覆盖 规则用例从毫秒级退化到秒级,且难覆盖边界
把 Infrastructure 当成"所有工具的存放处"(含业务工具) 业务逻辑沉到最外层,绕过了所有内层
同一个项目里 AccountRepo 一处放领域、一处放 adapter 规范不清,依赖方向随机

自检

Q1 同事把"转账时扣手续费"写进了 TransferUseCase,理由是"手续费是业务策略,放应用层也算业务"。按洋葱的分层,它应该放哪?判断依据是什么?

▸ 答案:应放领域服务(FeePolicy.Transfer)。依据不是"是不是业务",而是这条规则需要几个实体:只涉及一个账户的规则放实体;同时涉及转出方与收款方的规则放领域服务;只涉及"先查谁、后存谁、失败怎么补偿"的放应用服务。手续费同时改变两个账户的余额,属于跨实体不变量,放应用层会导致第二个入口(批量代付、MQ 重试)重写一遍费率逻辑。

Q2 有人说:"洋葱架构就是四层同心圆,整洁架构也是四层同心圆,所以它们完全一样。"这句话哪半对、哪半错?

▸ 答案:"同构"对,"完全一样"错。两者都要求依赖指向圆心、核心不依赖技术,这是同一个设计。差别在层的内容与所有权:洋葱的第二圈是 Domain Services(跨实体规则),接口归领域;整洁的第二圈是 Use Cases(应用业务规则),接口归用例。这个差别会直接体现在代码评审上——同一个 AccountRepo 在洋葱里放领域包,在整洁里放用例包,两套规范不能混用。

Q3 一个团队把洋葱画成了 6 层:Domain、Domain Service、App Service、App Helper、Adapter、Infra。哪里最可能出问题?

▸ 答案:App Helper 这一层。它没有独立的变化原因,大概率是 sinkhole(透传)或垃圾桶(什么都装)——两种都会让"应用层只做编排"这条约束失效。层数不是能力证明,每多一层必须能回答"它隔离了哪种变化";答不上来就该合并。同理,如果 Adapter 和 Infra 之间只是 return next.X(),也应合并。

Q4 你的领域层需要"当前时间"来判断转账是否在营业时间内。直接 time.Now() 会让测试只能在营业时间跑。怎么处理才不破坏洋葱?

▸ 答案:时间在这里是业务输入,应作为领域服务的显式参数(Transfer(from, to, amount, now)),或注入一个领域侧定义的 Clock 端口(实现放外层)。两种都可以,优先显式参数——它让"营业时间判断"这一规则的输入一目了然,测试不需要构造任何替身。不要在领域层直接 time.Now(),也不要为了统一而在所有地方注入 Clock(纯展示用途的时间不需要抽象)。