Of Ducks and Go Interface Misuse

Of Ducks and Go Interface Misuse

Bart Bucknill
Bart Bucknill

May 10, 2022

"If it walks like a duck and quacks like a duck, it is a duck"

Go interfaces are satisfied by any type which has the same set of methods. This means that if a function requires an interface which implements the Quack() and Walk() methods, any type which implements these two methods will do. The underlying type could vary, and there could be other methods present. This flexibility in some languages takes its name from the Duck test, and is known as Duck Typing; although similar for practical purposes, the typing used in Go is known as Structural Typing.

The interchangeability of interfaces is very useful. Used right, it allows code to be flexible by supporting multiple implementations. Used wrong, creating different implementations is difficult, packages are coupled, and creating test mocks is complex. Unfortunately such a misuse of Go interfaces is something I've seen in more than one code base.

This blog will start with a simple example using concrete types, and then examine a common anti-pattern as we attempt to reimplement our example using interfaces. Finally, the last example will look at how to use interfaces right, and the advantages this brings. Let's get started!

Using Concrete Types and No Interfaces

Imagine you are writing a library that needs to store blobs of data. The initial requirements are that this data be stored in AWS S3.

The simplest approach is to not use an interface at all. This approach can lead to code that is easier to read and navigate than when interfaces are used. In this case, with only one object store to support, you decide to go for the simple approach.

You begin by creating the awss3 package. Although the Data Processing package only needs the Upload method, you also implement the Get method, as you need it elsewhere.

package awss3
import "fmt"

type AWSS3 struct {
				//AWS S3 client and configs go here
}

func (s3 *AWSS3) Upload(data string) {
				fmt.Println("Uploading to S3!")
}

func (s3 *AWSS3) Get(id string) string {
				//Get and return blob from S3
				return "S3 Data"
}

You then import the AWS S3 package into the processor package where it is used.

package processor
import "fmt"
import (
				"awss3" "main/awss3"
)

func Process(awsUploader *awss3.AWSS3) {
				//...does important data processing things before uploading
				awsUploader.Upload("upload me!")
}

As you are writing tests for this, you realise that you have to use the concrete implementation that uploads data to S3. So you go through the whole dance of making sure that the environment running the tests provides access to S3, and you write an integration test that validates data is written there correctly — more complexity than you were hoping for in the tests, but manageable.

As the processor package grows in complexity and reaches hundreds of lines of code, it becomes challenging for other developers to know what methods from the awss3 package it uses.

This solution continues to work perfectly well until one day, you hear that in order to comply with the data at rest requirements of a new national market, you will need to support an entirely new data storage provider. Or maybe the VP of Engineering wants your platform to be "cloud agnostic." Whatever the case, you are going to need to make the processor package support multiple storage providers, and you can't do this using concrete types. "Isn't this exactly what Go interfaces are for?" you ask yourself.

❌ Antipattern: Interface Definition in the Implementation Package

The first approach many developers take is influenced by how interfaces are used in languages such as Java. However, there are a few key differences in Go that will lead to a common antipattern, as this example will soon show.

You start by refactoring the AWS S3 package to export an interface. Something seems slightly off (is this interface even needed here?), but you need to get it done, so ignore the slight sense of unease and power onward.

package awss3
import "fmt"

type Uploader interface {
				Get(string) string
				Upload(string)
}

type AWSS3 struct {
				//AWS S3 client and configs go here
}

func (s3 *AWSS3) Get(id string) string {
				//Get and return blob from S3
				return "S3 Data"
}

func (s3 *AWSS3) Upload(data string) {
				fmt.Println("Uploading to S3!")
}

You import this interface into the processor package.

package processor
import "fmt"
import (
	awss3 "main/awss3"
)

func Process(uploader awss3.Uploader) {
	//...does important data processing things before uploading
	uploader.Upload("upload me!")
}

Finally, you implement the first new storage provider in the googlecloudstorage package. The asymmetry between the awss3 package (which defines an interface) and the Google Cloud package (which does not) is already bothering you; but you really know something is wrong when you have to implement a Get method in the Google package, which is not used by Process, just in order to satisfy the AWS interface.

package googlecloudstorage
import "fmt"

type GCStorage struct {
				//Google cloud storage client and configs go here
}

func (gcs *GCStorage) Upload(data string) {
				fmt.Println("Uploading to GCS!")
}

func (gcs *GCStorage) Get(id string) string {
				fmt.Println("GCStorage.Get THIS IS NOT NEEDED 😞")
				return ""
}

