应按业务动词而非技术分层拆分模块,优先将“创建订单”“履行订单”“订单查询”等行为切片,每个子模块只接收最小依赖并定义内部接口,测试边界需与模块边界对齐。

拆分前先识别“臃肿”的具体症状
模块臃肿不是代码行数多就一定有问题,而是体现在调用链混乱、测试难覆盖、改一处牵三处。常见信号包括:func ProcessOrder 里嵌套了支付校验、库存扣减、物流生成、短信通知、日志归档五层逻辑;或者一个 service.go 文件里塞了 12 个方法,其中 7 个只被本文件内调用;又或者单元测试要 mock 全局数据库+缓存+第三方 SDK 才能跑通。
真正该拆的,是违反单一职责且存在隐式耦合的部分——比如订单状态变更时,硬编码调用了 SendSMS 和 UpdateWarehouseStock,而这两个动作本不该由订单服务决定是否执行或执行顺序。
按领域行为而非技术分层做切片
别一上来就按 controller/service/repository 分三层复制粘贴。Go 没有强制分层,强行套用反而加剧依赖。优先按业务动词切:把“创建订单”相关逻辑(校验、生成单号、初始化状态)拎进 order/creation.go;把“履行订单”(扣库存、发货运单、通知仓库)放进 order/fulfillment.go;把“订单查询”这种纯读场景单独放 order/query.go。
关键判断标准:这些函数是否共享同一组输入约束?是否共用同一组失败回滚机制?是否会被同一类外部事件触发? 如果答案都是“是”,才属于同一子模块。
立即学习“go语言免费学习笔记(深入)”;
- 避免把
ValidatePaymentMethod和CalculateTax塞进同一个文件——前者依赖支付网关响应,后者依赖税率配置表,失败原因和重试策略完全不同 - 接口定义放在子模块内部,如
fulfillment.Shipper接口只被fulfillment包使用,不暴露到根 service 层 - 跨子模块调用必须走明确接口,禁止直接 import 另一个子模块的 struct 或 unexported 方法
用组合代替继承,控制依赖传递范围
重构时最容易犯的错,是把原来的大 struct 拆成小 struct 后,又让每个小 struct 都持有 *sql.DB、redis.Client、http.Client。结果表面拆了,实际还是全局依赖缠绕。
正确做法:每个子模块只接收它真正需要的最小依赖。比如 order/creation 只需要能查用户余额的接口 balance.Reader,而不是整个用户服务实例;order/fulfillment 需要的是 stock.Reserver 接口,而非完整库存服务。
- 在 main 初始化时统一构造依赖树,用结构体字段注入,不要用全局变量或 func init()
- 子模块构造函数显式声明依赖,例如
func NewFulfiller(r stock.Reserver, s shipper.Shipper) *Fulfiller - 如果某子模块临时需要一个新依赖,别偷偷加全局 client,先补接口定义,再让上层传入
测试边界要和模块边界对齐
重构后测试没跟上,等于没重构。重点不是覆盖率数字,而是验证“这个子模块是否能独立满足它的契约”。比如 order/creation.Create 的测试,应该只 mock balance.Reader 和 order.Store,绝不碰物流或短信模块。
容易忽略的点:错误路径的隔离性。当 ValidateAddress 失败时,要确认 CalculateShippingFee 根本没被调用——这说明你的拆分确实切断了隐式流程耦合。
- 每个子模块配一个
xxx_test.go,文件名与模块同名,不建统一 test 目录 - 测试用例命名体现行为而非实现,如
TestCreate_OrderWithInvalidAddress_ReturnsError,而非TestCreate_WithMockBalanceClient - 集成测试放在顶层,只验证各子模块组合后的端到端流程,不测单个子模块内部细节
最麻烦的从来不是怎么拆,而是怎么让团队在后续迭代中不把新逻辑又塞回旧文件。上线前同步更新 README 中的模块职责图,并把 go:generate 脚本加入 CI,自动检查跨模块的非法 import。


















