golang重构遗留系统的核心标尺是“接口契约冻结,实现可替换,包边界清晰”:冻结已暴露接口签名与error语义,按业务域组织包结构,渐进式module替换,统一错误处理与日志行为,严防隐式契约失效。

重构遗留系统时,Golang开发规范不是拿来背的 checklist,而是用来判断“改不改”“怎么改”“改到哪为止”的标尺。核心原则就一条:**接口契约冻结,实现可替换,包边界清晰**。
如何识别并冻结对外暴露的接口契约
遗留系统最常踩的坑是:以为改了函数名或加个参数不影响调用方,结果上线后 panic 或逻辑错乱。Go 没有运行时反射强制校验,全靠开发者自觉守约。
-
func GetUserByID(id int) (*User, error)这类函数签名一旦被其他模块 import 并调用,就必须冻结——不能改参数类型、不能增删返回值、不能变更error的语义(比如把errors.New("not found")换成fmt.Errorf("user %d missing")) - 检查所有
go list -f '{{.Imports}}' ./...输出,定位哪些包 import 了该模块;再用grep -r "GetUserByID" ./cmd/ ./internal/otherdomain/确认实际调用点 - 把已暴露的函数全部提取到
interface.go中,例如:type UserFinder interface { GetUserByID(int) (*User, error) },后续所有新代码只依赖这个接口,而非原包 - 禁止在接口里塞未被调用的方法,哪怕“以后可能用上”——接口膨胀是解耦失败的第一征兆
包组织必须按业务域收敛,而非技术分层
看到 handlers/、services/、repositories/ 这种顶层目录,基本可以判定重构还没真正开始。这种结构让跨模块引用失控,也掩盖了真实依赖。
- 每个业务模块(如
user)应独占一个子目录:internal/domain/user/,内含interface.go、service.go、handler.go、repository.go,且只允许本目录内文件互相 import -
cmd/api/main.go只做初始化和启动,不写任何业务逻辑;api/v1/user.go只定义 DTO 和 OpenAPI Schema,不放 handler 实现 - 禁止
user/service.go直接 importorder/repository.go;如需协作,定义回调接口(如UserCreatedListener)并在user/interface.go中声明 - 全局基础能力(日志、配置、DB 连接池)收口到
internal/pkg/log、internal/pkg/config,显式导入,不通过_空导入或 init() 注册
module 替换必须渐进,不能一刀切迁出仓库
很多团队一上来就想把 user 拆成独立 Git 仓库 + 单独 CI/CD,结果测试跑不通、版本对不上、依赖循环,两周卡在 go mod tidy 上。
- 先在原
go.mod中用replace声明伪模块路径:replace example.com/project/user => ./internal/domain/user,让其他模块能import "example.com/project/user",且支持独立go test - 确保
internal/domain/user/go.mod里 module 名与 replace 路径一致,并包含完整require列表(尤其是internal/pkg/xxx) - 只有当该模块出现多团队维护、部署节奏不一致、或需要独立语义化版本(如
v1.2.0)时,才迁出为真实 Git 仓库;迁移前必须补全tools.go统一管理 linter、swag 等开发依赖 - 迁移后,原单体仓库中保留
replace指向新仓库 tag,避免本地开发断连
错误处理与日志必须保持行为一致,否则监控告警会失效
重构时最容易被忽略的细节:下游服务正用 strings.Contains(err.Error(), "timeout") 做熔断,你把错误信息从 "db timeout" 改成 "failed to query: context deadline exceeded",整个链路就静默降级了。
- 所有公开函数返回的
error类型必须保持errors.Is()和errors.As()兼容;建议统一用自定义 error 类型(如ErrUserNotFound),而非字符串拼接 - 旧实现中的日志字段名(如
"user_id"、"duration_ms")必须在新实现中完全复现,字段类型和空值处理(nil vs 0 vs "")也要一致 - 灰度切换期间,两个实现的日志级别(
Info/Warn/Error)和打点位置必须对齐,否则 Prometheus 的 rate() 计算会突变 - 如果旧代码用
log.Printf,新代码必须也走同一日志实例(如pkg/log.DefaultLogger().Infof),不能混用标准库 log 和第三方 logger
真正的难点不在语法或工具,而在于每次修改前都要问一句:这个改动会不会让某个没写在文档里的隐式契约失效?比如某个运维脚本正 grep 日志里的固定字符串,或者某个前端页面靠 error message 做 UI 分支。这些细节,往往比接口设计更难发现,也更致命。
golang免费学习笔记(深入):立即使用
在学习笔记中,你将探索golang的核心概念和高级技巧!