You were looking forward to getting rid of your slow integration tests, and implementing a simple spy to replace concrete implementations of the data storage packages, but your moment of triumph on deleting the many lines of setup and tear down code is marred by the ugliness of needing an unused Get method in the mock.

package processor
import "fmt"

type MockUploader struct {
				uploadedData []string
}

func (mu *MockUploader) Upload(data string) {
				mu.uploadedData = append(mu.uploadedData, data)
}

func (mu *MockUploader) Get(id string) string {
				fmt.Println("MockUploader.Get THIS IS NOT NEEDED 😞")
				return ""
}

Clearly the pattern you've ended up with is not living up to all that you'd been led to expect from Go interfaces. After a little reading, you refactor everything one last time.

✅ Interface Defined Where It's Used

This time, you define the interface in the processor package, where it's actually used. All that Process now needs is something that implements the Upload method — it doesn't matter where the implementation sends the data, even if it's straight to /dev/null.


package processor

type Uploader interface {
				Upload(string)
}

func Process(uploader Uploader) {
				//...does important data processing things before uploading
				uploader.Upload("upload me!")
}

Now the Google Cloud package only needs to implement Upload, so you can delete that unneeded Get.

package googlecloudstorage
import "fmt"

type GCStorage struct {
				// Google cloud storage client and configs go here
}

func (gcs *GCStorage) Upload(data string) {
				fmt.Println("Uploading to GCS!")
}

The same applies to your tests, where the mock now only needs to implement the Upload method.


type MockUploader struct {
				uploadedData []string
}

func (mu *MockUploader) Upload(data string) {
				mu.uploadedData = append(mu.uploadedData, data)
}

It doesn't matter that the awss3 package has an additional Get method: so long as it has the Upload method, the Uploader interface will be satisfied.

package awss3
import "fmt"

type AWSS3 struct {
				// AWS S3 client and configs go here
}

func (s3 *AWSS3) Upload(data string) {
				fmt.Println("Uploading to S3!")
}

func (s3 *AWSS3) Get(id string) string {
				// Get and return blob from S3
				return "S3 Data"
}

Now in whichever executable uses the data processor, either of these implementations can be injected.

package main
import (
				"main/awss3"
				"main/googlecloudstorage"
				"main/processor"
)
func main() {
				processor.Process(&googlecloudstorage.GCStorage{})
				processor.Process(&awss3.AWSS3{})
}

Happy that you've finally got it right, you sit back to contemplate the elegance and advantages of your solution. 😎

Advantages of Interfaces Used Correctly

Different Implementations

The ability to provide different implementations of the same methods allows great flexibility — as can be seen in the example above, where the single Processing function is able to use a Google Cloud or AWS S3 storage provider without modification. A classic and common example of this is the Stringer interface.

In this post's example, a developer can easily create a new implementation for another storage provider — they only need to ensure that their implementation satisfies the Uploader interface. As the Uploader interface is exported by the data processing package (it's capitalised), it will be included in the package documentation.

Test Mocks

As a responsible developer (or just a human seeking to alleviate the pain of inevitable new features), you of course want your library to be well tested. Happily, using interfaces correctly makes it easy to define a new mock implementation. This allows you the option of avoiding running a specific concrete implementation, which might make network requests leading to complex setup, slow running, and potentially unreliable tests.

Interface Segregation

The "I" in SOLID stands for Interface Segregation, the principle that "Many client-specific interfaces are better than one general-purpose interface." Go interfaces allow us to achieve this by defining an interface in the client that only specifies the methods it actually uses.

In our example, it doesn't matter that the AWSS3 type also implements Get. It still satisfies the Uploader interface, and when we look at the signature of the Process function, we can immediately see that it only uses the Upload method.

Conclusion

At 8th Light we've seen the impact that getting interfaces wrong can have when working on a large Go codebase with many developers. The need to support the requirements of different packages in one definition leads to the addition of many methods to a single, bloated interface. When looking at the client packages, it is not immediately apparent which methods from the massive imported interface they actually use. To mitigate this last problem, new interfaces may be defined for the client package, resulting in the need to update two different interfaces if adding a new method. The difficulty of writing test mocks for huge interfaces often results in the use of mock generators, which are complex in themselves and sometimes break type safety — no one wants to work on tests which are more complex than the implementation itself. I've seen this done in different codebases, despite being explicitly called out in the Go code review comments.

We've also seen interfaces done right — sometimes in the same codebase! With interface definitions in the client package, the code is more flexible, easier to mock, and less coupled. With the interface defined where it is used, the code is easier to understand, as it is immediately apparent which methods are actually required. Complexity is reduced, and developers are happy.