Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

第 2 章 编码、重构与 Code Review:构建可演进代码的实践方法

把架构意图写成可读、可测、可重构、可审查的代码,并通过高质量 Review 持续阻止系统走向腐化。

第 1 章讨论的是系统设计与技术方案写作,回答了“为什么这样设计”和“如何把设计讲清楚”。但真正决定系统长期质量的,往往不是 PPT 上的架构图,而是每天进入仓库的代码。

很多团队在方案阶段讲得很好,落地时却迅速失真:

  • 设计文档里分层清晰,代码里却出现跨层直连;
  • 领域边界在图里很优雅,提交里却把 Controller、Service、DAO 和第三方调用揉成一团;
  • 大家都知道要做幂等、超时、补偿和监控,但真正写代码时这些保障常常遗漏;
  • 团队也做 Code Review,但流于“看起来没问题”或“只是挑格式”,没有真正拦住设计偏移和线上风险。

所以这一章不再把“编码”理解成语法熟练度,也不把“重构”理解成推倒重写,更不把“Code Review”理解成合并前的礼貌动作,而是把它们一起看成架构执行力的一部分。一个成熟的工程团队,至少要在三个层面上同时达标:

  • 编码阶段:把复杂业务拆成清晰边界、稳定抽象和可验证实现。
  • 重构阶段:在不停止交付的前提下,持续修复边界失真、抽象退化和历史包袱。
  • 评审阶段:在缺陷、耦合和错误模式进入主干前,把问题尽可能提前暴露。

读完本章,你应该能建立三件事的共同语言:

  • 什么样的代码,才算真正支持系统长期演进。
  • 老代码开始腐化时,应该如何小步重构,而不是等到必须重写。
  • Code Review 到底在审什么,而不只是“帮忙看一眼”。
  • 架构师、开发者、Reviewer 分别应该承担什么责任。

2.1 为什么架构最终会输在代码细节上

2.1.1 架构不是画出来的,是提交出来的

系统设计常常失败,不是因为没人知道应该分层、隔离依赖、控制一致性,而是因为这些原则没有被持续写进代码。架构图表达的是目标形态,代码库体现的才是真实形态。

如果一个团队长期容忍下面这些现象,再好的技术方案也会被慢慢掏空:

  • Handler 直接拼 SQL;
  • 领域对象里混入远程调用和缓存访问;
  • “临时”分支逻辑逐步堆成主流程;
  • 为了赶需求,把异常处理、超时控制、回滚语义留给“后面补”。

时间一长,团队面对的就不再是“如何实现新需求”,而是“改一行为什么会炸三处”。这类问题本质上不是功能问题,而是代码结构已经不再承载架构意图

2.1.2 代码质量差的真实代价

很多人把代码质量理解成审美问题,仿佛只是“优雅不优雅”。但对业务系统来说,代码质量的代价非常具体:

问题短期表现长期后果
边界混乱改动快,复制方便模块无法替换,重构成本陡增
函数过大新需求先堆进去回归风险高,没人敢动老逻辑
缺少测试点交付初期看不出来故障只能靠线上反馈暴露
Review 走形式合并速度快缺陷和坏味道持续进入主干
命名模糊写的人知道半年后团队整体认知失真

架构师尤其不能把这些问题看成“实现细节”。系统能否长期演进,往往不是取决于是否用了微服务、CQRS、Saga 这些名词,而是取决于每一次提交是否在强化还是侵蚀边界。

2.1.3 架构师为什么必须懂编码和 Review

架构师不一定要承担最多业务代码,但必须对代码落地保持足够近的距离。原因很简单:

  • 设计是否真的可实现,只有进入代码以后才会暴露真实摩擦;
  • 团队抽象是否过度、边界是否失真,Review 时最容易看出来;
  • 如果架构师长期离开编码现场,方案会越来越像“理想模型”,而不是可落地工程。

一个可操作的底线是:核心链路的关键模块,架构师至少要能定期深度 Review,并能亲自写出代表团队标准的样例代码。

flowchart LR
	A[架构设计] --> B[代码实现]
	B --> C[Code Review]
	C --> D[合并与发布]
	D --> E[线上反馈]
	E --> A

这条闭环里,编码和 Review 不是末端动作,而是把设计变成现实、再把现实反馈给设计的关键环节。


2.2 好代码首先是边界清楚

2.2.1 先问职责,再写函数

