Skip to content

设计原则:用 Go 守住业务规则,再决定需要多少抽象 ​

订单提交之后能不能继续加商品?新加一种配送方式,要不要改整个订单流程?HTTP 框架换掉时,计价规则需不需要跟着搬家?设计原则的价值在于帮你回答这些具体问题,不能只把五个字母或几层目录贴在项目上。

本文从一个可运行的小订单对象开始,逐步解释 SOLID、Option、装饰器、策略、工厂,以及整洁架构、六边形架构和 DDD。代码是教学模型:只有数量、运费和提交状态,没有数据库、支付、库存或分布式事务。实验固定 Go 1.27.2、Linux/amd64。

先把不能被破坏的规则写出来 ​

本例有四条约束:数量必须为正,累计数量不能超限,空订单不能提交,提交后不能继续添加。把这些限制集中在 Order 的行为入口,而不是让每个 handler 自己记一份。

go
func (o *Order) Add(n int) error {
    if o.submitted { return errors.New("order already submitted") }
    if n <= 0 || n > o.limit-o.items {
        return errors.New("item limit exceeded")
    }
    o.items += n
    return nil
}

先验证再修改,因此失败不会先把状态改坏。limit-o.items 在本例已保持的范围内比较余量,避免先做 items+n 再判断超限。完整测试还断言:数量已经为 2、再加 1 失败后,数量仍是 2。

正在绘制图示…

可切换适合窗口或原始尺寸
查看图源代码
stateDiagram-v2
    [*] --> Empty: NewOrder
    Empty --> Draft: Add 合法数量
    Draft --> Draft: Add 后仍未超限
    Draft --> Submitted: Submit 运费合法
    Empty --> Empty: Submit 被拒绝
    Draft --> Draft: 非法 Add 被拒绝
    Submitted --> Submitted: Add 或再次 Submit 被拒绝
放大图示

原始尺寸下可横向或纵向滚动;按 Escape 关闭。

正在绘制图示…

查看图源代码
stateDiagram-v2
    [*] --> Empty: NewOrder
    Empty --> Draft: Add 合法数量
    Draft --> Draft: Add 后仍未超限
    Draft --> Submitted: Submit 运费合法
    Empty --> Empty: Submit 被拒绝
    Draft --> Draft: 非法 Add 被拒绝
    Submitted --> Submitted: Add 或再次 Submit 被拒绝

这里的箭头是对象状态,不是数据库事务。submitted 与 items 都不导出,外部包不能绕过方法直接赋值,这是 Go 导出规则提供的基本封装;同一个包内部仍有访问能力,封装边界需要配合包设计。导出标识符

把必须一直成立的规则称为“不变量”。DDD 中的聚合边界就是围绕相关对象与一致性约束组织的,不是简单把所有相关数据库表放进一个 struct。Eric Evans:Aggregates

SOLID:用五个问题审查变化,而不是照搬类层次 ​

原则在这个 Go 程序里问什么可以怎么做过度使用会怎样
S:单一职责订单为什么变化,日志格式又为什么变化?订单规则与传输、存储适配分开把每个方法拆成一个包,调用反而难追
O:开闭原则新配送策略能否通过明确扩展点加入?注入 Shipping 函数或小接口为没有发生的变化预建大量扩展点
L:里氏替换换一个实现后,调用方依赖的行为契约还成立吗?同一接口实现遵守错误、取消和并发等约定只检查方法集相同,忽略行为改变
I:接口隔离消费方真的需要这么多方法吗?按实际能力定义小接口为每个 getter 都造一个无价值接口
D:依赖倒置业务规则是否必须 import 具体数据库或框架?业务声明所需能力,外部适配器实现用容器隐藏依赖,却没有减少耦合

上表是针对 Go 的解释,依据 Dave Cheney 的 SOLID Go Design;具体取舍是本文建议,不是 Go 语言强制所有项目遵守的架构。

Go 没有传统的 class 继承层次。嵌入会提升方法和字段,但不会自动获得“任意嵌入类型都能当作被嵌入类型”的子类型关系;不要把它当成 Java 式继承照搬。结构体嵌入、方法集

L 最容易被忽略。某个实现满足 io.Reader 的方法签名,却在读到部分数据时谎报 n,或者返回与契约不符的错误,编译器仍可能允许赋值,调用方却会出问题。因此“小接口”只是表面面积小,还需要明确行为承诺。Reader 契约

同样,D 不等于“使用依赖注入框架”。普通构造函数参数已经能把依赖方向表达出来。真正要看的是哪个包知道哪个细节,而不是项目里有没有 Container 这个类型。

