Skip to content

Why Go has no inheritance — rebuilding OOP with embedding and interfaces

Contents9 sections

Go has no classes and no inheritance, yet it’s hard to call it non-object-oriented. The official FAQ answers “Is Go an object-oriented language?” with “Yes and no.” This post explains why by following what Go put where inheritance used to be.

Where Go keeps the four pillars of OOP

I’d memorized the four pillars for interviews, and the mapping fell apart when I moved to Go. Encapsulation, abstraction, inheritance, polymorphism: none of the mechanisms class-based languages use for them exists in Go as-is. Every goal is still met, just by other means.

Pillar Class-based languages Go
Encapsulation class + private / protected package boundaries + identifier case. The unit is the package
Abstraction abstract classes, interfaces interfaces. There are no abstract classes
Inheritance extends, type hierarchies none. Embedding is not inheritance
Polymorphism overriding + vtables interfaces only. There is no other path

The first row deserves a closer look. In Go, the unit of encapsulation is the package. Inside a package, every type can see every other type’s lowercase fields. If you arrive thinking one type equals one class, that looks leaky. In practice the package boundary is the real wall, and everything inside it counts as one unit of implementation.

There’s one more mechanism that doesn’t get much attention: the internal/ directory. A package under it can only be imported from within the tree rooted at internal’s parent. Even exported names stay unusable outside the project, which makes it a layer of encapsulation one step wider than the package.

The internal directory
myapp/
├── internal/
│ └── auth/ ← importable only from under myapp/
│ └── token.go exported names still can't be used by other modules
├── pkg/
│ └── client/ ← importable by anyone
└── main.go

Methods aren’t tied to classes either. You can attach methods to types that aren’t structs, including a type built on a basic type, like type Celsius float64. Go never starts from the idea that an object is an instance of a class.

The official FAQ answers “Is Go an object-oriented language?” with “Yes and no.” It has types and methods and lets you write in an object-oriented style, but there is no type hierarchy. When someone asks me, I say that only class-based inheritance is missing and the other goals are met by different tools, then walk through the tools in the table above.

Not removed, but split in two

For a long time I read “why no inheritance?” as “what did Go leave out?”, and that makes for a weak answer. More precisely, Go separated two features that inheritance used to ship as one.

Class inheritance does two different jobs at once. It hands down the parent’s implementation (code reuse), and it lets a child stand in wherever the parent is expected (polymorphism). Because the two are tied together, wanting only the implementation drags the type relationship along with it.

Inheritance: one extends, two jobs code reuse polymorphismis-a · want one, get bothsplit in twoEmbeddingcode reusehas-aInterfacespolymorphismcan-do+ Generics (Go 1.18)same logic, different typesUse only the part you need
One is-a became a has-a and a can-do

Some problems went away along with inheritance.

Problem What goes wrong
Tight coupling Inheriting implementation binds parent and child tightly together
Fragile base class Changing the parent can break every child, without knowing where the children are
Deep hierarchies To find where a method came from, you have to climb the whole hierarchy
The diamond problem With multiple inheritance, inheriting the same ancestor along two paths makes it unclear which one wins

The FAQ gives one more reason, and it’s the one that stuck with me. In object-oriented languages, too much time goes into declaring and debating the relationships between types up front. Go chose to let those relationships hold automatically, without declarations. The implicit interface satisfaction from part 1 is exactly that mechanism. With no declared relationships, there’s no type hierarchy to maintain.

There is a cost: more delegation code. Using a parent method as-is, which inheritance gives you for free, has to be written out by hand. Embedding’s promotion cuts down that repetition, so it’s worth pinning down exactly what embedding is.

What embedding really is: anonymous fields and promotion

Write a type without a field name and the type’s name becomes the field name, and you can reach the inner fields and methods while skipping a level. That skipping is called promotion.

Embedded (anonymous field)

type Dog struct {
Animal
Breed string
}
  • Reach it directly as d.Name
  • Promoted methods join Dog’s method set

Named field

type Dog struct {
Animal Animal
Breed string
}
  • Reach it only as d.Animal.Name
  • Dog’s method set is unchanged

The memory layout is identical. The only difference is promotion, and promotion does two things. It shortens the name, and it puts the inner methods into the outer type’s method set. Thanks to the second, Dog satisfies every interface Animal satisfied. The abstract-class substitute later in this post depends on exactly that.

Promotion and shadowing
type Animal struct{ Name string }
func (a Animal) Sound() string { return "..." }
func (a Animal) Speak() string { return a.Name + ": " + a.Sound() }
type Dog struct {
Animal // anonymous field: same layout as an Animal Animal field
Breed string
}
func (d Dog) Sound() string { return "Woof" } // looks like an override…
d := Dog{Animal: Animal{Name: "Bori"}, Breed: "Jindo"}
fmt.Println(d.Name) // Bori: promoted, short for d.Animal.Name
fmt.Println(d.Sound()) // Woof: Dog's own (the shallower one wins)
fmt.Println(d.Animal.Sound()) // ...: the inner method, reached directly

You can also reach the inner value by its type name, as in d.Animal.Sound(). It’s the explicit way to call an inner method the outer type has shadowed, roughly where a super call would sit in other languages.

