Programming languages evolve by absorbing trends from their era, which makes their capabilities increasingly similar. That's why we should focus on what they can't do - the constraints a language deliberately imposes often reveal its designers' philosophy most clearly.
For example, Ruby's choice not to mandate type declarations reflects Matz's philosophy of "programmer happiness" first. Go, on the other hand, incorporates many deliberate limitations from its design phase. These aren't deficiencies, but manifestations of a clear commitment to simplicity.
This article explores Go's philosophy beyond mere syntax and specifications, focusing particularly on its unique approach to interfaces.
The Peculiarity of Go Interfaces
Go's interface implementation is distinctive compared to most languages. While typical object-oriented languages require explicit declaration of which interface a class implements, Go does not. This becomes one of the first stumbling blocks for experienced developers new to Go.
The practical problem: when you look at a struct definition, you cannot immediately tell which interfaces it implements. This becomes especially problematic when implementing interfaces from external codebases - without knowing the interface exists, the code becomes cryptic.
Consider this example where Preferences struct implements sql.Scanner and driver.Valuer:
package main
import (
"database/sql/driver"
"encoding/json"
"fmt"
)
type Preferences struct {
Theme string `json:"theme"`
Receive bool `json:"receive"`
}
func (p *Preferences) Scan(src any) error {
if src == nil {
*p = Preferences{}
return nil
}
bytes, ok := src.([]byte)
if !ok {
return fmt.Errorf("unexpected type: %T", src)
}
return json.Unmarshal(bytes, p)
}
func (p Preferences) Value() (driver.Value, error) {
return json.Marshal(p)
}
type User struct {
ID int64 `bun:",pk,autoincrement"`
Name string
Prefs Preferences `bun:"type:jsonb"`
}
When saving User to a database or retrieving it, automatic JSON conversion happens for Prefs. But without knowledge of sql.Scanner/driver.Valuer, this code is mysterious.
Another risk: interface definitions might change, and your implementation could silently fall out of compliance. The official Go FAQ suggests this pattern to guarantee interface satisfaction:
var _ IUserRepository = (*UserRepository)(nil)
However, this itself requires specific knowledge to understand, and I've had colleagues question what this line does.
While these issues can be mitigated through comments and knowledge sharing, they wouldn't exist if the language required explicit interface declaration. So why did Go choose this approach?
The Philosophy Behind Go Interfaces
Dependency Direction in Interfaces
In most languages, implementations explicitly declare their interfaces. This creates a dependency from implementation to interface:
graph TB
UserRepository --> IUserRepository
CreateUserUseCase --> IUserRepository
In Go, implementations don't reference interfaces. They remain independent, and the dependency exists only where the interface is used:
graph TB
UserRepository
IUserRepository
CreateUserUseCase --> IUserRepository
From this perspective:
-
UserRepositoryshouldn't "know" it implementsIUserRepository - It's correct behavior that
UserRepositorydoesn't error even if it no longer satisfiesIUserRepository
The Go community has a saying: "Don't design with interfaces, discover them". Rather than designing interfaces first and implementing them, you should create structs first and discover common interfaces later.
The Origins: Why This Philosophy?
Understanding Go's philosophy requires understanding its creators' context. Go's designers (Ken Thompson, Rob Pike, et al.) came from painful experiences with massive C++ projects at Google, where they prioritized fast compilation and simplified dependencies above all else.
Explicit implements declarations create cascading problems:
- Interface changes ripple across the entire codebase
- Compile-time checking requires traversing all dependencies
- Build times balloon in large codebases
Go's implicit implementation offers:
- Localized interface changes with minimal ripple effects
- Easier parallel compilation
- Fast builds even at Google scale (millions of lines of code)
This design choice was fundamentally optimization for giant codebases in giant corporations. The philosophy isn't just aesthetic - it's a pragmatic solution to a concrete problem they faced daily.
The Reality of Interface Usage
However, in real-world development, we typically create interfaces first and implement them in structs.
For instance, we often define structs that conform to standard library interfaces like io.Reader to achieve specific behaviors.
Moreover, established design patterns and architectures are built on interface-first abstraction. Following these design principles necessarily leads to creating interfaces and implementing structs that conform to them:
interface Strategy { void execute(); }
class ConcreteStrategyA implements Strategy { ... }
class ConcreteStrategyB implements Strategy { ... }
Even though the language decouples dependencies, our mental model remains coupled. This gap between language specification and mental model creates instinctive friction, leading us to add comments or guarantee lines.
The "Correct" Way According to Go Philosophy
What would proper interface usage look like following Go's philosophy? The composition over inheritance design philosophy provides insights.
Can Interfaces Be Discovered?
The article cites Plan9 kernel as an example, where all system data items implement the same interface without special mechanisms, enabling various operations through a common interface.
In contrast, languages requiring upfront type hierarchy design lock that hierarchy in early, leading to over-engineering and preventing natural, emergent interactions as the system grows. This exemplifies the "discover, don't design" principle.
However, I find this somewhat idealistic.
Plan9 kernel was implemented by a small team of geniuses at Bell Labs working toward a unified vision - a highly special condition. In typical development environments where individuals develop freely, you're more likely to end up with inconsistent classes than meaningful interfaces. That's precisely why we define interfaces upfront to maintain code coherence.
I understand the concern about over-abstraction through type hierarchies causing unnecessary code early in development. But Plan9 kernel succeeded because it designed a powerful abstraction upfront - "everything is a file" - even if not enforced at the language level. This wasn't delayed decision-making about abstraction, but choosing the right abstraction from the start.
This is less about "discovering interfaces" and more about having the ability to identify the right abstraction initially - hardly a reproducible design guideline.
Go's Standard Library Designs Interfaces First
Ironically, Go's own standard library - created by those who advocate "discover interfaces" - is full of explicitly designed interfaces:
-
io.Reader/io.Writer- designed from the start -
http.Handler- designed from the start -
sort.Interface- designed from the start
The standard library designers succeeded through the ability to design appropriate abstractions upfront, not by "discovering" them.
Therefore, Go's philosophy seems idealistic rather than practically implementable in my view.
Following Go's Philosophy Pragmatically
That said, merely criticizing language specifications for diverging from personal philosophy isn't productive. Let's consider how to properly use interfaces while respecting Go's philosophy: "When in Go, do as the Gophers do".
The scenario where struct-interface independence truly shines is when the struct's definition and usage are separated.
Imagine Library A and Library B with common characteristics. Library C can use them through an interface without making A and B depend on C:
graph TB
LibA[Library A: Struct]
LibB[Library B: Struct]
LibC[Library C: Interface Definition]
LibC --> LibA
LibC --> LibB
In most languages, you'd need to modify Library A and B's code to implement Library C's interface:
class A implements LibCInterface
class B implements LibCInterface
This ability to discover common interfaces while maintaining independence from external dependencies is unique to Go's language design.
Conclusion: The Right Tool for the Right Context
The pragmatic approach: Go's philosophy for loose coupling across boundaries, and explicit design for order within boundaries.
To minimize friction with Go's language specifications, an architecture of many small, independent libraries is optimal. Conversely, a single large library implementing something like onion architecture is arguably misaligned with Go's language design.
Note: This conclusion might seem premature based on this article alone, but considering Go's other language specifications reinforces this view.
Go is a language with strong opinions, making it suited for specific contexts and less so for others. Deep understanding of its philosophy is essential for appropriate usage.
What are your thoughts on Go's interface philosophy? Do you find implicit implementation liberating or frustrating? Share your experiences in the comments!
Top comments (0)