Menu

Go Interfaces: Why, How and When

By Noam Yadgar
#go #software-engineering #software-design

In programming, interfaces are a powerful concept that lets us express our code more abstractly. Interfaces allow us to reason about the higher-level logic of our processes without getting down to the small details. Go has arguably one of the best interface implementations. With great features like implicit implementations, assertion, and more.

Interfaces must be used cautiously since they introduce more abstraction to our code, making it susceptible to unnecessary wrappers, misuse of definitions, and sometimes, even memory issues. In this article, I will discuss cases where interfaces can positively impact your code. But first, let’s talk about what an interface is not.

Not for hiding code

I’ve encountered a common claim that interfaces let you hide internal code and expose only the relevant details. However, we can also do this by using the exported values feature in Go. Interfaces are less about hiding details for the sake of hiding and more about defining a contract between layers and packages in your system.

interface

Using interfaces

Interfaces let us put together the higher-level logic. For example, we can write a simple function that extracts the string of an fmt.Stringer interface and write its bytes to an io.Writer. This function, on its own, doesn’t do anything but only represents the interaction between two types that satisfy these interfaces:

1func writeStrTo(s fmt.Stringer, w io.Writer) (int, error) {
2	return w.Write([]byte(s.String()))
3}

The Stringer interface, defined in the fmt package is:

1type Stringer interface {
2	String() string
3}

Implementing an interface

Let’s implement a struct that satisfies the Stringer interface:

1type kv struct {
2	key   string
3	value string
4}
5
6func (t kv) String() string {
7	return fmt.Sprintf("%s=\"%s\"\n", t.key, t.value)
8}

Now, let’s write a main function that iterates through a slice of our kv type and appends each element to os.Stdout. Notice that in Go, interfaces are inferred implicitly. In other words, we don’t need to tell the compiler that our kv type acts as an fmt.Stringer when we pass it to the function.

Implementations of interfaces in Go are always passed by reference (pointers)

 1func main() {
 2	kvs := []kv{
 3		{key: "ENV", value: "dev"},
 4		{key: "LEVEL", value: "debug"},
 5	}
 6
 7	for _, t := range kvs {
 8		if _, err := writeStrTo(t, os.Stdout); err != nil {
 9			panic(err)
10		}
11	}
12}

If we run this, we get:

go run main.go
ENV="dev"
LEVEL="debug"

We’re using os.Stdout as our io.Writer parameter. os.Stdout is an *os.File which satisfies the io.Writer interface.

Providing interfaces

You can provide your interfaces for a few good reasons. One good reason is that it lets you supply multiple implementations.

Multiple implementations

Defining an interface in a package is common whenever you have multiple types that adhere to a specific set of methods. This way, you allow the consumer of your package to use the interface inside their logic and plug in an implementation that fits their needs.

For example, in a hypothetical cache package, we can define this interface:

1type Cache interface {
2	Set(k string, v any, opts ...any) error
3	Get(k string) (any, bool) error
4	Delete(k string) error
5	Clear() error
6}

Your package may offer a few different implementations of the Cache interface. One way of providing those types is by exposing constructor functions such as NewDefaultCache, NewLruCache, NewRedisCache, etc. This pattern follows the strong golden rule:

Functions return concrete types and accept interfaces.

flowchart LR
    C[c.NewDefaultCache] -->|*cache| B
    D[c.NewLruCache] --> |*lruCache| B
    A[c.NewRedisCache] -->|*redisCache| B("NewHandler(c.Cache)")

Be agnostic

Have a look at this definition of a message broker, can you spot the problem?

 1import (
 2	"context"
 3
 4	amqp "github.com/rabbitmq/amqp091-go"
 5)
 6
 7type MessageBroker interface {
 8	Send(ctx context.Context, target string, msg amqp.Publishing)
 9	Receive(
10        ctx context.Context,
11        target string,
12        handler func(context.Context, amqp.Delivery) error,
13    )
14}

The problem is that we’re defining an interface that should generally pack the typical behavior of a message broker, but we’re using concrete types of specific technology.

To begin with, we should see if wrapping our message broker with such an abstraction layer is even necessary. A good reason might be that we’re working on a large enterprise-level code base, and we want to design the system such that the underlying technology of our message broker can be easily swapped with a different one without causing any dramatic refactor.

Therefore, it’s better to aim for the most minimal and general definitions. One that has no hints towards any specific technology but provides just enough definition to carry the behavior of any message broker. A perfect interface is never guaranteed, but it will be much easier for concrete types to adopt simple definitions than the other way around. The general rule here is:

Don’t imply specific implementations in your interfaces.

This may not be perfect, but a better one.

 1import (
 2	"context"
 3)
 4
 5type MessageBroker interface {
 6    Sender
 7    Receiver
 8}
 9
10type Sender interface {
11	Send(context.Context, Message) error
12}
13
14type Receiver interface {
15	Receive(context.Context, MessageHandlerFunc) error
16}
17
18type Message interface {
19    Body() []byte
20    Metadata() map[string]any
21}
22
23type MessageHandlerFunc func(context,Context, Message) error
---
  config:
    class:
      hideEmptyMembersBox: true
---
classDiagram
    MessageBroker <|.. rabbitmq
    MessageBroker <|.. sqs
    MessageBroker <|.. kafka
    
    app-1 ..> MessageBroker
    app-2 ..> MessageBroker
    app-n ..> MessageBroker

    class MessageBroker:::intr
    class rabbitmq
    class sqs
    class kafka
    class app-1:::srv
    class app-2:::srv
    class app-n:::srv

    classDef srv fill:#f97,stroke:#d75
    classDef intr fill:#aee,stroke:#d75

Provide a platform

Especially when building a framework of some sort, sometimes you’d like to provide consumers of your package with a definition so that they can implement their logic and plug it back into your process. For example, maybe you wrote a package that manages the lifecycle of an application, and you’re expecting consumers to implement an App interface like:

1type App interface {
2	Init(context.Context) error
3	Run() error
4	Stop() error
5}

To achieve the complete pattern, you may also provide a function that accepts the App interface and performs the necessary lifecycle control flow. Hypothetically, a function like:

 1var Timeout = time.Second * 15
 2
 3func Deploy(app App) {
 4	defer func() {
 5		if err := app.Stop(); err != nil {
 6			panic(err)
 7		}
 8	}()
 9
10	ctx, cancel := context.WithTimeout(
11		context.Background(), Timeout,
12	)
13	defer cancel()
14
15	errchan := make(chan error)
16	go func() {
17		errchan <- app.Init(ctx)
18	}()
19
20	select {
21	case <-ctx.Done():
22		if err := ctx.Err(); err != nil {
23			panic(err)
24		}
25	case err := <-errchan:
26		if err != nil {
27			panic(err)
28		}
29	}
30
31	ch := make(chan os.Signal, 1)
32	signal.Notify(ch, syscall.SIGTERM, syscall.SIGINT)
33	go func() {
34		sig := <-ch
35		errchan <- fmt.Errorf("received signal: %s", sig.String())
36	}()
37
38	go func() {
39		errchan <- app.Run()
40	}()
41
42	if err := <-errchan; err != nil {
43		panic(err)
44	}
45}

Consumers of your package can now call Deploy with their implementation of an App and expect your package to manage their app’s lifecycle.

Please note that the code above is not battle-tested and is used for an educational purpose

Test mocks

One important feature of interfaces is that they allow for better unit testing by implementing a mock whenever the interface is used in your internal code. I’ve seen a few claims about how test mocks are degrading the quality of the tests and how you’re not testing the real logic and interaction.

It’s the other way around. Using test mocks, you can stage your exact test scenarios with absolute control. You’re not relying on a non-deterministic process or the outside environment; you can genuinely unit-test your logic. Let’s implement an App that can produce all failed cases.

 1type appMock struct {
 2	failOnInit,
 3	failOnRun,
 4	failOnStop,
 5	sendInterrupt bool
 6	sleep time.Duration
 7}
 8
 9func (a *appMock) Init(context.Context) error {
10	if a.failOnInit {
11		return fmt.Errorf("App.Init: failed")
12	}
13
14	time.Sleep(a.sleep)
15	return nil
16}
17
18func (a *appMock) Run() error {
19	if a.failOnRun {
20		return fmt.Errorf("App.Run: failed")
21	}
22
23	if a.sendInterrupt {
24		go func() {
25			_ = syscall.Kill(syscall.Getpid(), syscall.SIGINT)
26		}()
27		time.Sleep(time.Millisecond * 500)
28	}
29
30	return nil
31}
32
33func (a *appMock) Stop() error {
34	if a.failOnStop {
35		return fmt.Errorf("App.Stop: failed")
36	}
37
38	return nil
39}

