正确做法是在 order/domain 包中定义最小契约接口(如 UserEmailGetter),由 user/infra 或 user/mock 实现,避免业务与基础设施耦合,确保接口仅含 1–3 个动词命名方法,不包含 Close/Config 等生命周期方法,禁用 interface{} 替代真正接口。

接口定义在调用方包里,而不是实现方
这是最容易被绕开、但后果最直接的一条。如果你在 user/infra 包里定义了 UserRepository 接口,然后让 order/domain 包去 import 它——等于把业务逻辑和基础设施耦合在了一起,DIP 和 ISP 同时破防。
正确做法是:在 order/domain 里定义自己真正需要的接口,比如:
type UserEmailGetter interface {
GetEmailByID(ctx context.Context, id int64) (string, error)
}
然后让 user/infra 或 user/mock 去实现它。这样:
- 订单模块编译期不依赖用户服务的任何内部结构
- 切换实现(HTTP → Redis → 内存 mock)时,上层代码完全不动
- 接口不会因为用户服务加了个新字段而被迫升级
一个接口只暴露 1–3 个方法,且带动词命名
像 UserInterface、Service 这类泛称接口,基本就是 ISP 的反面教材。它往往塞了 Create、Update、Delete、FindAll、Close、HealthCheck……结果 80% 的调用方只用其中 1 个方法,却得为其余 5 个写 mock 或空实现。
立即学习“go语言免费学习笔记(深入)”;
按实际使用场景切小接口,例如:
-
UserEmailGetter—— 只要查邮箱 -
UserStatusUpdater—— 只负责更新状态 -
UserEventPublisher—— 只发事件,不碰 DB
好处是:测试更轻、替换更准、接口膨胀风险归零。别怕多定义几个接口——Go 的接口本就廉价,组合才是常态。
别把 Close()、Config()、Start() 塞进业务接口
常见错误是把资源生命周期方法硬塞进业务契约里,比如:
使用 uber-go/dig 在 Golang 中实现依赖注入——基于反射容器,提供 Provide/Invoke、dig.In/dig.Out 参数与结果对象,支持命名依赖等
type PaymentProcessor interface {
Process(ctx context.Context, p *Payment) error
Close() error // ❌ 不该在这里
}
问题来了:内存 mock 实现要不要关?HTTP 客户端要不要关?谁来 defer?单元测试是不是得反复构造又销毁?
正确拆法:
- 业务行为 →
PaymentProcessor(只有Process) - 资源管理 → 单独的初始化函数,如
NewStripeClient(cfg Config) *stripe.Client,由调用方控制生命周期 - 配置注入 → 通过构造函数参数传入,不是接口方法
接口只承诺“能做什么”,不承诺“怎么活/怎么死”。
interface{} 不是接口隔离,是类型检查的延迟炸弹
用 interface{} 当“万能参数”看似解耦,实则放弃编译期校验。一旦下游返回结构变了(比如字段名改了、嵌套层级深了),上游 panic 在运行时才爆,CI 过了、上线就挂。
真正的隔离靠的是最小契约接口:
- 它必须有明确方法签名
- 它只暴露调用方当前需要的那 1–2 个能力
- 它的实现可以是 struct、func、甚至 map,只要满足签名
记住:interface{} 是 Go 的逃生舱,不是设计原则。ISP 要求你亲手画出边界,而不是扔个黑盒进去赌运气。
接口粒度是否合适,不看它多“优雅”,而看它能否让新增实现不牵连旧代码、让单测不因无关方法失效。这点比记住五条原则更重要。

















