Back to blog

Sunday, August 16, 2026

How Big Should a Go Interface Be?

Title card for 'How Big Should a Go Interface Be?' with a short code example

How Big Should a Go Interface Be? (Interface Segregation in Practice)

As small as the function that uses it. One method is the usual size. io.Reader is Read. fmt.Stringer is String. A ten-method UserRepository is almost always the producer describing a struct, not the consumer describing a need. If your mock is full of panic("not implemented"), the interface is too big.

The failure

sendWelcome only fetches. The mock still has to pretend to save, list, and delete.

type UserRepository interface {
    Save(User) error
    FindByID(id string) (User, error)
    Update(User) error
    Delete(id string) error
    List() ([]User, error)
}

func sendWelcome(repo UserRepository, id string) error {
    u, err := repo.FindByID(id)
    if err != nil {
        return err
    }
    return mail.Send(u.Email, "welcome")
}

type repoMock struct{}

func (repoMock) Save(User) error            { panic("unused") }
func (repoMock) FindByID(string) (User, error) { return User{}, nil }
func (repoMock) Update(User) error          { panic("unused") }
func (repoMock) Delete(string) error        { panic("unused") }
func (repoMock) List() ([]User, error)      { panic("unused") }

Add Count to UserRepository and every mock in the repo fails to compile. That is the signal.

Why this happens

Go does not require you to declare "this struct implements X" at the type. The consumer can name the methods it calls. A fat interface inverts that: the provider publishes a kitchen-sink contract, and every caller pays for methods they never invoke.

Tests get heavy. Refactors get wide. Two teams copy the same interface with one method different and nothing composes.

The standard library stayed small on purpose. io.Copy needs a Reader and a Writer, not a File. Types that can do both still satisfy both. When you need both at once, you embed:

type ReadWriter interface {
    Reader
    Writer
}

That is still two capabilities, named, not a 20-method blob.

The fix

Write the function against a concrete type first. Extract only the methods it calls. Name the interface after that capability.

type userByID interface {
    FindByID(id string) (User, error)
}

func sendWelcome(repo userByID, id string) error {
    u, err := repo.FindByID(id)
    if err != nil {
        return err
    }
    return mail.Send(u.Email, "welcome")
}

The production PostgresUsers struct can still have Save, List, and Delete. This function never sees them. The test is one method.

Keep a larger interface only where the same call site needs several methods together (a transaction that must Commit and Rollback). Prefer embedding small ones over one growing list.

If a mock method is never called, it does not belong on the interface that test is satisfying.