Go Interfaces: The Art of "What" Over "How"
Program to an interface, not an implementation.
· 3 min read
“Program to an interface, not an implementation.” This isn’t a new, flashy Go-specific slogan; it’s a time-tested principle in software design that’s relevant across many programming languages. The core idea is simple yet powerful: focus on what a thing can do, not what it’s made of. 🧐
What are Go Interfaces? #
In Go, an interface is a type that specifies a set of method signatures. If a type defines all the methods in an interface, it is said to satisfy that interface. The beauty of Go’s approach is that this satisfaction is implicit—you don’t need to explicitly declare that your type implements a particular interface.
For example:
// The interface just defines what something can do.
type Speaker interface {
Speak() string
}
Any type that has a Speak() string
method automatically satisfies the Speaker
interface. A cool feature of Go is that you can define methods not only on structs
but also on other types, like int
or string
, allowing them to satisfy interfaces too.
A Practical Example: Calculating Total Weight #
Let’s make this concrete. Imagine you need a function to sum the weights of various items, like cars and boats.
Your first attempt might look like this:
type Car struct {
// ... fields
}
type Boat struct {
// ... fields
}
// ❌ Not a scalable approach!
func totalWeight(cars []*Car, boats []*Boat, ...) float64 {
// ... logic to sum weights for each type
}
This function is brittle. What happens when you need to add planes, trains, or bikes? You’d have to modify the function signature every single time, which is a maintenance nightmare.
This is where interfaces shine. Instead of worrying about what the items are (Car
, Boat
), we can focus on what they can do: provide their weight.
First, we define an interface that captures this capability:
// We define an interface for anything that has weight.
type Weighter interface {
Weight() float64
}
Now, we can write a single, flexible function that accepts any slice of types that satisfy our Weighter
interface.
// ✅ Scalable and clean!
func totalWeight(items []Weighter) float64 {
var total float64
for _, item := range items {
total += item.Weight()
}
return total
}
This totalWeight
function can now accept a slice of Car
, Boat
, or any other type, as long as that type has a Weight() float64
method. Your code becomes decoupled, scalable, and much easier to test and maintain. 💪
When Should You Define an Interface? #
Here’s a key piece of advice: define an interface when you need one, not before.
It’s tempting to try and predict all the interfaces you might need upfront. However, this often leads to bloated interfaces with too many methods. A “fat” interface creates a weak abstraction because it forces implementers to define methods they may not even need.
The best practice is to let the consumer of the interface define it. In our example, the totalWeight
function was the consumer. It needed a way to get the weight from its arguments, so we created the Weighter
interface specifically for that purpose. This “just-in-time” approach ensures your interfaces are small, focused, and truly useful. ✨