Option 模式:让可选项可读,也把默认值和错误写清楚 ​

构造参数只有两个时,普通函数或 Config struct 往往更清楚。可选参数逐渐增加、需要保留默认行为时,功能选项可以让调用处只写关心的变化。Functional options 原始文章

本例默认数量上限 10、固定运费 500;调用方只覆盖需要的项:

go
o, err := NewOrder(WithLimit(2), WithShipping(strategy))

Option 的类型是 func(*options) error,不是固定只能无返回值的语法。WithLimit(0) 会返回:

text
limit must be positive

构造函数先在内部 options 上应用全部选项,成功后再生成 Order,避免交付一个已暴露但只初始化一半的对象。附录还测试 nil option、nil strategy,以及重复设置的规则:WithLimit(2), WithLimit(3) 最终为 3。

API 方案调用处特点适合情形
普通参数简短,参数位置固定少量必填依赖和值
Config struct字段集中,适合序列化和审查一组稳定配置;需区分零值与未设置
Functional Option可选项按名字组合,默认调用简洁多个独立可选行为;需规定错误、顺序和冲突

Option 不是自动降低复杂度。若两个选项互斥、后一个依赖前一个,构造函数仍要做整体校验;若选项立即打开网络连接,失败回滚与资源清理也更难。本文建议选项主要描述配置,资源创建集中到可控的构造过程。

零值同样需要说明:本例 NewOrder() 能创建可用对象,但直接写 Order{} 不满足数量上限与策略的要求。不能既要求必须走构造函数,又让文档暗示所有零值都可直接使用。

策略和工厂:一个负责做事,一个负责选谁做 ​

go
type Shipping func(items int) int

Shipping 表示运费算法。实现只需要一个操作时,函数已经足够;若算法有多个相关操作和状态,再考虑小接口。

Strategy("pickup") 返回自提算法,运费为 0;Strategy("delivery") 返回配送算法,教学规则为 500 加每件 10。这个按名称选实现的函数是简单工厂,未知名称返回错误。工厂负责选择与构造,不应把订单状态机也搬进去。

完整示例创建数量上限 2 的订单,执行加入、超限加入、提交、再次加入,实际输出:

text
<nil>
item limit exceeded
520 <nil>
order already submitted

520 来自本例策略:500 + 2×10。运费使用小整数只是为了展示扩展点,代码没有实现币种、完整溢出防护或实际计费规则;不能直接当作生产金融计算库。

使用策略后,新配送计算可以在独立函数里测试;代价是构造和阅读时要跟踪注入的实现。只有一个永远不变的计算规则时,普通函数调用更省事。若策略会访问外部系统或失败,也应让签名返回 error、接受必要 context,而不是把错误藏在一个随意的负数里。

装饰器与中间件:包装前后顺序是行为的一部分 ​

Go 的 http.Handler 只有一个 ServeHTTP 方法,HandlerFunc 能把函数适配成 Handler。中间件接收一个 Handler,返回另一个 Handler,在调用 next 前后加行为。Handler、HandlerFunc

附录包装 A(B(handler)),A、B 在进入和退出时记录名字,实际测试输出:

text
[enter A enter B handler exit B exit A]

这条顺序决定鉴权、超时、日志和恢复逻辑覆盖什么。外层能观察整个内层,内层提前返回时可能不调用后面的 handler;顺序不是排版问题。附录使用 defer 记录退出,只验证单请求顺序,未实现生产级鉴权或 panic 恢复。

装饰器的收益是共享横切行为而不改每个业务 handler,代价是控制流增加一层,错误或 header 处理也可能互相影响。包装 ResponseWriter 时还要留意可选接口,不能只实现 Write 就宣称与所有流式协议行为完全兼容。标准库的 ResponseController 明确规定了相关能力和包装器的 Unwrap 约定。

整洁架构和六边形架构:关心依赖方向,不必复制同一目录树 ​

整洁架构强调源代码依赖指向业务政策所在的内层;外层技术细节依赖内层定义的规则和抽象。六边形架构从应用内外的边界看问题,通过端口定义交互,通过适配器连接 UI、数据库、测试等外部对象。Clean Architecture 原文、Hexagonal Architecture 原文

下面是可选的演进方向,箭头表示源码依赖;不是说附录已经实现了这些包:

正在绘制图示…

可切换适合窗口或原始尺寸
查看图源代码
flowchart LR
    H["HTTP 适配器"] --> U["下单用例<br/>定义需要的仓储端口"]
    CLI["CLI / 测试入口"] --> U
    U --> D["Order 领域规则"]
    DB["SQL 仓储适配器"] --> U
    DB --> D
    M["main 组装实现"] --> H
    M --> DB