很多坏代码并不是因为工程师不会写,而是因为下笔前没先回答一个问题:这段代码到底属于哪一层、承担什么职责。

以电商下单为例,至少有几类不同职责:

  • Handler:接收请求、参数绑定、鉴权、返回协议;
  • Use Case / Application:编排下单流程,协调库存、计价、订单持久化;
  • Domain:表达订单、库存、金额、不变量与业务规则;
  • Infrastructure:数据库、缓存、MQ、RPC 客户端实现。

这些职责一旦混在一起,短期会觉得“写起来更快”,长期就会出现两个典型后果:

  • 业务逻辑无法脱离接口或存储独立测试;
  • 每次改动都必须理解整条链路的全部技术细节。

关于「这些职责在真实工程里如何落到目录结构上」,第 1 章给出了两套完整的 Go 目录映射(三层架构版与 DDD 版),2.7 节会基于可运行的 ~/Projects/system-design-architecture-examples/ 示例工程做完整走读。示例工程独立于本书仓库维护。

2.2.2 用例编排层要显式,不要隐式散落

业务系统最常见的腐化方式,不是类太多,而是“关键业务流程没有稳定的编排层”。于是下单的一部分逻辑藏在 Handler,另一部分藏在 Service,另一部分又埋在 Repository Hook 或异步消费者里。

更健康的做法是让“流程在哪里发生”这件事一眼可见:

type PlaceOrderUseCase struct {
	inventory InventoryService
	pricing   PricingService
	repo      OrderRepository
}

func (uc *PlaceOrderUseCase) Execute(ctx context.Context, cmd PlaceOrderCommand) (string, error) {
	if err := cmd.Validate(); err != nil {
		return "", err
	}

	quote, err := uc.pricing.Quote(ctx, cmd.UserID, cmd.Items)
	if err != nil {
		return "", fmt.Errorf("quote price: %w", err)
	}

	if err := uc.inventory.Reserve(ctx, cmd.Items); err != nil {
		return "", fmt.Errorf("reserve inventory: %w", err)
	}

	order := NewOrder(cmd.UserID, cmd.Items, quote.PayableAmount)
	if err := uc.repo.Save(ctx, order); err != nil {
		return "", fmt.Errorf("save order: %w", err)
	}

	return order.ID, nil
}

这段代码未必复杂,但它提供了两个重要价值:

  • 流程顺序显式可读;
  • 下层依赖是抽象接口,便于测试和替换。

2.2.3 写“业务语言”,不要写“技术噪音”

很多代码难读,不是逻辑太深,而是表达层级不对。调用方真正关心的是“预留库存”“计算应付金额”“创建订单”,而不是一堆技术性细节。

反例通常长这样:

func CreateOrder(ctx context.Context, req *Request) error {
	if req == nil {
		return errors.New("nil")
	}
	// 混着校验、查库、拼对象、写缓存、打日志、改状态
	return nil
}

问题不在于函数短,而在于它没有向读者提供清晰语义。好的业务代码,读起来应该像在追踪一个真实业务动作,而不是在猜作者想做什么。

2.2.4 参数太多,通常说明抽象不对

参数列表过长,往往意味着以下几种问题之一:

  • 缺少输入模型,调用方必须自己拼装上下文;
  • 一个函数做了太多事,需要的上下文过多;
  • 中间状态没有被显式建模,只能靠参数平铺传递。

例如下面这种签名几乎注定不可维护:

func CalcPrice(userID string, city string, channel string, skuID string, qty int, couponCode string, vipLevel int, usePoints bool) (int64, error) {
	return 0, nil
}

更合理的做法是让输入拥有清晰语义:

type PriceQuoteQuery struct {
	UserID     string
	City       string
	Channel    string
	Items      []QuoteItem
	CouponCode string
	VIPLevel   int
	UsePoints  bool
}

这样做不只是“好看”,更重要的是:

  • 新字段扩展不会破坏大批调用方;
  • 测试构造输入更自然;
  • Review 时更容易判断字段语义是否合理。

2.3 重构不是推倒重来,而是持续纠偏

很多团队嘴上都知道“代码要可维护”,但一旦老逻辑开始变形,第一反应要么是继续堆条件,要么是豪情万丈地说“找时间重写”。前者会让代码越改越烂,后者通常永远没有时间真正发生。对业务系统来说,真正可执行的路径往往只有一条:在持续交付中小步纠偏。

