DRY, or Don't Repeat Yourself, is one of the most well-known software development principles. Many engineers will go to great lengths to achieve this laudable goal, so a successful programming language must be designed with this in mind. Interfaces are just one of the tools that programming languages implement to enable code reuse.
Although there are countless differences in the way programming languages implement interfaces, they all define a contract for interaction between software components. Differentiating itself from the typical object-oriented implementation, the Go programming language implements interfaces in a way that aims to reduce complexity, encourage composition, and enable flexible use of the types.
The most impactful difference between Go’s interfaces and those from object-oriented languages is that Go’s interfaces work by implicit implementation. Rather than requiring a type to explicitly declare that it implements an interface, Go determines that a type satisfies an interface as long as it implements all of the required method signatures. The different types and the interface they implement are only coupled by the code using them, rather than always being linked together.
This difference offers a few key benefits:
Reduce complexity and coupling by limiting the hierarchical inheritance of interfaces
Simplify types that implement multiple interfaces
Enables you to add interfaces later on when they become necessary instead of designing entire applications around them
Interfaces can be created for types in different packages
This last benefit is the most interesting and unique. The ability to define an interface based on external types leads to a few more specific advantages:
By excluding inheritance, Go reduces the complexity that can occur from a deep hierarchical structure. When programs are designed around a base set of classes or interfaces, any simple changes to those base structures require a significant refactor.
The alternative practice of composition leads to reduced complexity and more readable code. Composition relies on splitting up functionality among different types and using them together, instead of re-defining the functionality of types through inheritance. Now you can re-use these individual components elsewhere, add more functionality with new components, and easily refactor or remove existing ones.
Instead of being concerned about what type something is, your code just needs to know about what that type can do, and luckily the interface informs it.
Polymorphism is perhaps the entire reason behind the existence of interfaces. This common practice is one of the most effective and easy-to-use methods of code reuse. Since an interface defines a strict contract for how types are used, these different types can be used interchangeably; this is polymorphism.
A very common and useful scenario for this is having a flexible storage backend for your program: use Postgres in production, SQLite when running locally, and mocks when testing (or skip the database mocks, but that's a topic for another day).
type StorageClient interface {
GetValue(id string) (string, error)
}
func NewStorageClient(clientType string) (StorageClient, error) {
switch clientType {
case "sqlite":
return sqlite.NewClient()
case "postgres":
return postgres.NewClient()
default:
return nil, fmt.Errorf("invalid client type: %s", clientType)
}
}
This implementation allows you to easily use the StorageClient
interface throughout the program without concern for the data storage layer behind it.
You can take advantage of the implicit nature of interfaces by defining an interface for the functions you use from an external library. For example, you are assigned a task to implement a function that fetches recent rain data, in inches, from a weather data API.
The made-up weather provider publishes Go package called weather
, which provides a Client
struct with various weather-related methods returning data in metric units:
func GetRainInches(since time.Duration, client weather.Client) (float32, error) {
rainMM, err := client.GetRain(since)
if err != nil {
return 0, fmt.Errorf("error getting data from API: %w", err)
}
return rainMM / 2.54, nil
}
How will you unit test this code? Since Go has implicit interfaces, you can create your interface that just defines the methods that you need from the library. Now, if your function expects this interface instead, you can create your mocks. Since you currently just need the GetRain
method, this is simple:
type WeatherClient interface {
GetRain(time.Duration) (float32, error)
}
func GetRainInches(since time.Duration, client WeatherClient) (float32, error) {
rainMM, err := client.GetRain(since)
if err != nil {
return 0, fmt.Errorf("error getting data from API: %w", err)
}
return rainMM / 2.54, nil
}
Then, your test file might contain a new struct that also implements the interface:
type MockWeatherClient struct {
expectedErr error
expectedMM float32
}
func (c MockWeatherClient) GetRain(time.Duration) (float32, error) {
return c.expectedMM, c.expectedErr
}
After this simple refactor, you do not depend on external libraries to provide interfaces that make your code testable! You have the additional side-effect of being one step closer to allowing your program to use different weather APIs generically.
While interfaces in Go were designed with considerations for simplicity and avoiding some of the common pitfalls of object-oriented patterns, there are still some things to be aware of.
It may be tempting to define interfaces for everything with the hopes that you can create a more generic and flexible program. Remember that one of the implicit interfaces is that you can easily create a new interface when you need it without making changes to any existing types, so there is no benefit to creating interfaces early in the process. Additionally, since types do not explicitly declare which interfaces they implement, it may be hard to tell which types are being used by your program when you have superfluous interfaces.
While there are always tradeoffs and no perfect solutions, I have found Go's version of interfaces to be incredibly flexible, intuitive, and useful. I have been able to create more flexible programs and improve testability all while minimizing complexity.
Also published here.