Our Deploy function is a good example of a relatively complex function. It uses panic (a rare thing to do in Go), channels, and a timeout. Those will be hard to test if we haven’t had our test mock. Since we have created a mock that can produce all cases, our unit test can look like this:

 1func TestDeploy_happyFlow(*testing.T) {
 2	Deploy(&appMock{})
 3}
 4
 5func TestDeploy_panic(t *testing.T) {
 6	tc := []struct {
 7		name string
 8		app  App
 9		msg  string
10	}{
11		{
12			name: "panic on Init",
13			app:  &appMock{failOnInit: true},
14			msg:  "App.Init: failed",
15		},
16		{
17			name: "panic on Init timeout",
18			app:  &appMock{sleep: time.Second},
19			msg:  "context deadline exceeded",
20		},
21		{
22			name: "panic on Run",
23			app:  &appMock{failOnRun: true},
24			msg:  "App.Run: failed",
25		},
26		{
27			name: "panic on Stop",
28			app:  &appMock{failOnStop: true},
29			msg:  "App.Stop: failed",
30		},
31		{
32			name: "panic on SIGINT",
33			app:  &appMock{sendInterrupt: true},
34			msg:  "received signal: interrupt",
35		},
36	}
37
38	Timeout = time.Millisecond * 500
39	for _, tt := range tc {
40		t.Run(tt.name, func(t *testing.T) {
41			testDeployWithPanic(tt.app, tt.msg, t)
42		})
43	}
44}
45
46func testDeployWithPanic(app App, msg string, t *testing.T) {
47	defer func() {
48		r := recover()
49		if r == nil {
50			t.Errorf("expected a panic")
51			return
52		}
53
54		err, ok := r.(error)
55		if !ok {
56			t.Errorf("expected error in panic")
57			return
58		}
59
60		if err.Error() != msg {
61			t.Errorf("error returned unexpected message: %s", err.Error())
62		}
63	}()
64
65	Deploy(app)
66}

Mock libraries

Test mocks are so common that some of Go’s most popular open-source packages are dedicated to their automatic creation. One example is Uber’s mockgen and gomock . A set of tools that automatically generates test mocks from any interface and integrates them into your unit tests.

Let’s generate a mock for our Cache interface:

mockgen -package internal -source ./cache.go -destination mocks.go

The command above takes our Cache interface, stored in cache.go and outputs a new file, named mocks.go, with our Cache mock under the internal packages. In our tests, we can use the mock like the following:

 1func TestHandler(t *testing.T) {
 2    // spawn a new mock instance
 3	ctl := gomock.NewController(t)
 4	cache := NewMockCache(ctl)
 5
 6    // "stage" its expected calls and returns
 7	cache.EXPECT().Get("test").Return(1)
 8
 9    // test your internal logic... 
10    handler := NewHandler(cache)
11    if err := handler.Get("test"); err != nil {
12        t.Errorf(err)
13    }
14}

Slice out a definition

Sometimes, a third-party package may provide a concrete type (usually a client of some service), and to write proper unit tests, you will provide an interface that slices out the set of used methods from this type. Essentially, defining an interface that the third-party type will satisfy.

For example, testing internal logic that uses the s3.Client from AWS Go SDK will require a connection to a real S3-compatible bucket and will rely on the side effects of this bucket. This will completely miss the point of unit testing, which is supposed to be deterministic and independent of the environment.

The solution is to define an interface that can fit the s3.Client and use this interface instead. The s3.Client has about 192 methods (true to version 1.50.0), so defining an interface that fits all of this type is tedious and unnecessary. Instead, you can slice out only the methods you’re using from the s3.Client.

Maybe you only need the s3.Client to call GetObject. You can define your interface as follows:

1type S3Client interface {
2    GetObject(
3        context.Context, 
4        *s3.GetObjectInput, 
5        ...func(*s3.Options),
6    ) (*s3.GetObjectOutput, error)
7}

This interface completely contradicts my claim of being agnostic. In this pattern we’re not trying to abstract the behavior of any storage service, but we’re explicit about our intentions of using S3 exclusively. Defining an abstract storage service interface would result in implementing a wrapper to the s3.Client, another layer of abstraction that might be unnecessary if we’re not planning on replacing S3 in our system.

By defining and consuming the interface above, we can now easily generate a test mock and write proper unit tests for our internal logic, interacting with the mock as if we’re communicating via a real S3 client.

flowchart LR
    A["s3.NewFromConfig(sdkConfig)"] -->|*s3.Client| B("myFunction(S3Client)")