When names collide

There are two rules. The shallower depth wins: a name on the outer type shadows the same name inside. And two or more at the same depth are ambiguous. A common misconception is that declaring an ambiguous type is an error. It isn’t. The compile error comes only when you actually use the name.

An ambiguous selector
type A struct{ Name string }
type B struct{ Name string }
type C struct{ A; B; Name string } // C.Name is shallower, so it wins (shadowing)
type D struct{ A; B } // this declaration compiles
var d D
_ = d.Name // compile error here: ambiguous selector d.Name
_ = d.A.Name // being explicit fixes it

No overriding: there’s no way back out

The line that matters most in the code above is the one it didn’t print. What does d.Speak() return?

🧪 Try it

What does d.Speak() print?

Dog redefined Sound(), so anyone used to inheritance expects Bori: Woof. I ran it.

Calling the promoted Speak
d := Dog{Animal: Animal{Name: "Bori"}, Breed: "Jindo"}
fmt.Println(d.Speak())
Output
Bori: ...

Speak() is a method on Animal, and inside it the a in a.Sound() is just an Animal. It has no way of knowing a Dog is wrapped around it.

Class inheritanceAnimal.speak()parent methodthis.sound()finds the real typeDog.sound()the override runs→ Bori: WoofGo embeddingAnimal.Speak()promoted calla.Sound()resolved staticallyAnimal.Sound()Dog is invisible→ Bori: …The embedded Animal never sees the Dog
a.Sound() inside Animal.Speak is fixed to Animal.Sound at compile time. It has no way to see the outer Dog

The FAQ nails this down in one sentence: the only way to get dynamically dispatched methods is through an interface, and methods on a struct or any other concrete type are always resolved statically. a.Sound() inside Animal.Speak() is fixed to Animal.Sound at compile time for the sole reason that a has type Animal. There’s never a moment at run time to ask whether it’s really a Dog. Promotion only shortens names. It doesn’t route calls back out.

So how do you design it? Flip the direction

Wanting the parent to call a method the child redefined is the template method pattern. Go can’t build that structure, so you flip it into the strategy pattern: pull the varying part out into an interface and inject it from outside.

Template method: impossible in GoAnimal · the skeletonSpeak() fixes the flow and calls Sound()✕ can’t call down to the childDog · the part to fill inredefining Sound() changes nothingd.Speak() → Bori: …Strategy: inject it from outsideAnimal · the skeletonholds an interface field, S Sounderinjected at constructionDogSound{} · test fake · …anything that satisfies Soundera.Speak() → Bori: Woof
If you can't call down, pass it in. That's why Go code is full of strategies
Pull the varying part into an interface
type Sounder interface{ Sound() string }
type Animal struct {
Name string
S Sounder // injected from outside
}
func (a Animal) Speak() string { return a.Name + ": " + a.S.Sound() }
type DogSound struct{}
func (DogSound) Sound() string { return "Woof" }
a := Animal{Name: "Bori", S: DogSound{}}
fmt.Println(a.Speak()) // Bori: Woof, now it works as intended

Filling the gap left by abstract classes

An abstract class puts two things in one type: some behavior I implement, and some I force subclasses to provide. Go has nothing like it, so you split the two. Shared implementation comes in through embedding, and the required contract is declared as an interface.

Shared code via embedding, contract via interface
// ① Shared implementation: embed it and get it promoted
type BaseJob struct{ ID string }
func (b BaseJob) Key() string { return "job:" + b.ID }
// ② The required contract: declare it as an interface
type Runner interface {
Key() string
Run(context.Context) error
}
type SyncJob struct{ BaseJob } // shared part is promoted
func (SyncJob) Run(ctx context.Context) error { return nil } // contract implemented here
var _ Runner = SyncJob{} // forget Run and this line fails to compile (see part 1)

SyncJob satisfies Runner because Key() was promoted into SyncJob’s method set. This is promotion’s second job from earlier, put to work.

Embedding pitfalls in practice

The convenience comes with a clear price. If you’re writing a library in particular, know these before you embed.

Pitfall What happens
Internals leak into your public API Every exported method of the embedded type becomes an exported method of yours. Removing one later breaks compatibility
Embedding sync.Mutex Lock and Unlock become public API, so anyone can lock your value. A named field, mu sync.Mutex, is safer
Pointer-receiver methods If you embed a type by value and its methods have pointer receivers, only *Outer satisfies the interface (method sets in part 1)
Name collisions The shallower one wins; at equal depth you get an ambiguous error the moment you use it

The first row is the one that bites most often. One convenience grows your entire API surface. So in practice, picking the methods you need and delegating them explicitly is more often the better choice. It costs a few more lines, but you decide what’s public.

Delegate instead of embedding
// Bad: dozens of exported Client methods all become LeakyStore's API
type LeakyStore struct{ *clientv3.Client }
// Good: delegate only what you need. You decide the public surface
type Store struct{ c *clientv3.Client }
func (s *Store) Get(ctx context.Context, key string) (*clientv3.GetResponse, error) {
return s.c.Get(ctx, key)
}
// Keep the lock in a named field so Lock/Unlock don't leak out
type Cache struct {
mu sync.RWMutex // not embedded
m map[string]int
}

