Go Interfaces: Five Best-Practices for Enhanced Code Maintainability

When you’re coding in Go, you’ll quickly come to appreciate interfaces. An interface in Go is a type that spells out a set of methods. But here’s the interesting part: Go does things differently from other languages. Instead of needing an explicit declaration, a type in Go automatically satisfies an interface as long as it has all the required methods.
Implicit satisfaction gives you a lot of flexibility, and a few habits keep it from turning into a mess. Here are the five I try to follow.
Best Practice 1: Define Interfaces Where They Are Used
Go interfaces generally belong in the package that uses values of the interface type, not the package that implements those values. 1
This one feels backwards at first: define interfaces where they are used, not where they are implemented. It has a few advantages:
Encourages Loose Coupling
The consumer package no longer imports the implementation package, and tests can swap in a fake with a few lines. Example:
Consumer Package
type FileReader interface {
ReadFile(filePath string) ([]byte, error)
}
func ProcessFile(reader FileReader, filePath string) ([]byte, error) {
return reader.ReadFile(filePath)
}Implementation package
type LocalFileReader struct {}
func (lfr LocalFileReader) ReadFile(filePath string) ([]byte, error) {
// Implementation for reading a file from local storage
}Enhances Code Flexibility
The interface only lists the methods that package actually needs, so it stays small and specific.
Aligns with Dependency Inversion Principle
This is the Dependency Inversion Principle in practice: the code that uses a file reader and the code that implements one both depend on the interface, not on each other.
classDiagram
class FileProcessorModule {
+FileReader reader
+ProcessFile()
}
class FileReaderInterface {
<< interface >>
+ReadFile(string) []byte, error
}
class LocalFileReader {
+ReadFile(string) []byte, error
}
FileProcessorModule --> FileReaderInterface : uses
LocalFileReader ..|> FileReaderInterface : implements
Best Practice 2: Keep Interfaces Small and Focused
In Go, smaller and more specific interfaces are usually better. This is often referred to as the “Interface Segregation Principle.” A well-designed Go interface:
- Contains only the methods that are necessary for the required functionality.
- Is easier to implement and understand.
- Allows for more reusable and interchangeable code components.
type FileSaver interface {
SaveFile(filePath string, data []byte) error
}
type FileRetriever interface {
RetrieveFile(filePath string) ([]byte, error)
}The standard library’s io.Reader and io.Writer are the model here: one method each, implemented everywhere.
Best Practice 3: Use Composition to Build More Complex Interfaces
Small interfaces can be combined into bigger ones by embedding them:
type FileSaver interface {
SaveFile(filePath string, data []byte) error
}
type FileRetriever interface {
RetrieveFile(filePath string) ([]byte, error)
}
type FileManager interface {
FileSaver
FileRetriever
}Now, any type that implements both SaveFile and RetrieveFile methods implicitly satisfies the FileManager interface.
Functions can then ask for the smallest interface they need: something that only reads files takes a FileRetriever, not a FileManager.
classDiagram
class FileSaver {
<< interface >>
+SaveFile(string, []byte) error
}
class FileRetriever {
<< interface >>
+RetrieveFile(string) []byte, error
}
class FileManager {
<< interface >>
}
FileManager --> FileSaver : composes
FileManager --> FileRetriever : composes
Best Practice 4: Understand the Zero Value of Interfaces
The zero value of an interface is nil, and it hides a classic trap. An interface value holds two things: a concrete type and a value. It is only nil when both are missing. An interface holding a nil pointer of some concrete type is not a nil interface.
var fileManager FileManager // Uninitialized interface, nil
type CloudFileManager struct{}
func (cfm CloudFileManager) SaveFile(filePath string, data []byte) error {
// Method implementation
}
func (cfm CloudFileManager) RetrieveFile(filePath string) ([]byte, error) {
// Method implementation
}
var cfm *CloudFileManager
fileManager = cfm
if fileManager != nil {
fileManager.SaveFile("path/to/file", []byte("data"))
}Here fileManager holds a nil *CloudFileManager, so the interface itself is not nil. The check passes, the method is called on a nil pointer, and the program panics. The nil check did not protect you.
The fix is to avoid putting typed nil pointers into interfaces in the first place. If a function returns an interface, return a plain nil, not a nil pointer of a concrete type. This bites most often with error: a function that returns a *MyError as an error returns a non-nil error even when the pointer is nil.
Best Practice 5: Design for Interface, Not Implementation
Think about the behaviour you need, not the type that provides it. Code that depends on an interface doesn’t care which implementation it gets, so you can swap one for another without touching the callers.
type FileProcessor interface {
ProcessFile(filePath string, data []byte) ([]byte, error)
}
type EncryptionProcessor struct{}
func (ep EncryptionProcessor) ProcessFile(filePath string, data []byte) ([]byte, error) {
// Encrypt and process the file data
}
func processFileData(processor FileProcessor, filePath string, data []byte) ([]byte, error) {
return processor.ProcessFile(filePath, data)
}
var encryptor FileProcessor = EncryptionProcessor{}
processFileData(encryptor, "example/path", []byte("example data"))In this example, the FileProcessor interface abstracts the file processing operations, allowing EncryptionProcessor or any other processor type to be used interchangeably as long as it satisfies the FileProcessor interface.
It also makes testing easier: a test can pass in a fake FileProcessor instead of the real one.