重访 Go 中的接口隔离原则
Revisiting Interface Segregation in Go

原始链接: https://rednafi.com/go/interface-segregation/

## Go语言中的接口隔离原则:总结 虽然接口隔离原则(ISP)——“客户端不应该被迫依赖于它们不使用的接口方法”——起源于面向对象设计,但在Go语言中也很有价值。许多Go新手会自然而然地重新发现这个概念,并发现它对代码清晰度和可维护性有益。 核心思想是倾向于使用**小型、消费者定义的接口**,而不是大型的、包罗万象的接口。Go语言鼓励在“消费者”端(即*使用*接口的代码附近)定义接口,而不是在“生产者”端(即类型实现接口的地方)定义接口。这样可以最大限度地减少耦合。 例如,不要定义一个包含`Save`和`Load`方法的通用`Storage`接口,而是创建一个只包含`Save`方法的`Saver`接口,如果一个函数只需要保存功能。这简化了测试,允许使用最小的“假”实现,只关注所需的接口方法。 Go语言的隐式接口满足机制使得这种方法无缝可行。这种模式可以促进更清晰的意图,减少不必要的依赖,并使代码更能抵抗变化,符合Go语言中将接口本地化到使用和测试环境的常见做法。

Hacker News 新闻 | 过去 | 评论 | 提问 | 展示 | 招聘 | 提交 登录 重访 Go 中的接口隔离原则 (rednafi.com) 5 分,作者 ingve 2 小时前 | 隐藏 | 过去 | 收藏 | 1 条评论 et1337 0 分钟前 [–] 在 $WORK,我们已经将接口隔离推向了极致。例如,假设我们有一个数据访问对象,被许多不同的包使用。与其定义一个单一的接口并在生产者端进行模拟,该接口可以被所有这些包重用,不如每个包定义自己的最小接口,只包含它需要的方法,并对应一个模拟对象。这使得追踪执行流程变得极其困难,并将一个简单的函数签名更改变成了一个耗时一小时的模拟对象重新生成的苦差事。回复 考虑申请 YC 的 2026 年冬季批次!申请截止日期为 11 月 10 日 指南 | 常见问题 | 列表 | API | 安全 | 法律 | 申请 YC | 联系 搜索:
相关文章

原文

Object-oriented (OO) patterns get a lot of flak in the Go community, and often for good reason.

Still, I’ve found that principles like SOLID, despite their OO origin, can be useful guides when thinking about design in Go.

Recently, while chatting with a few colleagues new to Go, I noticed that some of them had spontaneously rediscovered the Interface Segregation Principle (the “I” in SOLID) without even realizing it. The benefits were obvious, but without a shared vocabulary, it was harder to talk about and generalize the idea.

So I wanted to revisit ISP in the context of Go and show how small interfaces, implicit implementation, and consumer-defined contracts make interface segregation feel natural and lead to code that’s easier to test and maintain.

Clients should not be forced to depend on methods they do not use.

— Robert C. Martin (SOLID, interface segregation principle)

Or, put simply: your code shouldn’t accept anything it doesn’t use.

Consider this example:

type FileStorage struct{}

func (FileStorage) Save(data []byte) error {
    fmt.Println("Saving data to disk...")
    return nil
}

func (FileStorage) Load(id string) ([]byte, error) {
    fmt.Println("Loading data from disk...")
    return []byte("data"), nil
}

FileStorage has two methods: Save and Load. Now suppose you write a function that only needs to save data:

func Backup(fs FileStorage, data []byte) error {
    return fs.Save(data)
}

This works, but there are a few problems hiding here.

Backup takes a FileStorage directly, so it only works with that type. If you later want to back up to memory, a network location, or an encrypted store, you’ll need to rewrite the function. Because it depends on a concrete type, your tests have to use FileStorage too, which might involve disk I/O or other side effects you don’t want in unit tests. And from the function signature, it’s not obvious what part of FileStorage the function actually uses.

Instead of depending on a specific type, we can depend on an abstraction. In Go, you can achieve that through an interface. So let’s define one:

type Storage interface {
    Save(data []byte) error
    Load(id string) ([]byte, error)
}

Now Backup can take a Storage instead:

func Backup(store Storage, data []byte) error {
    return store.Save(data)
}

Backup now depends on behavior, not implementation. You can plug in anything that satisfies Storage, something that writes to disk, memory, or even a remote service. And FileStorage still works without any change.

You can also test it with a fake:

type FakeStorage struct{}

func (FakeStorage) Save(data []byte) error         { return nil }
func (FakeStorage) Load(id string) ([]byte, error) { return nil, nil }

func TestBackup(t *testing.T) {
    fake := FakeStorage{}
    err := Backup(fake, []byte("test-data"))
    if err != nil {
        t.Fatal(err)
    }
}

