Go Interfaces: Design Principles and Testability

Go interfaces are implicit — a type satisfies an interface by implementing its methods, without declaring it. This small design decision has large implications for how code is organized and tested. Sm

Introduction#

Go interfaces are implicit — a type satisfies an interface by implementing its methods, without declaring it. This small design decision has large implications for how code is organized and tested. Small, focused interfaces are idiomatic Go, and understanding why produces significantly more testable code.

Interface Basics#

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
// Interface definition: what can be done
type Storer interface {
    Store(ctx context.Context, key string, data []byte) error
    Load(ctx context.Context, key string) ([]byte, error)
    Delete(ctx context.Context, key string) error
}

// Implementation: any type with these methods satisfies Storer
type S3Store struct {
    client *s3.Client
    bucket string
}

func (s *S3Store) Store(ctx context.Context, key string, data []byte) error {
    _, err := s.client.PutObject(ctx, &s3.PutObjectInput{
        Bucket: &s.bucket,
        Key:    &key,
        Body:   bytes.NewReader(data),
    })
    return err
}
// ... Load and Delete implementations

// No explicit "implements" declaration needed
var _ Storer = (*S3Store)(nil)  // compile-time check

Small Interfaces: The Go Idiom#

The Go standard library models this well:

1
2
3
4
5
6
// io package: tiny, composable interfaces
type Reader interface { Read(p []byte) (n int, err error) }
type Writer interface { Write(p []byte) (n int, err error) }
type Closer interface { Close() error }
type ReadWriter interface { Reader; Writer }  // composed
type ReadWriteCloser interface { Reader; Writer; Closer }
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// BAD: large interface forces implementers to provide many methods
type UserRepository interface {
    GetUser(id int) (*User, error)
    CreateUser(u *User) error
    UpdateUser(u *User) error
    DeleteUser(id int) error
    ListUsers() ([]*User, error)
    GetUserByEmail(email string) (*User, error)
    CountUsers() (int, error)
    // ... 10 more methods
}

// GOOD: small interfaces; compose as needed
type UserGetter interface {
    GetUser(ctx context.Context, id int) (*User, error)
}
type UserCreator interface {
    CreateUser(ctx context.Context, u *User) error
}
// Service only depends on what it actually uses
type OrderService struct {
    users UserGetter  // not the full UserRepository
}

Accept the minimum interface required; return concrete types.

Interfaces for Testability#

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
// Without interface: tightly coupled to real HTTP calls
type WeatherService struct{}
func (w *WeatherService) GetTemperature(city string) (float64, error) {
    resp, _ := http.Get("https://api.weather.com/" + city)
    // ... parse response
    return 22.5, nil
}

// Test would make real HTTP calls — slow, unreliable

// With interface: easy to mock
type WeatherProvider interface {
    GetTemperature(ctx context.Context, city string) (float64, error)
}

type AlertService struct {
    weather WeatherProvider
    threshold float64
}

func (a *AlertService) CheckHeatAlert(ctx context.Context, city string) (bool, error) {
    temp, err := a.weather.GetTemperature(ctx, city)
    if err != nil {
        return false, err
    }
    return temp > a.threshold, nil
}

// Test: inject a mock
type mockWeather struct {
    temp float64
    err  error
}
func (m *mockWeather) GetTemperature(_ context.Context, _ string) (float64, error) {
    return m.temp, m.err
}

func TestCheckHeatAlert(t *testing.T) {
    tests := []struct {
        name      string
        temp      float64
        threshold float64
        want      bool
    }{
        {"above threshold", 35.0, 30.0, true},
        {"below threshold", 20.0, 30.0, false},
        {"at threshold", 30.0, 30.0, false},
    }

    for _, tc := range tests {
        t.Run(tc.name, func(t *testing.T) {
            svc := &AlertService{
                weather:   &mockWeather{temp: tc.temp},
                threshold: tc.threshold,
            }
            got, err := svc.CheckHeatAlert(context.Background(), "London")
            if err != nil {
                t.Fatal(err)
            }
            if got != tc.want {
                t.Errorf("got %v, want %v", got, tc.want)
            }
        })
    }
}

Interface Composition for Capabilities#

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
// Build up interfaces through composition
type Reader interface {
    Read(ctx context.Context, id string) (*Record, error)
}

type Writer interface {
    Write(ctx context.Context, r *Record) error
}

type Deleter interface {
    Delete(ctx context.Context, id string) error
}

type ReadWriter interface { Reader; Writer }
type Store interface { Reader; Writer; Deleter }

// Functions accept the minimum needed
func fetchAndProcess(ctx context.Context, r Reader, id string) error {
    record, err := r.Read(ctx, id)
    // ...
    return nil
}

// Accepts Store because it both reads and deletes
func archiveRecord(ctx context.Context, s Store, id string) error {
    record, _ := s.Read(ctx, id)
    archive(record)
    return s.Delete(ctx, id)
}

Interface Satisfaction at Compile Time#

Use blank identifier assignments to verify interface satisfaction without runtime overhead:

1
2
3
4
5
6
// Compile-time check: will fail to compile if S3Store doesn't implement Storer
var _ Storer = (*S3Store)(nil)
var _ Storer = (*LocalStore)(nil)

// In tests: verify mocks implement interfaces
var _ WeatherProvider = (*mockWeather)(nil)

When Not to Use Interfaces#

1
2
3
4
5
6
7
8
9
10
// Don't create interfaces speculatively for a single implementation
// UNNECESSARY:
type UserServiceInterface interface {
    GetUser(id int) (*User, error)
}
type UserService struct{}
// Only one implementation exists; the interface adds no value

// Add the interface when you have a second implementation
// (production + test mock, or two real backends)

The Go proverb: “Don’t design with interfaces, discover them.” Start with concrete types; extract interfaces when you have two or more implementations that should be interchangeable.

Conclusion#

Define interfaces at the consumer, not the producer. Keep them small — one or two methods. Use them to express what a component needs, not what a component provides. This approach produces loosely coupled, highly testable code where any component can be replaced with a test double that satisfies the interface.

Contents