重构的目标不是把代码变得“更漂亮”,而是降低未来每一次变更的摩擦成本,让系统重新回到可理解、可替换、可测试、可评审的状态。如果一段代码已经让团队不敢动、不愿改、每次修改都伴随高回归风险,那么它就已经不是局部实现问题,而是生产效率和系统演进能力的问题。

2.3.1 什么信号说明已经不能再继续堆逻辑

下面这些现象,通常意味着代码已经进入“继续堆功能比及时重构更危险”的阶段:

  • 一个函数同时处理校验、编排、持久化、远程调用和异常恢复。
  • 改一条业务规则要同时改多个层次,甚至多个服务。
  • 同一类逻辑在不同分支里复制粘贴,只是改了几个字段名。
  • 命名已经和真实业务语义对不上,只能靠口口相传理解。
  • Reviewer 看到代码时会说“能跑,但我不敢保证后面还好改”。

这些信号的共同点是:代码已经不再帮助团队表达业务,而是在迫使团队绕着历史包袱工作。

2.3.2 重构的第一原则:先收口边界,再优化内部实现

很多失败的重构,不是因为方向不对,而是因为一上来就想把所有问题一起解决:顺手改命名、顺手换框架、顺手改协议、顺手统一抽象。结果系统还没变好,风险面先被扩大了。

更稳妥的路径通常是:

  1. 先识别最关键的腐化点,到底是编排层缺失、依赖泄漏,还是领域规则散落。
  2. 先把边界收口,让调用关系清楚,职责归属明确。
  3. 再逐步替换内部实现,而不是先大面积重写细节。

换句话说,重构优先级往往不是“把代码写得更优雅”,而是“让未来的改动先有一个正确的落点”。

2.3.3 小步重构的常用路径

对线上系统来说,最有价值的不是理想化重构,而是能在真实交付节奏里落地的重构路径。下面几种方式最常见,也最实用:

重构路径适用场景关键动作
抽输入模型参数平铺、语义混乱先把输入收拢成明确命令或查询对象
补编排层流程散落在 Handler / Service / Hook把主流程显式收敛到 Use Case / Application 层
包装旧实现老模块不能立刻替换先加抽象隔离层,再逐步迁移调用方
拆副作用核心逻辑难测把纯业务判断与数据库 / RPC / MQ 分开
显式错误语义失败路径模糊把重复、超时、冲突、不可恢复错误区分开

这些动作看起来不“宏大”,但恰恰因为它们足够小,才更容易进入日常交付节奏,而不是永远停留在重构计划里。

2.3.4 把“空中换引擎”用在代码重构上

系统重构和架构重构有一个共同点:真正成熟的做法,往往不是停机重建,而是在线迁移。代码层面同样如此。很多时候,旧实现不能立刻删除,但可以先被包进一个更干净的抽象中,让新逻辑逐渐迁移到新边界。

一个常见步骤是:

识别腐化模块
  -> 定义新接口
  -> 用适配层包装旧实现
  -> 新需求优先走新接口
  -> 老调用逐步迁移
  -> 用测试和 Review 锁住行为
  -> 删除旧路径

这种做法的意义在于,它允许团队在保持业务连续交付的同时,逐步让代码库重新变得可演进,而不是在“继续忍受”和“推倒重写”之间二选一。

2.4 编码时真正应该守住的几个原则

2.4.1 可读性优先于炫技

业务系统不是算法竞赛,维护周期远长于编写周期。多数情况下,未来读代码的人并不是当前作者本人,因此代码首先要对团队友好,而不是对作者本人高效。

可读性通常来自四件事:

  • 命名和业务语义一致;
  • 控制流简单,避免多层嵌套;
  • 副作用位置明确;
  • 错误路径可见,不靠隐式约定。

2.4.2 把变化点隔离,而不是把一切都抽象

很多团队一遇到重复就急着抽象,最后得到的是“看起来可复用、实际没人敢改”的公共代码。真正应该抽象的不是相似代码本身,而是稳定的概念和可预期的变化点

例如:

  • 不同库存来源的预留逻辑,可以抽象成同一接口下的不同实现;
  • 不同业务线都要发消息,不一定需要一个万能 SendEverythingService
  • 只出现两次的小差异,不一定值得立即抽象成复杂模式。

好的抽象会减少决策面,坏的抽象会制造更多决策面。

2.4.3 失败路径要和成功路径一样认真

