You want to build large, scalable Golang projects?
Interface-driven design is non-negotiable.
This is where you separate the dabblers from the builders.
Interface-driven design is to Go what polymorphism is to other strongly typed languages
but without the mess and madness.
In traditional OOP, classes define both data and behavior, and they can inherit from each other:
class Animal {
data
behaviour
}
But in Go, interfaces define only behavior. No data. Just function signatures.
type Animal interface {
walk()
}
If something quacks like a duck (implements the walk
method), then it's an Animal
.
That’s duck typing.
Full Code:
git clone https://github.com/sklyt/goway.git
But why does this matter?
Let’s rewind for a second, interface-driven design makes way more sense once you understand what it means to be strongly typed.
In a statically typed language e.g c++, a container can only hold one type:
class Bird {}
std::vector<Bird> zoo;
This zoo
only accepts Bird
s. So if you need an Alligator
, you’re stuck:
class Alligator {}
std::vector<Alligator> zoo2;
Now we have two zoos. Not ideal.
Enter polymorphism:
If both Bird
and Alligator
inherit from Animal
, and Animal
is the common base class they qualify.
class Animal {}
class Bird : public Animal {}
class Alligator : public Animal {}
std::vector<Animal> zoo;
Now you can toss both in the same zoo.
But there's a catch:
They’re tightly coupled to Animal
. Any changes to the base class ripple through all the subclasses. That’s not great for scaling.
Go takes a different path.
Go doesn’t do inheritance. It’s all about composition and contracts.
Duck typing isn’t a relationship. It’s a contract.
Duck Typing 101
type Animal interface {
walk()
}
// Type is on the right in Go (yeah I know 😭)
func MakeWalk(a *Animal) {
a.walk()
}
That’s it. Anything that wants to be an Animal
just needs to implement walk()
.
We don’t care about its fields, history. Just that it walks.
type Bird struct {
type_ string
name string
}
func (b *Bird) walk() {
fmt.Println("I am walking")
}
Now we can do:
MakeWalk(&Bird{name: "Tweeter", type_: "weird_bird"})
This is how we build LEGO-style systems. Plug-and-play components.
Real Example: Local and Online Storage
Let’s bring it to life with a real-world example.
Step 1: Set up a new project
go mod init goway
Structure:
internal/
storage/
storage.go
local.go
online.go
main.go
storage.go
package storage
type Storage interface {
Save(key string, data []byte) error
Load(key string) ([]byte, error)
}
That’s our contract.
local.go
type LocalStore struct {
Path string
store map[string][]byte
}
func (l *LocalStore) Save(key string, val []byte) error {
fmt.Println("Saving " + key + " to " + l.Path)
return nil
}
func (l *LocalStore) Load(key string) ([]byte, error) {
fmt.Println("Loading " + key + " from " + l.Path)
data, ok := l.store[key]
if !ok {
return nil, ErrNotFound
}
return data, nil
}
Implements Save
and Load
→ It's a Storage
.
online.go
type OnlineStore struct {
URL string
}
func (o *OnlineStore) Save(key string, data []byte) error {
fmt.Println("Uploading " + key + " to " + o.URL)
return nil
}
func (o *OnlineStore) Load(key string) ([]byte, error) {
fmt.Println("Downloading " + key + " from " + o.URL)
return []byte("..."), nil
}
Also implements Save
and Load
→ It’s a Storage
.
Now the fun part: main.go
func useStorageGet(store storage.Storage, key string) ([]byte, error) {
data, err := store.Load(key)
if err != nil {
return nil, fmt.Errorf("get failed: %w", err)
}
return data, nil
}
func main() {
local := storage.LocalStore{Path: "./data"}
online := storage.OnlineStore{URL: "http://"}
res, err := useStorageGet(&local, "foo")
if err != nil {
// cache miss, fallback to online
res, err = useStorageGet(&online, "foo")
if err != nil {
// handle error
}
}
fmt.Println(string(res))
}
One function. Two implementations.
No switch statements. No if-else chains. Just clean, swappable behavior.
That’s the foundation of composition. And we’re not even using Go’s full power yet!
What’s next?
This is just the start.
Coming up in future posts:
- Constructor functions
- Struct embedding
- Functional options
- Dependency injection (for real)
- Channels (concurrency magic)
Together, these techniques will take your Go projects from:
“eh it runs”
to
Top comments (2)
Great walk-through, but if duck typing is a “contract,” does that mean my code needs a good lawyer before it implements an interface? Just kidding—I do love how Go skips the class inheritance therapy sessions. Still, it feels a bit weird that I can make anything an Animal as long as it “walks.” My fridge might be eligible soon!
I actually like that analogy! I guess you’re the lawyer in this case 🤣
If it walks like an Animal, talks like an Animal… case closed.
I totally agree! The looser the language, the more it all depends on your creativity, and your constraints.
It’s kinda like frameworks.
Angular is strict (very OOP vibes), while React is more like duck typing: “Just make it work and don’t break anything.”
But yeah, really great observation! Now I can’t unsee my fridge implementing
Walkable
😂