Go Interfaces as Requirements, not Capabilities
This is a hard-won lesson that's taken me many years to learn, and I have seen others follow a similar path, so I'm writing it down in hopes that maybe some people will find a shortcut.
Go's type system is, in my experience, fairly unique. There's a family of object-oriented type systems that's fairly common in C++, Java, PHP, Python, Ruby and others. These have the idea of subclassing—following the entirely-overused analogy of Linnean taxonomy, that a Dog is a Mammal is an Animal. They tend to have the concept, either as keyword or convention, of "protected" or "private" methods, implementation details of a class. They often have some kind of interface or abstract class that defines some kind of API (though some, like Ruby, tend to lean toward duck typing).
Go, superficially, resembles these object-oriented languages. It has the keyword interface
! You can "embed" a struct, and isn't that basically subclassing?
No. It is, deeply, not.
Where Java-esque Dependency Inversion Failed
But this superficial similarity lured me in. I tried to apply the textbook, Java-centric, version of the "dependency inversion principle." And that did lead to improvements—but very much a local maxima.
// duck_interface.go
package duckterface
type Duck interface {
Quack() string
}
// makes_duck.go
package makes_duck
import "duckterface"
type duck struct {
Q string
}
func New(q string) duckterface.Duck {
return &duck{
Q: q,
}
}
func (d *duck) Quack() string {
return fmt.Sprintf("QUACK! %s!", d.Q)
}
// needs_duck.go
package needs_duck
import (
"duckterface"
"fmt"
)
func Bread(d duckterface.Duck) {
fmt.Println(d.Quack())
}
The cracks started to show, however, when it came to bigger interfaces. Big interfaces are an antipattern for a good reason. When it comes to testing, duckterface.Duck
is a fairly small interface to implement:
package needs_duck_tests
// this is OK
type mockDuck struct {}
func (*mockDuck) Quack() string {
return "Quack!"
}
func TestBread(t *testing.T) {
md := &mockDuck{}
actual := needs_duck.Bread(md)
// ...
But something like a repository, that may provide several methods, like Create, Get, List, Update, Delete becomes onerous:
package baserepository
type Repository interface {
CreateWidget(*Widget) error
GetWidget(id int64) (*Widget, error)
ListWidget() ([]Widget, error)
UpdateWidget(id int64, Thing1) error
DeleteWidget(id int64) error
CreateDoodad(*Doodad) error
// ... etc ...
Mocking this becomes painful quickly. We turn to tools that generate complicated mock code. Those mocks, in turn, require their own documentation and learning curve. I prefer the mockDuck
from before—simple, comprehensible, and only a couple of lines of code.
Dependency Inversion for Go
Over time, I've learned that a Go-specific version of dependency inversion scales even better. One where, rather than using interfaces to declare what functionality something provides, you use small, locally defined interfaces to declare what something needs.
Consider a piece of business logic that creates Widget
s. It does some validation, checks for uniqueness, and sets some defaults. It needs something that implements GetWidget
and CreateWidget
:
// not exported--this is only for defining our requirements
type widgetGetCreator {
GetWidget(id int64) (*Widget, error)
CreateWidget(*Widget) error
}
type WidgetUseCase struct {
Widgets widgetGetCreator
}
The Repository
type from above clearly implements this widgetGetCreator
(a name inspired by io.ReadCloser
) interface, so we can use anything that is a Repository
(even if we changed that into a struct!) for WidgetUseCase.Widgets
. And in tests, we have a much smaller interface to implement.
type mockWGC struct {
widget *Widget
err error
}
func (m *mockWGC) GetWidget(int64) (*Widget, error) {
return m.widget, m.err
}
func (m *mockTGC) CreateWidget(*Widget) error {
return m.err
}
This tiny struct also implements widgetGetCreator
, but it does not implement Repository
. With mockWGC
, we can write tests that are very self-contained:
func TestWidgetUseCase_FailsOnDuplicate(t *testing.T) {
// Arrange
mwgc := &mockWGC{widget: Widget{}}
creator := WidgetUseCase{Widgets: mwgc}
// Act
err := creator.MakeWidget(&Widget{})
// Assert
if err == nil {
t.Fail()
}
}
We can control exactly what is returned, reducing the system under test to a single unit of code. And we can do so in the confines of a single test: everything we need to know about this behavior is contained within the test.
Using interfaces this way even allows us to skip around. When I'm writing code in Go, it's pretty common for me to run into a situation where I realize I need something—say I'm building a WebFinger handler, and realize I need something to fetch a user definition:
type userGetter interface {
GetUser(context.Context, string) (*models.User, error)
}
type Handler struct {
Users userGetter
}
func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
u, err := h.Users.GetUser(r.Context(), r.URL.Query().Get("resource"))
// ... etc ...
}
While writing the code, I can ignore all implementation details of that userGetter
. It is enough to know I will have a userGetter
. I can test this code. I can write simple implementations, and see that things fail correctly:
package repository
type UserRepo struct{}
func (*UserRepo) GetUser(context.Context, string) (*models.User, error) {
return nil, errors.New("not implemented yet")
}
This lets me limit and encapsulate where I might be hard-coding things, while still building for a more general future. And I can move on to the areas I want to think about next, rather than letting the dependency graph force me onto a particular path. It's also easier to have multiple team members contribute, since we can parallelize and encapsulate the work.
And the Exception: Libraries
The one major exception I have discovered is in libraries. Including interface definitions makes it easier for consumers to not need to repeat the interface.
For example, a SQL library I worked on exposed an interface like this:
type DB interface {
Get(context.Context, any, string, ...any) error
Select(context.Context, any, string, ...any) error
Exec(context.Context, string, ...any) error
Begin(context.Context) error
Commit(context.Context) error
Rollback(context.Context) error
}
In this case, the library provides the interface because otherwise every consumer would need to define it as their own requirement. The interface is still small enough to mock by hand, but having a common definition is convenient.