“Accept interfaces, return structs” in Go

Bryan F Tan
5 min readAug 25, 2021

Say goodbye to preemptive interfaces

In this post I will attempt to explain the thought process behind accepting interfaces and returning structs in Go. As a young Go developer, this was something I had trouble wrapping my mind around. Hopefully this article will help others who are also starting out on their Go journey.

*This post assumes some basic knowledge of Go syntax and interfaces.

Setting the scene

(Producers and Consumers)

First and foremost, where is this phrase applicable? It all boils down to the interaction between a producer package and a consumer package. Producers provide some service and the consumer uses it. This sort of interaction is common since we usually organise code into packages of different responsibilities. Package consumers will then have dependencies on external packages for some functionality.

One example of this is between our business logic and database layer. Let’s use a simple demonstration that will follow us throughout this article.

├── db
│ └── db.go
└── user
└── user.go

In the db package, db.go provides some persistent storage functionality. In the user package, user.go contains some business logic we want to handle with the user. Here, the user package will be the consumer, using the stateful services provided by the db package.

Accepting interfaces

(Let the consumer define the interfaces it uses)

To make things easier to digest, we will break the phrase in half. Accepting interfaces has to do with the consumer package. It is saying that the consumer should receive its dependency as an interface. An interface that it has defined itself.

Let’s look at our example:

//db.go
package
db
type Store struct {
db *sql.DB
}
func NewDB() *Store { ... } //func to initialise DBfunc (s *Store) Insert(item interface{}) error { ... } //insert itemfunc (s *Store) Get(id int) error { ... } //get item by id

Here db.go simply provides some methods for insert and read.

//user.go
package user
type UserStore interface {
Insert(item interface{}) error
Get(id int) error
}
type UserService struct {
store UserStore
}
// Accepting interface here!
func NewUserService
(s UserStore) *UserService {
return &UserService{
store: s,
}
}
func (u *UserService) CreateUser() { ... }func (u *UserService) RetrieveUser(id int) User { ... }

The consumer user.go requires a storage dependency in order to carry out the user-related business logic. It does not care what the storage is, just that it has 2 methods, Insert() and Get() so it is able to create and retrieve a user. Hence, it defines its own an interface UserStore and accepts it as a dependency. The Store struct in db.go implements this interface and thus can be used as a dependency.

It’s as simple as that! Accepting Interfaces is all about letting the consumer define what they want in an interface. The consumer should not be worried about what the dependency is, just that it can perform the tasks the consumer needs. This is also possible due the implicit nature of Go inheritance. Doing so brings about some benefits:

  • Looser coupling, greater flexibility

By accepting interfaces, consumers are not coupled with their dependencies. If tomorrow I decide to go with MySQL instead of Postgres, user.go would not need to be changed at all. This retains the flexibility of using any storage as long as it satisfies the consumer defined interface.

  • Easier testing

Testing would also be made simpler as we can easily pass in an in-memory mock without having to spin up an actual db instance which could be expensive just for the sake of unit testing. We can just have a mock in-memory store with the appropriate data needed for our test cases.

//user_test.gofunc TestCreateUser(t *testing.T) {
s := new(inMemStore) //use some in-memory store...
service := NewUserService(s)

//... test the CreateUser() function
}

Return Structs

(Producers return concrete types)

The other half, Returning structs, is much easier to comprehend. It is saying that producers should provide concrete types to consumers instead of an interface. This actually makes sense. If I am a package consumer and calling a function that creates a concrete type, FooI am probably interested in calling one or more methods that are specific to that type. If NewFoo() returns an interface, the client code would have to manually cast it to Foo so that it can invoke the Foo- specific methods; this would defeat the purpose of returning an interface in the first place.

In our example, NewDB() returns a concrete type to consumers.

//db.go
package
db
type Store struct {
db *sql.DB
}
// returning concrete type here!
func NewDB
() *Store { ... }
func (s *Store) Insert(item interface{}) error { ... }func (s *Store) Get(id int) error { ... }

Furthermore, returning concrete types allows for new methods to be added to implementations without requiring extensive refactoring.

Bad example: preemptive interfaces

Now that we have seen the good example, let’s turn to an anti-pattern that is commonly made.

//postgres.go
package
db

type Store interface {
Insert(item interface{}) error
Get(id int) error
}

type MyStore struct {
db *sql.DB
}

func InitDB() Store { ... } //func to initialise DB
func
(s *Store) Insert(item interface{}) error { ... } //insert item
func
(s *Store) Get(id int) error { ... } //get item by id
//user.go
package
user

type UserService struct {
store db.Store
}

func NewUserService
(s db.Store) *UserService {
return &UserService{
store: s,
}
}
func (u *UserService) CreateUser() { ... }
func (u *UserService) RetrieveUser(id int) User { ... }

Notice what has changed? The interface is now defined by the producer and the consumer is using that interface as an entry point. This is known as a preemptive interface. The producer is preemptively defining an interface before it is actually being used.

Without a realistic example of usage, it is too difficult to see whether an interface is even necessary, let alone what methods it ought to contain. Some may argue that having the producer return an interface provides developers focus on the API emitted by the function. However, this is unnecessary in Go; implicit interfaces allows for graceful abstraction after the fact without requiring you to abstract up front.

Preemptive interfaces also makes testing more difficult. Say we had a mock in-memory store for testing user.go. But we add a new function to the interface due the increasing requirements for db.go . Now our mock no longer works as this is a breaking change.

We have shown above that our consumer user.go can reduce its tight dependence on db.go and solve these problems with accepting an interface, and for producer db.go to return structs (concrete types).

Conclusion

Accepting interfaces, return structs may sound so foreign when you first hear it. However, it can be boiled down to 2 main concepts

  1. Let the consumer define the interfaces it uses
  2. Producers should return concrete types

Additional resources

https://github.com/golang/go/wiki/CodeReviewComments#interfaces

--

--

Bryan F Tan

Software Engineer | Interested in Go and distributed systems