Deep vs Shallow Go interfaces
I recently read A Philosophy of Software Design by John Ousterhout (of Tcl/Tk, Raft, Sprite fame).
One of the core concepts explored in this book is the distinction between “deep” vs “shallow” modules (in the author’s terms a module is any kind of abstraction, separated into the user-facing interface and the underlying implementation).
The author argues that “the best modules are those that provide powerful functionality yet have simple interfaces”. The argument is not about absolute size, but rather the ratio of utility afforded by the abstraction compared to the size of the abstraction itself, in other words, a cost/benefit tradeoff.
In our case, the main mechanism for composable abstractions in Go is the
interface
type, so let’s examine the concept through this lens.

A deep interface
To me, maybe the best example of a deep interface is io.Reader
.
// Reader is the interface that wraps the basic Read method.
//
// Read reads up to len(p) bytes into p. It returns the number of bytes
// read (0 <= n <= len(p)) and any error encountered. Even if Read
// returns n < len(p), it may use all of p as scratch space during the call.
// If some data is available but not len(p) bytes, Read conventionally
// returns what is available instead of waiting for more.
// ...
// Implementations of Read are discouraged from returning a
// zero byte count with a nil error, except when len(p) == 0.
// Callers should treat a return of 0 and nil as indicating that
// nothing happened; in particular it does not indicate EOF.
//
// Implementations must not retain p.
type Reader interface {
Read(p []byte) (n int, err error)
}
It couldn’t possibly get any smaller than that, right? It’s simple enough that you won’t ever need to look it up again. Searching the Go standard library, one will find numerous implementations including reading from files, from network connections, compressors, ciphers and more.
This abstraction is both easy to understand and use; the docstring tells you everything you, as a user, need to know. The underlying implementation can be buffered, may allow reading from streams or remote locations like an S3 bucket. But crucially, consumers of this API don’t need to worry about how reading happens — implementation can be deep and non-trivial, but a user doesn’t have to care. Furthermore, it allows for very little ambiguity when reasoning about what the code does.
All these properties are especially important for core functionality that’s used frequently.
A shallow interface
On the other hand, an example of a shallow interface I’ve used recently is from the redis-go client.
I’ve trimmed it down for the purposes of this post, but you can see it here in its entirety. It contains 45 methods and uses 19 other interfaces as extensions for a total of ~200 methods.
type Cmdable interface {
Pipeline() Pipeliner
Pipelined(ctx context.Context, fn func(Pipeliner) error) ([]Cmder, error)
TxPipelined(ctx context.Context, fn func(Pipeliner) error) ([]Cmder, error)
TxPipeline() Pipeliner
Command(ctx context.Context) *CommandsInfoCmd
CommandList(ctx context.Context, filter *FilterBy) *StringSliceCmd
CommandGetKeys(ctx context.Context, commands ...interface{}) *StringSliceCmd
CommandGetKeysAndFlags(ctx context.Context, commands ...interface{}) *KeyFlagsCmd
Info(ctx context.Context, section ...string) *StringCmd
LastSave(ctx context.Context) *IntCmd
Save(ctx context.Context) *StatusCmd
Shutdown(ctx context.Context) *StatusCmd
ShutdownSave(ctx context.Context) *StatusCmd
ShutdownNoSave(ctx context.Context) *StatusCmd
...
...
StringCmdable
StreamCmdable
TimeseriesCmdable
JSONCmdable
}
While the functionality provided by Redis is much larger than just ‘reading’, each of these methods has a much simpler implementation; they do exactly one thing, and they’re small enough you could possibly replicate them just by their name and arguments. The ratio of the functionality provided to the size of the abstraction is very different than before.
func (c cmdable) CommandGetKeys(ctx context.Context, commands ...interface{}) *StringSliceCmd {
args := make([]interface{}, 2+len(commands))
args[0] = "command"
args[1] = "getkeys"
copy(args[2:], commands)
cmd := NewStringSliceCmd(ctx, args...)
_ = c(ctx, cmd)
return cmd
}
This also shifts the responsibility of doing the right thing towards the user, as they have to understand the nuances between individual methods. In a code review, this makes it harder to reason about what happens at a glance.
func (c cmdable) Get(ctx context.Context, key string) *StringCmd
func (c cmdable) MGet(ctx context.Context, keys ...string) *SliceCmd
func (c cmdable) Set(ctx context.Context, key string, value interface{}, expiration time.Duration) *StatusCmd
func (c cmdable) SetEX(ctx context.Context, key string, value interface{}, expiration time.Duration) *StatusCmd
func (c cmdable) SetNX(ctx context.Context, key string, value interface{}, expiration time.Duration) *BoolCmd
Comparison
So, is this another post criticizing other dev practices? Not really. As always, things exist on a spectrum and these previous examples show the two extremes.
As a developer, it can often feel more natural to write shallower interfaces. Similar ‘shallow’ examples (that are not strictly interfaces) are the aws-sdk-go’s session Options or Viper’s public API. Why?
- It makes methods smaller and easier to test
- It maps more closely to the mental map of the system itself
- It takes less time to think up-front about how the user will consume it
- Usually, it only affords a single implementation, maybe two, so it’s easier to imagine how it will be used
In contrast, io.Reader
offers additional advantages:
- Can be easily retrofitted to other use cases
- Requires no state checks to use properly
- Interfaces like this tend to remain stable over time, while a shallower version would often grow to accommodate more and more features
- It allows for natural composability into other abstractions, like a
ReadWriter
or aReadCloser
type ReadCloser interface {
Reader
Closer
}
The go-kit/log.Logger and the http.Handler interfaces are prime showcases of these concepts in the real world.
This not a fair comparison, as you will rarely write such core functionality as the Reader from scratch, and well, Cmdable is not an abstraction but rather a driver covering the entirety of Redis operations.
But still, does that client API need five different methods for saving and
shutting down? Does a user of the client need to deal with both running
commands and getting meta-information around the DB connection and runtime
metrics at the same time? Is each of the datatypes different enough to have
their own interface? And do I as a reviewer, need to know beforehand whether
the code needs to Ping
, Echo
or Hello
?
So, next time you design or review an abstraction, take a closer look. How “deep” is your API? In what ways could you mold it into something simpler that hides complexity from the user and reduces cognitive load?
Outro
And that’s all for today! If you have any comments, remarks or ideas, feel free to reach out to me on Bluesky.
What are your favorite interfaces? Any specific one that you think touches the Platonic ideal? Any that disgusts you beyond imagination and makes you wanna quit, move to the countryside and grow tomatoes? Let me know!
Until next time, bye!