线上事故很少发生在“主流程最理想的那条路”,而更常见于:

  • 超时后是否重试;
  • 重试后是否重复扣减;
  • 部分成功后如何补偿;
  • 下游失败时状态是否半提交;
  • 幂等键是否真的覆盖重放场景。

所以编码时必须把错误处理当成一等公民。下面的例子比“调用成功就返回”更接近生产代码:

var ErrOrderAlreadyExists = errors.New("order already exists")

func (r *OrderRepository) Save(ctx context.Context, order *Order) error {
	_, err := r.db.ExecContext(
		ctx,
		`INSERT INTO orders (id, user_id, amount_cents) VALUES (?, ?, ?)`,
		order.ID,
		order.UserID,
		order.AmountCents,
	)
	if err != nil {
		if isDuplicateKey(err) {
			return ErrOrderAlreadyExists
		}
		return fmt.Errorf("insert order: %w", err)
	}
	return nil
}

这种写法的价值在于:

  • 业务性冲突可被上层识别;
  • 基础设施故障仍能保留错误链;
  • Review 时容易判断幂等行为是否符合预期。

2.4.4 让测试点自然存在

代码未必必须先写测试,但一定要写成“能被测试”的形状。最糟糕的情况不是没有测试,而是代码结构让你根本无法只测业务逻辑,只能起整套环境做脆弱的集成验证。

更易测的代码通常具备这些特征:

  • 输入和输出明确;
  • 外部依赖通过接口注入;
  • 核心逻辑不依赖全局状态;
  • 纯计算与副作用分离。

可测试性不是测试工程师的额外要求,而是架构是否清晰的副产品。

2.4.5 小步提交,优于大爆炸提交

许多 Review 效率低,不是 Reviewer 不负责,而是提交本身已经不可评审。一个同时包含“重命名、重构、修 bug、加新功能、顺手格式化全目录”的 PR,几乎不可能被认真看完。

高质量提交通常有三个特征:

  • 主题单一:一个 PR 只解决一类问题;
  • 变更可解释:作者能说明改动前后行为差异;
  • 回滚可控:出问题时能相对独立地撤回。

从工程协作角度看,小步提交本身就是对 Review 质量的投资。


2.5 Code Review 到底在审什么

2.5.1 Review 的目标不是挑错字

Code Review 的核心目标有四个:

  • 在合并前发现正确性和稳定性问题;
  • 检查实现是否偏离架构和设计约束;
  • 传播团队编码标准和隐性知识;
  • 通过讨论提升系统长期可维护性。

所以真正有价值的 Review,应该优先关注:

  • 这段代码会不会做错;
  • 这段代码以后会不会很难改;
  • 这段代码是不是把风险藏起来了。

而格式、换行、 import 排序这些内容,应该尽量交给格式化工具和静态检查器,而不是浪费人工评审注意力。

2.5.2 Reviewer 的检查顺序

一份高质量 Review,最好按由大到小的顺序看,而不是一上来盯局部实现。

建议顺序如下:

  1. 需求与行为:这次改动到底解决什么问题,是否改变了用户可见行为。
  2. 边界与设计:代码是否放在正确层次,是否引入了不必要耦合。
  3. 正确性:状态迁移、并发、异常、幂等、空值、边界条件是否安全。
  4. 可维护性:命名、结构、复用方式、注释是否帮助未来修改。
  5. 验证覆盖:测试、日志、指标、灰度与回滚手段是否足够。

这个顺序很重要,因为很多真正致命的问题,根本不是“某行写错”,而是整段代码从一开始就放错了位置。

2.5.3 Reviewer 最容易漏掉的风险

在业务系统里,下面几类问题尤其值得重点关注:

风险类型典型问题
状态一致性是否可能部分成功、部分失败
并发语义是否会重复执行、重复扣减、脏写覆盖
超时重试重试是否幂等,是否放大下游压力
兼容性新字段、新枚举、新协议是否影响旧调用方
观测性故障发生后是否能定位到哪一步出错
回滚能力发布失败后是否能快速止损

如果 Review 长期只盯代码风格,这些风险就会直接穿透到线上。

2.5.4 Author 应该怎样配合 Review

Review 质量不只是 Reviewer 的责任,Author 同样负有很大责任。作者在发起 PR 时,至少应该主动提供这些信息:

  • 这次改动解决什么问题;
  • 改动范围和非目标范围是什么;
  • 有没有行为变化或数据兼容风险;
  • 如何验证;
  • 有没有需要 Reviewer 特别关注的点。