That’s a step forward. It fixes the coupling issue and makes the tests free of side effects. However, there’s still one issue: Backup only calls Save, yet the Storage interface includes both Save and Load. If Storage later gains more methods, every fake must grow too, even if those methods aren’t used. That’s exactly what the ISP warns against.

The above interface is too broad. So let’s narrow it to match what the function actually needs:

type Saver interface {
    Save(data []byte) error
}

Then update the function:

func Backup(s Saver, data []byte) error {
    return s.Save(data)
}

Now the intent is clear. Backup only depends on Save. A test double can just implement that one method:

type FakeSaver struct{}

func (FakeSaver) Save(data []byte) error { return nil }

func TestBackup(t *testing.T) {
    fake := FakeSaver{}
    err := Backup(fake, []byte("test-data"))
    if err != nil {
        t.Fatal(err)
    }
}

The original FileStorage still works fine:

fs := FileStorage{}
_ = Backup(fs, []byte("backup-data"))

Go’s implicit interface satisfaction makes this less ceremonious. Any type with a Save method automatically satisfies Saver.

This pattern reflects a broader Go convention: define small interfaces on the consumer side, close to the code that uses them. The consumer knows what subset of behavior it needs and can define a minimal contract for it. If you define the interface on the producer side instead, every consumer is forced to depend on that definition. A single change to the producer’s interface can ripple across your codebase unnecessarily.

From Go code review comments:

Go interfaces generally belong in the package that uses values of the interface type, not the package that implements those values. The implementing package should return concrete (usually pointer or struct) types: that way, new methods can be added to implementations without requiring extensive refactoring.

This isn’t a strict rule. The standard library defines producer-side interfaces like io.Reader and io.Writer, which is fine because they’re stable and general-purpose. But for application code, interfaces usually exist in only two places: production code and tests. Keeping them near the consumer reduces coupling between multiple packages and keeps the code easier to evolve.

You’ll see this same idea pop up all the time. Take the AWS SDK, for example. It’s tempting to define a big S3 client interface and use it everywhere:

type S3Client interface {
    PutObject(
        ctx context.Context,
        input *s3.PutObjectInput,
        opts ...func(*s3.Options)) (*s3.PutObjectOutput, error)

    GetObject(
    	ctx context.Context,
    	input *s3.GetObjectInput,
    	opts ...func(*s3.Options)) (*s3.GetObjectOutput, error)

    ListObjectsV2(
    	ctx context.Context,
    	input *s3.ListObjectsV2Input,
    	opts ...func(*s3.Options)) (*s3.ListObjectsV2Output, error)

    // ...and many more
}

Depending on such a large interface couples your code to far more than it uses. Any change or addition to this interface can ripple through your code and tests for no good reason.

For example, if your code uploads files, it only needs the PutObject method:

func UploadReport(ctx context.Context, client S3Client, data []byte) error {
    _, err := client.PutObject(
        ctx,
        &s3.PutObjectInput{
            Bucket: aws.String("reports"),
            Key:    aws.String("daily.csv"),
            Body:   bytes.NewReader(data),
        },
    )
    return err
}

But accepting the full S3Client here ties UploadReport to an interface that’s too broad. A fake must implement all the methods just to satisfy it.

It’s better to define a small, consumer-side interface that captures only the operations you need. This is exactly what the AWS SDK doc recommends for testing.

To support mocking, use Go interfaces instead of concrete service client, paginators, and waiter types, such as s3.Client. This allows your application to use patterns like dependency injection to test your application logic.

Similar to what we’ve seen before, you can define a single method interface:

type Uploader interface {
    PutObject(
        ctx context.Context, input *s3.PutObjectInput, opts ...func(*s3.Options),
    ) (*s3.PutObjectOutput, error)
}

And then use it in the function:

func UploadReport(ctx context.Context, u Uploader, data []byte) error {
    _, err := u.PutObject(
        ctx,
        &s3.PutObjectInput{
            Bucket: aws.String("reports"),
            Key:    aws.String("daily.csv"),
            Body:   bytes.NewReader(data),
        },
    )
    return err
}

The intent is obvious: this function uploads data and depends only on PutObject. The fake for tests is now tiny:

type FakeUploader struct{}

func (FakeUploader) PutObject(
    _ context.Context,
    _ *s3.PutObjectInput,
    _ ...func(*s3.Options)) (*s3.PutObjectOutput, error) {
    return &s3.PutObjectOutput{}, nil
}

If we distill the workflow as a general rule of thumb, it’d look like this:

Insert a seam between two tightly coupled components by placing a consumer-side interface that exposes only the methods the caller invokes.

Fin!

~~~

联系我们 contact @ memedata.com