Go Interfaces: Why, How and When
By Noam YadgarIn 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.
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)")