一个好的 PR 描述,能显著降低 Reviewer 的理解成本,也更容易把注意力放到真正重要的问题上。


2.6 一份可执行的 Code Review 清单

下面给出一份更适合业务系统的 Review Checklist。它不是要求每次逐条机械打勾,而是帮助团队形成稳定视角。

2.6.1 业务与正确性

  • 这次实现是否真的满足需求,而不是只满足 happy path。
  • 是否覆盖了空输入、重复请求、无权限、数据不存在等边界情况。
  • 是否存在状态半提交、重复执行、顺序错误的问题。
  • 失败后是否有明确返回、重试策略或补偿语义。

2.6.2 架构与边界

  • 代码是否放在正确层次,是否出现跨层泄漏。
  • 领域规则是否仍由领域对象或用例层表达,而不是散落在外层。
  • 是否引入了新的隐式耦合,例如直接依赖具体实现、共享可变状态、万能工具类。
  • 新抽象是否真有稳定价值,还是为了“看起来高级”。
  • 目录结构与分层是否符合团队约定的映射(可对照第 1 章目录映射与 ~/Projects/system-design-architecture-examples/ 样例)。

2.6.3 可读性与可维护性

  • 命名是否表达业务语义,而不是技术细节。
  • 函数是否过长、职责是否过多。
  • 控制流是否过深,是否需要提前返回或拆分步骤。
  • 注释是否解释“为什么”,而不是重复“代码在做什么”。

2.6.4 可靠性与可运维性

  • 是否设置了合理的超时、重试、限流或幂等控制。
  • 是否补充了必要的日志、指标、Tracing 标签。
  • 故障发生时,能否快速定位模块、输入和阶段。
  • 发布后是否具备灰度、监控和回滚抓手。

2.6.5 测试与验证

  • 是否新增或调整了足够说明行为的测试。
  • 测试是否覆盖关键分支,而不是只覆盖表面行数。
  • 是否区分了单元测试、集成测试和端到端验证责任。
  • 是否给出手工验证步骤或构造数据方式。

如果团队能长期围绕这五个维度讨论,Review 很快就会从“凭感觉”升级为“有共同标准”。


2.7 优秀代码实践走读:从可运行示例看好代码的形状

前面几节讲的是原则,这一节看实例。本书配套的 ~/Projects/system-design-architecture-examples/ 目录下有三个可编译运行的 Go 工程,它们不是玩具 Demo,而是刻意用来展示「代码结构如何承载架构意图」的对照样本。示例代码已从书籍仓库移出,避免书稿构建项目和可运行工程相互耦合:

示例工程展示重点对应的代码问题
~/Projects/system-design-architecture-examples/order-service标准三层架构的自然形态职责够清楚,但业务规则散落在 service,依赖具体实现
~/Projects/system-design-architecture-examples/product-serviceDDD 四层架构、聚合根、值对象、领域事件、三级缓存、Outbox业务规则内聚到领域模型,依赖方向向内,可独立测试
~/Projects/system-design-architecture-examples/common-services全局 ID 等基础服务的薄封装通用域不需要重抽象

这一节从这三个工程里挑出五段代码,分别对应 2.2-2.4 的原则。读的时候建议对照源码完整看一遍——真实工程里的取舍细节(日志、注释、错误处理)比书上任何摘录都更有信息量。

2.7.1 目录结构本身就是第一份「好代码」

product-service 的顶层结构,几乎可以直接拿来当团队模板(完整树见第 34 章 34.6 节):

product-service/
├── cmd/main.go                    # 入口:只做组装,不写业务
└── internal/
    ├── domain/                    # 领域层:聚合根、值对象、领域事件、Repository 接口
    ├── application/               # 应用层:用例编排、DTO
    ├── infrastructure/            # 基础设施:Repository 实现、缓存、Kafka
    └── interfaces/                # 接口层:HTTP、gRPC、Event 三种触发方式

这个结构好在哪里,用 2.2 的视角看非常清楚:

  • 职责归属没有歧义。一个新需求来了,「这段代码该放哪」有明确答案,这是 2.2.1 的落地。
  • 依赖方向物理可见domain 不 import 任何其他 internal 包,interfacesinfrastructure 都依赖内层——Review 时一条 import 就能发现跨层泄漏。
  • 三种入口(HTTP/gRPC/Event)平级,都只做协议转换后调用同一个 application service,不存在「Kafka 消费者里藏着一版业务逻辑」的隐式编排问题(2.2.2)。