放大图示

原始尺寸下可横向或纵向滚动;按 Escape 关闭。

正在绘制图示…

查看图源代码
flowchart LR
    H["HTTP 适配器"] --> U["下单用例<br/>定义需要的仓储端口"]
    CLI["CLI / 测试入口"] --> U
    U --> D["Order 领域规则"]
    DB["SQL 仓储适配器"] --> U
    DB --> D
    M["main 组装实现"] --> H
    M --> DB

运行时用例可以调用 SQL 实现,但源码不必 import SQL 包:实现作为接口值传入。编译期依赖方向与运行时调用方向因此可以不同。这个区分是依赖倒置在 Go 里的实际用处。

它也有成本:接口、适配、数据转换和包导航都会增加。简单 CRUD 服务没有复杂业务不变量时,handler 调用清晰的存储函数可能已经足够。只有当业务规则需要独立演进、多个入口复用,或者测试需要替换外部依赖时,隔离边界的收益才更明显。

“六边形”不要求恰好六个端口,“整洁架构”也不要求四个固定文件夹。先让代码体现实际边界,再决定名字,不要靠目录模仿图形。

DDD:先对齐业务语言,再决定对象叫什么 ​

DDD(领域驱动设计)围绕复杂业务的模型与共同语言展开。其战略概念帮助划清模型适用范围,战术概念帮助在这个范围内表达身份、值和一致性。把实体叫 Entity、把 DAO 改名 Repository,并不会自动得到领域模型。Evans:DDD Reference

概念在订单场景中的含义不应混为一谈
通用语言 Ubiquitous Language产品、业务专家和代码对“提交”“取消”的含义一致仅统一数据库列名
限界上下文 Bounded Context一套模型和词汇成立的范围一定等于一个微服务或一张表
实体 Entity订单由稳定身份区分,属性可以变化每个带字段的 struct 都是实体
值对象 Value Object金额由币种与数值定义,按值比较只剩数据、没有校验行为的随意 DTO
聚合 Aggregate一个入口保护一组相关对象的不变量把所有业务装进一个巨大事务
领域服务 Domain Service不自然属于单个实体的领域操作所有业务都堆进一个万能 Service
仓储 Repository以领域对象为视角获取和保存聚合必须暴露每张表的 CRUD 方法
领域事件 Domain Event业务上发生过的事实,如订单已提交一发事件就保证跨系统可靠一致

这些概念见原始参考中的对应章节:Entities、Value Objects、Aggregates、Services、Repositories、Domain Events、Bounded Context 和 Ubiquitous Language。表中订单解释是本文例子,完整定义以 DDD Reference 为准。

例如,商品目录中的“商品价格”可能是当前展示价;订单中的“成交单价”可能需要保留购买时的快照。它们都叫价格,却不能用一个可变对象无条件共享。限界上下文让团队明确“同一个词在这里指什么”,上下文之间再显式转换。

附录 Order 只演示聚合入口保护数量与提交状态,没有订单 ID、持久化或并发版本控制,因此不能说它已经实现完整 DDD。真实并发提交还需要结合数据库事务、版本检查或其他一致性机制;把字段设为私有不会自动阻止两个请求同时修改同一个对象。

领域事件也需要下一步设计:状态提交成功以后如何可靠发送、发送失败如何补偿、消费者如何去重。采用一个事件 struct 并没有解决这些问题。先用模型让不变量清楚,再按实际跨进程边界加入可靠性方案。

用变化检验抽象是否值得保留 ​

本文建议在审查时问:加一种配送方式是否只改预期扩展点;替换 HTTP 是否仍能测试订单规则;非法状态是否仍可绕开;删掉某层后是否反而更清楚。能够减少具体修改范围的边界值得保留,只有名词却不保护行为的抽象可以简化。

来源与验证边界 ​

2026-10-13 实际执行数量/状态不变量、失败不改变状态、选项默认值/顺序/错误、工厂选策略、装饰器嵌套顺序,test、race、vet 通过。没有数据库、事件总线、分布式事务或生产架构迁移实验。设计选择的优缺点是基于这些边界的建议,不是测量出的普遍结论。

完整实验代码 ​

把下列文件放在同一目录,使用 Go 1.27.2,运行 go test -count=1 -v ./...、go test -count=1 -race ./...、go vet ./...。Example 测试核对完整业务输出;其余测试检查默认值、失败状态与中间件顺序。

go.mod ​

go.mod
module example.com/v2/design

go 1.27.2

order.go ​

代码块 · 86 行 · 点击收起 / 展开
go
package design

import (
	"errors"
	"fmt"
)

