主题
设计原则:用 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 被拒绝
这里的箭头是对象状态,不是数据库事务。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) intShipping 表示运费算法。实现只需要一个操作时,函数已经足够;若算法有多个相关操作和状态,再考虑小接口。
Strategy("pickup") 返回自提算法,运费为 0;Strategy("delivery") 返回配送算法,教学规则为 500 加每件 10。这个按名称选实现的函数是简单工厂,未知名称返回错误。工厂负责选择与构造,不应把订单状态机也搬进去。
完整示例创建数量上限 2 的订单,执行加入、超限加入、提交、再次加入,实际输出:
text
<nil>
item limit exceeded
520 <nil>
order already submitted520 来自本例策略: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
运行时用例可以调用 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 是否仍能测试订单规则;非法状态是否仍可绕开;删掉某层后是否反而更清楚。能够减少具体修改范围的边界值得保留,只有名词却不保护行为的抽象可以简化。
来源与验证边界
- SOLID Go Design、Functional options:Go 设计解释与模式原始文章。
- Clean Architecture、Hexagonal Architecture、DDD Reference:各方法作者的原始说明。
- Go 1.27.2 的 语言规范、io、net/http:对应语言与 API 契约。
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.2order.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")
}