“Accept interfaces, return structs” in Go
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 dbtype 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 usertype 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, Foo
I 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 dbtype 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
- Let the consumer define the interfaces it uses
- Producers should return concrete types
Additional resources
https://github.com/golang/go/wiki/CodeReviewComments#interfaces