Embedding interfaces: composition and test doubles

You can embed interfaces as well as structs, and that use is much safer. It’s how the standard library composes small interfaces. Since Go 1.14 you can also compose interfaces that share a method; before that, a duplicate method was a compile error.

Composing interfaces and a test double
type ReadWriter interface{ io.Reader; io.Writer } // compose small pieces
// Test double: embed the big interface, implement only what the test uses
type fakeStore struct{ VMStore } // leave the embedded value nil
func (f fakeStore) Get(id string) (VM, error) { return VM{ID: id}, nil }
// Calling a method you didn't implement is a call on a nil interface, so it panics.
// That's not a bug; it says this test should never reach that method.

Generics vs interfaces: the third axis

Generics arrived in 1.18 and added another option. At first I took them as a replacement for interfaces; using them showed me the two answer different questions. One question settles the choice: what changes from type to type?

Behavior differs → interface

  • Each type has to act differently
  • Polymorphism, mocking, swapping implementations
  • Example: a Notifier that sends via Slack, email, or SMS

Only the type differs → generics

  • The logic doesn’t change by a single character
  • Containers, Map, Filter
  • Runtime type assertions disappear and types are checked at compile time
Where generics fit, where interfaces fit
// Generics: only the type differs, the logic is identical
func Map[T, U any](in []T, f func(T) U) []U {
out := make([]U, 0, len(in))
for _, v := range in {
out = append(out, f(v))
}
return out
}
doubled := Map([]int{1, 2}, func(i int) int { return i * 2 }) // [2 4]
names := Map(users, func(u User) string { return u.Name })
// Interface: each type does something different
type Notifier interface{ Notify(msg string) error } // Slack/email/SMS…

Overused, though, generics make signatures hard to read fast. Introducing them once you’ve actually copy-pasted the same code two or three times worked well for me.

A word on performance. Go’s generics don’t copy the code for every type like C++ templates, and they don’t erase types and box values like Java. They sit in between: types with the same GC shape share one copy of the code, and the differences are passed in a dictionary. All pointer types share a single shape, while value types usually get code of their own. That’s why value-type generics run at essentially the speed of concrete types. When I benchmarked the three approaches, value-type generics came out nearly identical to concrete types while interfaces were 2.4x slower, and this is the reason.

Advice to reach for generics first whenever you need abstraction always puzzled me. It only clicked once I changed the question to “will I ever swap this out?” If you’ll swap implementations, use an interface; if only the type differs, use generics. If you’re writing an interface with nothing to mock, generics or a plain concrete type is probably enough.

What design patterns look like in Go

Carry a pattern over by name and it usually comes out awkward. The shapes change in Go, and the causes are everything covered so far: no inheritance means inheritance-based patterns disappear, and first-class functions mean more closure-based forms.

Pattern What it looks like in Go
Strategy Interface injection. By far the most common, since template method isn’t possible and designs converge here
Decorator http.Handler middleware: a closure that takes a handler and returns a wrapped one
Factory A NewXxx() constructor function. A separate factory type is rarely needed
Builder The functional options pattern, built from closures and variadic arguments
Singleton sync.Once. Since 1.21, sync.OnceValue is shorter
Observer Channels. Less need to build your own subscribe/unsubscribe machinery
Functional options and OnceValue
// Functional options: closures take the builder's place
type Option func(*Server)
func WithTimeout(d time.Duration) Option { return func(s *Server) { s.timeout = d } }
func WithRetries(n int) Option { return func(s *Server) { s.retries = n } }
func NewServer(opts ...Option) *Server {
s := &Server{timeout: 5 * time.Second} // defaults
for _, o := range opts {
o(s) // each option applies itself
}
return s
}
srv := NewServer(WithTimeout(3*time.Second), WithRetries(2))
// Singleton: since 1.21, this one line does it
var conn = sync.OnceValue(func() *DB { return mustConnect() })
db := conn() // call it as often as you like; it initializes once

When I explain a pattern, I try to start with why it ended up in that shape rather than with its name. For the strategy pattern, “template method doesn’t work, so I flipped the direction” tells you more than the name does.

Summary

  • Embedding and delegation take reuse, interfaces take polymorphism, and interface injection takes extension.
  • An embedded field has the same memory layout as a named one. Promotion shortens names and widens the method set, but it never routes calls back out.
  • Methods on concrete types are always fixed at compile time, so when you need a template method, flip it into a strategy.
  • In a library, delegate only the methods you need instead of embedding, and keep sync.Mutex in a named field.
  • Interfaces when you’ll swap implementations, generics when only the type differs.

Four parts in, Go’s design has started to read as one idea. Types don’t declare their relationships to each other in advance. That single principle shows up as implicit interface satisfaction and again as the absence of type hierarchies. With no declared relationships there’s no hierarchy to manage, and when you need a relationship, you make one with an interface. Most of what first felt inconvenient follows from that.

References

Edit on GitHub
esc

Recent posts

↑↓ move↵ openOpen search page ›