相比之下,order-service 代表了大多数项目的现状:application/service 直接依赖 infrastructure/persistence 的具体实现,model.Order 是贫血对象。它的 README 自己也写明「这些正是后面引入 Clean Architecture、DDD 和 CQRS 的原因」。两个工程对照读,能直观看到 2.3 说的「腐化信号」长什么样、演进的终点长什么样。

2.7.2 领域模型:业务规则内聚,而不是散落在 setter 之外

internal/domain/product.go 中的 Product 聚合根,是 2.2.3「写业务语言」的完整范例。看它的上架方法:

// OnShelf 上架
func (p *Product) OnShelf() error {
	if p.status == ProductStatusOnShelf {
		return errors.New("商品已上架")
	}
	if p.basePrice.Amount() <= 0 {
		return errors.New("商品价格必须大于0")
	}
	if len(p.images) == 0 {
		return errors.New("商品必须有至少一张图片")
	}

	p.status = ProductStatusOnShelf
	p.updatedAt = time.Now()

	p.addDomainEvent(ProductOnShelfEvent{
		SKUID:     p.skuID.Value(),
		OnShelfAt: p.updatedAt,
	})

	return nil
}

这段代码值得走读的四个点:

  1. 不变量由聚合根自己保护。调用方不可能绕过「价格必须大于 0」这个规则——规则内聚在模型里,而不是靠每个调用方「记得检查」。这正是第 1 章「贫血 vs 充血」对比的完整版。
  2. 状态迁移表达为业务动词OnShelf()OffShelf(reason)UpdateBasePrice(price),读代码就是在读业务流程,没有任何技术噪音。
  3. 每次状态变更留下领域事件。事件在聚合内部积累,由应用层统一发布——「业务事实已经发生」这件事被显式建模,而不是散落在各处 kafka.Send()
  4. 字段私有 + 只读 Getter。外部无法直接把商品改成上架状态,只能走 OnShelf(),非法状态在类型层面就不存在。

2.7.3 值对象:让非法输入在构造时就失败

internal/domain/value_objects.go 里金额的处理方式,是业务系统最值得抄的一个习惯:

// Price 值对象(使用分为单位,避免浮点精度问题)
type Price struct {
	amount   int64  // 金额(分)
	currency string // 货币(CNY)
}

func NewPrice(amount int64, currency string) (Price, error) {
	if amount < 0 {
		return Price{}, errors.New("价格不能为负数")
	}
	if currency == "" {
		currency = "CNY"
	}
	return Price{amount: amount, currency: currency}, nil
}

两个设计决策都值得在 Review 中坚持:

  • 金额用「分」存 int64,不用 float64。浮点精度问题在计价、退款分摊场景是真金白银的事故源(第 29 章计价系统会再次遇到这个约束)。
  • 校验收敛在构造函数NewPrice 是唯一能造出 Price 的地方,负数价格在系统中根本不可能存在——这比在每个使用处写 if price < 0 高一个量级,也正是 2.4.2「把变化点隔离」的实例。

2.7.4 应用服务:编排显式、依赖抽象、失败语义清楚

internal/application/service/product_service.go 展示了一个符合 2.2.2 的用例编排层:

// EventPublisher is owned by the application layer.
// Infrastructure adapters such as KafkaProducer implement it.
type EventPublisher interface {
	Publish(ctx context.Context, event domain.DomainEvent) error
	PublishBatch(ctx context.Context, events []domain.DomainEvent) error
}

type ProductService struct {
	repo           domain.ProductRepository
	eventPublisher EventPublisher
}

func (s *ProductService) CreateProduct(ctx context.Context, req *dto.CreateProductRequest) (*dto.CreateProductResponse, error) {
	// Step 1: 创建领域对象(校验和不变量在领域层完成)
	product := domain.NewProduct(skuID, spu, req.SupplierSKU, price, specs)

	// Step 2: 保存聚合根
	if err := s.repo.Save(ctx, product); err != nil {
		return nil, fmt.Errorf("保存商品失败: %w", err)
	}

	// Step 3: 发布聚合中积累的领域事件
	if err := s.publishDomainEvents(ctx, product); err != nil {
		...
	}
}