type Shipping func(items int) int
type options struct {
	limit    int
	shipping Shipping
}
type Option func(*options) error

func WithLimit(n int) Option {
	return func(o *options) error {
		if n <= 0 {
			return errors.New("limit must be positive")
		}
		o.limit = n
		return nil
	}
}
func WithShipping(s Shipping) Option {
	return func(o *options) error {
		if s == nil {
			return errors.New("shipping strategy is required")
		}
		o.shipping = s
		return nil
	}
}

type Order struct {
	items     int
	limit     int
	shipping  Shipping
	submitted bool
}

func NewOrder(opts ...Option) (*Order, error) {
	cfg := options{limit: 10, shipping: func(int) int { return 500 }}
	for _, opt := range opts {
		if opt == nil {
			return nil, errors.New("nil option")
		}
		if err := opt(&cfg); err != nil {
			return nil, err
		}
	}
	return &Order{limit: cfg.limit, shipping: cfg.shipping}, nil
}
func (o *Order) Add(n int) error {
	if o.submitted {
		return errors.New("order already submitted")
	}
	if n <= 0 || n > o.limit-o.items {
		return errors.New("item limit exceeded")
	}
	o.items += n
	return nil
}
func (o *Order) Submit() (int, error) {
	if o.submitted {
		return 0, errors.New("order already submitted")
	}
	if o.items == 0 {
		return 0, errors.New("empty order")
	}
	fee := o.shipping(o.items)
	if fee < 0 {
		return 0, errors.New("negative shipping fee")
	}
	o.submitted = true
	return fee, nil
}
func Strategy(name string) (Shipping, error) {
	switch name {
	case "pickup":
		return func(int) int { return 0 }, nil
	case "delivery":
		return func(n int) int { return 500 + n*10 }, nil
	default:
		return nil, fmt.Errorf("unknown shipping strategy %q", name)
	}
}

order_test.go ​

代码块 · 90 行 · 点击收起 / 展开
go
package design

import (
	"fmt"
	"net/http"
	"net/http/httptest"
	"testing"
)

func Example() {
	strategy, err := Strategy("delivery")
	if err != nil {
		panic(err)
	}
	o, err := NewOrder(WithLimit(2), WithShipping(strategy))
	if err != nil {
		panic(err)
	}
	fmt.Println(o.Add(2))
	fmt.Println(o.Add(1))
	fmt.Println(o.Submit())
	fmt.Println(o.Add(1))
	_, err = NewOrder(WithLimit(0))
	fmt.Println(err)
	// Output:
	// <nil>
	// item limit exceeded
	// 520 <nil>
	// order already submitted
	// limit must be positive
}
func TestRejectedChangePreservesState(t *testing.T) {
	o, err := NewOrder(WithLimit(2))
	if err != nil {
		t.Fatal(err)
	}
	if err = o.Add(2); err != nil {
		t.Fatal(err)
	}
	if err = o.Add(1); err == nil {
		t.Fatal("wanted error")
	}
	if o.items != 2 {
		t.Fatal(o.items)
	}
}
func trace(name string, next http.Handler, events *[]string) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		*events = append(*events, "enter "+name)
		defer func() { *events = append(*events, "exit "+name) }()
		next.ServeHTTP(w, r)
	})
}
func TestDecoratorOrder(t *testing.T) {
	var events []string
	core := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { events = append(events, "handler"); w.WriteHeader(204) })
	wrapped := trace("A", trace("B", core, &events), &events)
	rec := httptest.NewRecorder()
	wrapped.ServeHTTP(rec, httptest.NewRequest("GET", "/", nil))
	want := "[enter A enter B handler exit B exit A]"
	if fmt.Sprint(events) != want || rec.Code != 204 {
		t.Fatal(events, rec.Code)
	}
	t.Log(events)
}

func TestOptions(t *testing.T) {
	o, err := NewOrder(WithLimit(2), WithLimit(3))
	if err != nil || o.limit != 3 {
		t.Fatalf("order=%v error=%v", o, err)
	}
	if _, err := NewOrder(nil); err == nil {
		t.Fatal("nil option accepted")
	}
	if _, err := NewOrder(WithShipping(nil)); err == nil {
		t.Fatal("nil strategy accepted")
	}
	o, err = NewOrder()
	if err != nil {
		t.Fatal(err)
	}
	if err := o.Add(1); err != nil {
		t.Fatal(err)
	}
	fee, err := o.Submit()
	if err != nil || fee != 500 {
		t.Fatal(fee, err)
	}
	t.Log("defaults: limit=10 fee=500; repeated limit uses final value=3")
}

保留各书来源与许可 · 示例运行条件以正文为准