先看一版能跑的订单实现
arch-lab 是本书贯穿始终的订单交易系统:创建订单、预留库存、支付、履约、取消和退款。第一版不追求架构,只追求“今天能演示”。整个服务不到 200 行,一个 map 存订单,一个 sync.Mutex 防并发,HTTP handler 里直接改状态。
下面是最核心的两个 handler,省略了路由注册和启动代码:
// file: internal/adapters/httpapi/naive_handler.go
// tier: L1
// verify: go vet ./internal/adapters/httpapi/
package httpapi
import (
"encoding/json"
"fmt"
"net/http"
"sync"
"time"
)
// naiveOrder 把状态存成裸字符串,金额按 SKU 个数推算。
type naiveOrder struct {
ID string `json:"id"`
CustomerID string `json:"customer_id"`
Status string `json:"status"`
SKUs []string `json:"skus"`
TotalCents int64 `json:"total_cents"`
CreatedAt time.Time `json:"created_at"`
}
type NaiveStore struct {
mu sync.Mutex
orders map[string]*naiveOrder
seq int
}
func NewNaiveStore() *NaiveStore {
return &NaiveStore{orders: make(map[string]*naiveOrder)}
}
func (s *NaiveStore) Create(w http.ResponseWriter, r *http.Request) {
var req struct {
CustomerID string `json:"customer_id"`
SKUs []string `json:"skus"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
s.mu.Lock()
defer s.mu.Unlock()
s.seq++
o := &naiveOrder{
ID: fmt.Sprintf("ord-%d", s.seq),
CustomerID: req.CustomerID,
Status: "CREATED",
SKUs: req.SKUs,
TotalCents: int64(len(req.SKUs)) * 100,
CreatedAt: time.Now(),
}
s.orders[o.ID] = o
w.WriteHeader(http.StatusCreated)
_ = json.NewEncoder(w).Encode(o)
}
func (s *NaiveStore) Pay(w http.ResponseWriter, r *http.Request) {
s.mu.Lock()
defer s.mu.Unlock()
o, ok := s.orders[r.PathValue("id")]
if !ok {
http.NotFound(w, r)
return
}
o.Status = "PAID" // 不校验当前状态,重复支付也会成功
_ = json.NewEncoder(w).Encode(o)
}
这段代码有三个典型特征:状态是字符串、规则写在 handler 里、存储与协议共用同一个结构体。它没有“错”,只是把六类问题延后到了第一次真实流量。
六处代价
| 代价 | 朴素实现里的表现 | 第一次真实流量时的后果 | 架构回应 |
|---|---|---|---|
| 状态 | Status 是裸字符串,没有合法边 | 已取消订单可再次支付 | 状态机 + 显式错误码 |
| 并发 | 锁保护 map,不保护业务不变量 | 两人同时取消与支付,后写覆盖先写 | 版本号 + 条件更新 |
| 故障 | 没有超时、重试、降级边界 | 一次慢调用占满连接与内存 | 超时预算 + 端口隔离 |
| 变更 | HTTP 请求体就是领域模型 | 加一个字段就改三处 | 领域模型与 DTO 分离 |
| 观测 | 日志只有状态码 | 无法回答“这单卡在哪一步” | 结构化事件 + 订单时间线 |
| 安全 | 按 id 直接查,不校验归属 | 改一个 URL 就能看别人的订单 | 授权边界 + 不可枚举 ID |
状态:字符串没有边
o.Status = "PAID" 这一行可以在任何时刻执行。已取消的订单能支付,已退款的订单能再次履约,因为代码里根本没有“哪些迁移合法”的概念。数据库里也存不下这个知识:它只看到一个字符串,不知道 CREATED 的下一个合法值是 INVENTORY_RESERVED 或 CANCELLED。
并发:锁保护的是 map,不是不变量
sync.Mutex 只保证 orders 这个 map 不被并发写坏。它不保证“库存预留成功”和“订单状态变为已预留”这两件事一起发生,也不保证支付回调和取消请求不会交错。两个请求各自读到 CREATED,各自通过自己那段 if 判断,最后谁后写谁生效,中间那次状态迁移凭空消失。
故障:没有超时,就没有故障边界
handler 里直接调用库存和支付时,一个慢下游会让 goroutine 一直挂着。HTTP 服务器不会替你设业务超时;连接数、内存和上游重试会一起放大问题。更糟的是,调用方无法区分“支付没发生”和“支付发生了但响应丢了”,于是重试变成重复扣款。
变更、观测与安全
变更的代价在第二周就会显现:前端要加 currency,你要同时改 JSON 结构、内存结构和响应;运营要加“已履约”筛选,只能在 handler 里再补一层 if。观测的代价更隐蔽:出问题时你只有访问日志,没有“订单从创建到支付经历了哪些状态、每步耗时多少”的时间线。安全的代价最直接:GET /orders/{id} 只按 ID 查,没有校验 CustomerID 是否属于当前调用者。
这六处代价不是六个独立缺陷,它们共同指向一件事:代码里没有一处地方能回答“这个系统允许发生什么”。
第一版领域模型
第一版重构不拆服务、不引入框架,只做一件事:把订单的状态和迁移规则收进 internal/order,让 HTTP 层只能通过方法改状态。
// file: internal/order/order.go
// tier: L1
// verify: go test ./internal/order/
package order
import (
"fmt"
"time"
)
// Status 用字符串而不是 int:日志、数据库、API 三处看到同一个值。
type Status string
const (
StatusCreated Status = "CREATED"
StatusInventoryReserved Status = "INVENTORY_RESERVED"
StatusPaid Status = "PAID"
StatusFulfilled Status = "FULFILLED"
StatusCancelled Status = "CANCELLED"
StatusRefunded Status = "REFUNDED"
)
type Code string
const (
CodeInvalidState Code = "INVALID_STATE"
CodeOutOfStock Code = "OUT_OF_STOCK"
CodePaymentFailed Code = "PAYMENT_FAILED"
CodeVersionConflict Code = "VERSION_CONFLICT"
CodeIdempotencyConflict Code = "IDEMPOTENCY_CONFLICT"
)
// Error 带上被拒绝的迁移边,日志不需要再拼业务上下文。
type Error struct {
Code Code
Op string
From Status
To Status
}
func (e *Error) Error() string {
if e.From == "" {
return fmt.Sprintf("%s: %s", e.Code, e.Op)
}
return fmt.Sprintf("%s: %s (%s -> %s)", e.Code, e.Op, e.From, e.To)
}
type OrderItem struct {
SKU string
Quantity int
UnitPriceCents int64
}
type Order struct {
ID string
CustomerID string
Status Status
Items []OrderItem
TotalCents int64
Currency string
Version int64
CreatedAt time.Time
UpdatedAt time.Time
}
// New 只负责“创建即 CREATED”,不碰库存和支付。
func New(id, customerID, currency string, items []OrderItem, now time.Time) (*Order, error) {
if id == "" || customerID == "" || currency == "" || len(items) == 0 {
return nil, &Error{Code: CodeInvalidState, Op: "order.New: missing required field"}
}
var total int64
for _, item := range items {
if item.SKU == "" || item.Quantity <= 0 || item.UnitPriceCents < 0 {
return nil, &Error{Code: CodeInvalidState, Op: "order.New: invalid item " + item.SKU}
}
total += int64(item.Quantity) * item.UnitPriceCents
}
return &Order{
ID: id, CustomerID: customerID, Status: StatusCreated,
Items: append([]OrderItem(nil), items...), TotalCents: total,
Currency: currency, Version: 1, CreatedAt: now, UpdatedAt: now,
}, nil
}
// allowed 是状态机的唯一事实来源。
var allowed = map[Status][]Status{
StatusCreated: {StatusInventoryReserved, StatusCancelled},
StatusInventoryReserved: {StatusPaid, StatusCancelled},
StatusPaid: {StatusFulfilled, StatusRefunded},
StatusFulfilled: {},
StatusCancelled: {},
StatusRefunded: {},
}
func CanTransition(from, to Status) bool {
for _, candidate := range allowed[from] {
if candidate == to {
return true
}
}
return false
}
// TransitionTo 是唯一的写状态入口:判合法、改状态、升版本一次完成。
func (o *Order) TransitionTo(next Status, now time.Time) error {
if !CanTransition(o.Status, next) {
return &Error{Code: CodeInvalidState, Op: "order.TransitionTo", From: o.Status, To: next}
}
o.Status = next
o.Version++
o.UpdatedAt = now
return nil
}
Version 从第一版就存在,因为并发问题不能等到接数据库再补。Error.Code 也不是装饰:HTTP 层要把 INVALID_STATE 映射成 409,把 OUT_OF_STOCK 映射成 409 或 422,把 VERSION_CONFLICT 映射成 409 并允许重试。调用方按码分支,不按错误文本分支。
状态机:把“允许”写成数据
状态机不是一张画给评审看的图。它必须是一张表,能被代码读取、被测试穷举。上面 allowed 就是这张表;TransitionTo 是唯一的写入口;测试则独立再写一遍期望的边,防止实现和测试一起漂移。
// file: internal/order/order_test.go
// tier: L1
// verify: go test ./internal/order/ -run TestTransitionTo -v
package order
import (
"errors"
"testing"
"time"
)
// 期望的边独立于实现再写一遍;实现被误改时测试不会跟着漂移。
func TestTransitionTo(t *testing.T) {
legal := map[[2]Status]bool{
{StatusCreated, StatusInventoryReserved}: true,
{StatusCreated, StatusCancelled}: true,
{StatusInventoryReserved, StatusPaid}: true,
{StatusInventoryReserved, StatusCancelled}: true,
{StatusPaid, StatusFulfilled}: true,
{StatusPaid, StatusRefunded}: true,
}
statuses := []Status{
StatusCreated, StatusInventoryReserved, StatusPaid,
StatusFulfilled, StatusCancelled, StatusRefunded,
}
now := time.Unix(1_700_000_000, 0)
for _, from := range statuses {
for _, to := range statuses {
order := &Order{ID: "ord-1", Status: from, Version: 3}
err := order.TransitionTo(to, now)
want := legal[[2]Status{from, to}]
if got := err == nil; got != want {
t.Fatalf("%s -> %s: error = %v, want legal = %v", from, to, err, want)
}
if want {
if order.Status != to || order.Version != 4 || !order.UpdatedAt.Equal(now) {
t.Fatalf("%s -> %s: state not committed: %+v", from, to, order)
}
continue
}
var domainErr *Error
if !errors.As(err, &domainErr) || domainErr.Code != CodeInvalidState {
t.Fatalf("%s -> %s: error = %v, want INVALID_STATE", from, to, err)
}
if order.Status != from || order.Version != 3 {
t.Fatalf("%s -> %s: illegal transition mutated order", from, to)
}
}
}
}
func TestNewComputesTotal(t *testing.T) {
o, err := New("ord-1", "cus-1", "CNY", []OrderItem{
{SKU: "sku-a", Quantity: 2, UnitPriceCents: 1999},
{SKU: "sku-b", Quantity: 1, UnitPriceCents: 500},
}, time.Unix(1_700_000_000, 0))
if err != nil {
t.Fatal(err)
}
if o.TotalCents != 4498 || o.Status != StatusCreated || o.Version != 1 {
t.Fatalf("order = %+v", o)
}
if _, err := New("ord-2", "cus-1", "CNY", []OrderItem{{SKU: "sku-a", Quantity: 0}}, time.Now()); err == nil {
t.Fatal("zero quantity accepted")
}
}
这个测试穷举 6×6 个状态对,既验证合法边能提交,也验证非法边不修改 Status 和 Version。它的价值不在覆盖率,而在把“状态图”从口头约定变成可执行断言:以后有人加了 PAID -> CANCELLED,测试会立刻指出这与规格不符。
架构不是图,是一组可验证的约束
走到这里可以回答标题的问题了。后端架构要解决的不是“怎么画分层图”,而是在业务、流量和团队同时变化时,让系统仍然能做出可解释的决策。把这个目标翻译成 arch-lab 的第一组约束:
- C1:订单状态只能通过
TransitionTo修改,非法迁移返回INVALID_STATE。 - C2:每次状态变更必须递增
Version,持久化层用版本做条件更新。 - C3:HTTP 层不直接操作
Order字段,只调用领域方法并映射错误码。 - C4:所有外部依赖调用必须带截止时间,超时预算在入口处分配。
- C5:任何一条约束都要有对应的测试或检查命令;没有检查的约束只是愿望。
这五条约束有三个共同点:可验证、可回退、有明确的失效信号。可验证意味着能用 go test 或静态检查证明;可回退意味着决策记录了备选方案和重新评估的触发条件;失效信号意味着约束被打破时有人会知道,而不是等事故复盘。
架构决策也不是“一旦选定就永远正确”。第一版选择内存存储、同步调用和模块化单体,是因为当前只有一个团队、一个部署单元、还没有跨服务的强一致需求。等到库存服务必须独立扩缩容,或者支付团队要求独立发布,这些约束会被重新评估,而不是被当成信仰。
与站内文章的分工
站内的 架构设计基础原则 已经把 SOLID、高内聚低耦合、CAP/BASE 这些概念讲清楚了,本节不再重复定义。它回答“有哪些原则”,本节回答“这些原则在 arch-lab 的哪一行代码、哪一条断言上生效”。
架构模式与设计模式 区分了架构模式与设计模式的层次,并盘点了分层、管道-过滤器、微内核、事件驱动等模式。本节不重复那张清单,只把“模式”还原成选型维度:团队规模、独立部署、数据一致性、故障域、演进成本和运维复杂度,具体矩阵放在 1.3。
常见错误与边界
第一,把状态机写成 switch。switch 能判合法,但不能被测试穷举、不能被文档生成、不能告诉调用方“哪条边被拒绝”。表驱动才有这些能力。
第二,只改 Status 不改 Version。没有版本号,乐观锁无从谈起;两个并发请求都“成功”,实际是一次覆盖。
第三,让 HTTP DTO 直接复用领域结构体。JSON 字段一旦和聚合字段绑定,改 API 就会改领域,改领域就会破 API。
第四,把错误码当日志文案。Code 是契约,文本不是;调用方按文本匹配,翻译一次就全坏。
第五,以为领域模型能解决分布式一致性。它只能保证单进程内的不变量;跨库存、支付、消息的一致性要靠 1.2 的取舍和第 7 章之后的事务边界。
第六,在 New 里做库存校验。构造只回答“这个订单对象是否合法”,库存是另一个聚合的事实,失败应返回 OUT_OF_STOCK,而不是让订单创建失败得含糊其辞。
小结
后端架构要解决的是“变化时仍能做出可解释决策”的问题。朴素 handler 的六处代价——状态、并发、故障、变更、观测、安全——本质上是同一件事:规则没有落点。第一版 arch-lab 用显式 Status、表驱动 allowed、唯一写入口 TransitionTo 和独立期望表的测试,把状态规则变成了可执行约束。下一节把这些约束扩展成可测的质量属性场景,并正面处理它们之间的冲突。
阅读导航:上一节:目录 · 下一节:1.2 质量属性与无法回避的取舍 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。