对应 2.4.3 和 2.4.4,这段代码的三个好习惯:

  • 接口在使用方定义EventPublisher 接口声明在 application 层、由 infrastructure 的 KafkaProducer 实现——依赖方向向内,单元测试时可以注入内存假实现,不需要起 Kafka。
  • 错误用 %w 包装并带业务语义保存商品失败: %w 既保留了错误链可供日志追溯,又给上层提供了可判断的上下文。
  • 流程步骤一眼可读。建领域对象 → 保存 → 发事件,顺序显式。编排层的职责是「编排」,不是「什么都干」。

2.7.5 测试:好结构让测试自然存在

2.4.4 说「可测试性是架构清晰的副产品」,这个工程给出了证据。product_center_service_test.go 不需要任何外部依赖就能验证核心业务行为:

func TestProductCenterPublishesSnapshotAndOutbox(t *testing.T) {
	repo := persistence.NewProductCenterRepository()   // 内存实现
	svc := NewProductCenterService(repo)

	result, err := svc.PublishCommand(ctx, domain.PublishProductVersionCommand{...})

	// 断言发布版本递增、快照内容正确、Outbox 事件已写入
	snapshot, _ := repo.GetSnapshot(ctx, result.ItemID, result.PublishVersion)
	events, _ := repo.ListOutbox(ctx, domain.OutboxPending)
	...
}

能写出这种测试,前提是前面所有的结构决策都做对了:依赖接口注入、核心逻辑不碰全局状态、Repository 有内存实现。反过来,2.8.1 节(下单反例)里那个把一切都揉在 Handler 里的写法,你想给它补一个「库存不足」的单元测试都无从下手——只能起数据库做脆弱的集成测试。测试写不出来,往往就是结构腐化最早的报警器。

2.7.6 把示例工程用起来

建议团队这样使用这些示例,而不是读完就忘:

  • 新人 Onboarding:先跑通 product-servicego run ./cmd),再回答「我要加一个新的商品字段,应该改哪几层」。
  • Review 参照系:当 PR 里出现「这段逻辑该放哪层」的争论时,用示例工程的同类代码做参照,比抽象讨论快得多。
  • 重构对照目标:迁移老代码时,把 order-service(现状)和 product-service(目标)摆在一起,演进路径就是 2.3.4 的「空中换引擎」。

2.8 一个电商下单场景里的编码、重构与 Review

为了把原则落到具体场景,下面用一个简化的下单流程说明“代码怎么写”和“Review 怎么看”。

2.8.1 一个不健康的实现

func (h *OrderHandler) Create(c *gin.Context) {
	var req CreateOrderRequest
	if err := c.ShouldBindJSON(&req); err != nil {
		c.JSON(400, gin.H{"error": err.Error()})
		return
	}

	user, _ := h.userRepo.FindByID(c, req.UserID)
	if user == nil {
		c.JSON(400, gin.H{"error": "user not found"})
		return
	}

	total := int64(0)
	for _, item := range req.Items {
		sku, _ := h.skuRepo.Find(c, item.SKU)
		total += sku.Price * int64(item.Qty)
		_ = h.inventoryRepo.Decrease(c, item.SKU, item.Qty)
	}

	orderID := uuid.NewString()
	_ = h.db.ExecContext(c, "insert into orders(id,user_id,total_amount) values(?,?,?)", orderID, req.UserID, total)
	c.JSON(200, gin.H{"order_id": orderID})
}

这段代码的问题非常典型:

  • Handler 同时承担协议层、编排层、领域校验和持久化职责;
  • 错误基本被吞掉;
  • 库存扣减和订单写入之间没有一致性语义;
  • 没有超时、幂等、日志和观测点;
  • 几乎无法只针对业务规则编写单元测试。

2.8.2 如果这是线上老代码,应该怎么重构

真实团队里更常见的情况不是“从零开始写一个更好的版本”,而是坏代码已经在线上跑了很久,周围还挂着依赖、监控、补偿脚本和历史调用。这个时候,最危险的做法往往不是不动,而是上来就想一口气彻底重写。

更现实的重构路径通常是:

  1. 先把 Handler 中的流程编排提出来,形成单独的 Use Case。
  2. 再把库存、计价、订单持久化等依赖抽成清晰接口。
  3. 明确错误语义和幂等语义,让旧逻辑至少先变得可判断。
  4. 补上关键路径测试和最小观测点,再继续拆分内部实现。

这一阶段的目标不是“一次性得到完美代码”,而是让这段链路重新进入可控状态:可以测、可以审、可以继续演进。

