Go Practical Basics - Standard Library Interfaces
This is a series of posts about Go basics with a focus on practical real-world examples. To read more in this series, click here.
Go’s duck-typed interfaces are one of the languages most powerful features. The promotion of small, composable interfaces over large ones leads to flexible code that is easy to refactor, test and maintain.
But this concept is not always apparent to new users of Go, especially if you’ve come from languages where interfaces are declared to be satisfied. Go’s interfaces are satisfied implicitly.
A quick example
Imagine we create a new package the configure the standard library log/slog
package for structured logging in the pkg/logger
folder and add a file called logger.go:
// myproject/pkg/logger/logger.go
package logger
import (
"log/slog"
"os"
)
func NewLogger(serviceName string) *slog.Logger {
handler := slog.NewJSONHandler(os.Stdout, nil)
logger := slog.New(handler)
return logger.With("service", serviceName)
}
This is a standard pattern in Go. You take a type from the standard library or a third-party library, add the configuration you need and return it to your application ready to go.
But this code has a dependency with os.Stdout
that makes this function rigid. This may be OK for our needs when developing this library but in future we may want to change the destination of the logs in different situations.
If you look at the
function definition for NewJSONHandler
in the standard library you will see that it takes an interface of io.Writer
:
// /usr/lib/go-1.22/src/log/slog/json_handler.go
//
// NewJSONHandler creates a [JSONHandler] that writes to w,
// using the given options.
// If opts is nil, the default options are used.
func NewJSONHandler(w io.Writer, opts *HandlerOptions) *JSONHandler {
if opts == nil {
opts = &HandlerOptions{}
}
return &JSONHandler{
&commonHandler{
json: true,
w: w,
opts: *opts,
mu: &sync.Mutex{},
},
}
}
You will find many instances of the io.Writer
and io.Reader
interfaces being used in function parameters across the standard library. This is also probably a good time to mention that you should read the
standard library often. It’s readable and full of best-practices and examples.
So let’s make a small adjustment to out function and replace our concrete implementation with an interface.
// myproject/pkg/logger/logger.go
package logger
import (
"io"
"log/slog"
)
func NewLogger(serviceName string, destination io.Writer) *slog.Logger {
handler := slog.NewJSONHandler(destination, nil)
logger := slog.New(handler)
return logger.With("service", serviceName)
}
io.Reader and io.Writer
Let’s take a moment to talk about io.Writer
and the standard library interfaces in general.
io.Reader, io.Writer and the empty interface are the three most important interfaces in the entire ecosystem and they have an average of two-thirds of a method.
Rob Pike - Go Proverbs
If you look at the definition of io.Writer
in the
Go source code you will see this (as of Go 1.22):
// /usr/lib/go-1.22/src/io/io.go
//
// Writer is the interface that wraps the basic Write method.
//
// Write writes len(p) bytes from p to the underlying data stream.
// It returns the number of bytes written from p (0 <= n <= len(p))
// and any error encountered that caused the write to stop early.
// Write must return a non-nil error if it returns n < len(p).
// Write must not modify the slice data, even temporarily.
//
// Implementations must not retain p.
type Writer interface {
Write(p []byte) (n int, err error)
}
Anywhere you see this interface used in a method definition, as long as you pass in a struct that contains a function called Write
that takes a byte slice as a parameter and returns an int and an error, you’re good to go. So with that knowledge in mind, let’s return to our logging function.
In our original function, we wanted to log to os.Stdout
, so let’s call the funtion with that:
logger := logger.NewLogger("my-service", os.Stdout)
logger.Info("a test message")
if we look at the os.Stdout
object it’s simply a file pointer:
var os.Stdout *os.File
And if we dig into the file pointer in the standard libary we will find that it has a Write
method that conforms to the io.Writer
interface:
// /usr/lib/go-1.22/src/os/file.go
//
// Write writes len(b) bytes from b to the File.
// It returns the number of bytes written and an error, if any.
// Write returns a non-nil error when n != len(b).
func (f *File) Write(b []byte) (n int, err error) {
if err := f.checkValid("write"); err != nil {
return 0, err
}
n, e := f.write(b)
if n < 0 {
n = 0
}
if n != len(b) {
err = io.ErrShortWrite
}
epipecheck(f, e)
if e != nil {
err = f.wrapErr("write", e)
}
return n, err
}
This results in a JSON strucured log in stdout:
{"time":"2024-12-22T15:50:40.604750877+11:00","level":"INFO","msg":"a test message","service":"my-service"}
Swapping out functionality
So now we have our logger accepting an io.Writer
interface, we can call it with anything that has a compatible Write
method. Let’s say we want to write some unit tests for our logger. Interacting with the file system during tests is clunky and slow so let’s do it in memory:
logBuffer := bytes.Buffer{}
logger := NewLogger("test-logger", &logBuffer)
// Add some logs
logs := logBuffer.String()
logLines := strings.Split(logs, "\n")
for _, line := range logLines {
// test the log output
}
The bytes.Buffer
struct has a Write
function that is compatible with os.Writer
so you can use it as a drop-in replacement for testing purposes.
To extend this concept, you can write your own log destination structs to do whatever you need in a variety of situations. All they need is a compatible Write
method.
Conclusion
Every time you interact with the standard library, check the underlying types and interfaces. There’s a good chance you will see something like io.Writer
that you can utilise rather than writing your own definitions. The more comfortable you get with Go’s interfaces, the simpler your code becomes. You begin to really leverage the standard library as part of your code as opposed to other languages where you import something and it’s largely a black box.