2.8.3 更合理的拆分

type OrderHandler struct {
	uc *PlaceOrderUseCase
}

func (h *OrderHandler) Create(c *gin.Context) {
	var req CreateOrderRequest
	if err := c.ShouldBindJSON(&req); err != nil {
		c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
		return
	}

	orderID, err := h.uc.Execute(c.Request.Context(), req.ToCommand())
	if err != nil {
		writeOrderError(c, err)
		return
	}

	c.JSON(http.StatusOK, gin.H{"order_id": orderID})
}

这时 Reviewer 能更容易聚焦真正重要的问题:

  • Execute 是否定义了清晰的事务边界或补偿策略;
  • ToCommand() 是否丢失关键字段;
  • writeOrderError 是否正确映射业务错误与系统错误;
  • 下层依赖是否具备幂等和超时语义。

好的结构不是为了“看起来分层”,而是为了让 Review 变得可行。

2.8.4 Reviewer 在这个场景里应该怎么提问

看到类似下单链路时,Reviewer 至少应该追问这些问题:

  • 如果前端重复提交,请求是否会创建重复订单。
  • 如果库存扣减成功、订单写库失败,后续怎么恢复。
  • 如果计价服务超时,是否会重试,重试是否安全。
  • 是否需要在日志或指标里区分哪一个阶段失败。
  • 是否有测试覆盖“库存不足”“重复请求”“下游超时”这些关键场景。

这些问题的价值远高于“变量名是不是更短一点”。


2.9 常见误区与团队实践建议

2.9.1 误区一:把 Review 当审批,不当协作

有些团队把 Review 理解成“有人点了 Approve 就行”,于是流程是有了,质量却没有提升。真正有效的 Review 应该是共同发现风险、澄清设计和统一标准,而不是机械签字。

2.9.2 误区二:用 Review 替代设计

如果一个 PR 到了 Review 阶段才第一次暴露“模块边界错了”“方案方向不对”,说明前面的设计沟通已经缺位。Review 可以发现架构偏移,但不应该承担完整设计工作的全部责任。

更稳妥的做法是:

  • 大改动先有 TD 或 RFC;
  • 中等改动先在 Issue、文档或评论里对齐边界;
  • PR Review 重点核对“实现是否符合设计”,而不是从零开始发明设计。

2.9.3 误区三:提交太大,导致没人真看

如果团队长期接受超大 PR,最终结果通常是:

  • Reviewer 只看自己熟悉的几处;
  • 边界性问题被“以后再说”跳过;
  • 真正复杂的改动在匆忙中直接进入主干。

团队应该把“小步可审查”当成明确要求,而不是个人习惯。

2.9.4 误区四:把风格争议交给人肉争论

缩进、换行、 import 排序、基础 lint 规则,尽量交给自动化工具。人工注意力很贵,应该用来识别架构偏移、错误语义和未来维护成本,而不是反复争论空格。

2.9.5 建议建立团队级样例、重构预算与评审文化

单靠口头要求,很难稳定提升团队编码、重构和 Review 水平。更有效的做法通常包括:

  • 为核心链路维护“代表团队标准”的样例实现(本书的 ~/Projects/system-design-architecture-examples/ 三个 Go 工程就是这种样例的最小形态,见 2.7 节);
  • 为高风险场景维护 Review Checklist;
  • 为历史包袱最重的模块预留明确的重构预算,而不是永远让它排在需求之后;
  • 在复盘中把真实事故沉淀成新的评审规则;
  • 鼓励 Reviewer 提出基于风险的意见,而不是只给抽象评价。

最好的 Review 文化,不是“大家都很严格”,而是“大家知道为什么严格、严格在什么地方”。


2.10 本章小结

编码、重构与 Code Review 不是架构之后的附属环节,而是架构能否真正落地的主战场。一个团队如果只会写设计文档,却不能持续写出边界清晰、错误可控、便于重构和审查的代码,系统质量最终仍会滑向混乱。

你可以把本章压缩成三句行动原则:

  • 编码时先守边界,再谈技巧。
  • 重构时先收口边界,再替换内部实现。
  • Review 时先看正确性与设计,再看局部实现。
  • 让提交可审查,让风险可讨论,让结论可复用。

从下一章开始,我们会进一步进入生产级系统保障,讨论当代码进入真实流量、真实故障和真实资金风险环境后,还需要哪些可靠性、恢复与防资损能力。