Composition over inheritance: building without a family tree
Every other mainstream object language hands you inheritance as the default tool for reuse — and with it, a coupling that grows quietly for years until "change here, break there" is the dominant cost of touching the code. Go simply does not have it. This lesson is about what Go gives you instead: embedding, a small, mechanical reuse tool with no hidden coupling, plus the empty interface and type switches for the rare moment you must ask "what is this really?" The deeper lesson is question four of the five — does this depend on a behavior or a concretion? — and why Go pushes you toward "behavior" so hard that it deleted the most popular way to answer "concretion."
(type, data) pair. You need both: embedding promotes methods, and the empty interface is an interface.New capability: assemble large types from small ones by embedding, explain precisely why method promotion is forwarding and not inheritance, and reach for
any + a type switch only where dynamic typing genuinely earns its cost.any, type assertions, and type switches — and the rules for designing with composition rather than against it.1 · The pain inheritance bills you for later
Implementation inheritance — class Dog extends Animal — bundles three different things into one keyword: subtyping (a Dog is usable where an Animal is), code reuse (a Dog gets Animal's methods for free), and an open recursion where a base method can call a method the subclass overrode. That bundle is convenient to write and expensive to read. The famous symptoms have names: the fragile base class (a harmless-looking change in Animal silently breaks Dog because Dog depended on the base's internal call sequence), the gorilla/banana problem (you wanted one method but inherited the whole zoo of state and behavior up the chain), and the diamond ambiguity when two parents define the same thing. The common root: a subclass is coupled to the implementation of everything above it, and that coupling is invisible from the subclass's own source file.
Go's designers had watched this cost compound across a Google-scale codebase, and they applied question four of the five questions ruthlessly. Does code that wants to reuse Animal actually depend on Animal's behavior, or on its concrete identity? Almost always: the behavior. So Go split the bundle. Subtyping is handled by interfaces (Lesson 05) — structural, implicit, depending only on a method set. Reuse is handled by embedding — a separate, mechanical tool. And the open-recursion trick where a base reaches back into the subclass — the most powerful and most dangerous part — Go simply does not provide. Embedded methods can never call back into the outer type. That deletion is the whole point: it is the part that made inheritance fragile.
2 · Embedding a struct: promotion is forwarding, not magic
You embed a type by declaring a field with no name — just the type. The outer struct then gains the embedded type's fields and methods as if they were its own. Concretely: a logger we want several services to reuse.
type Logger struct {
prefix string
}
func (l Logger) Logf(format string, args ...any) {
fmt.Printf(l.prefix+": "+format+"\n", args...)
}
type Server struct {
Logger // embedded: a field whose name IS its type
addr string
}
s := Server{Logger: Logger{prefix: "web"}, addr: ":8080"}
s.Logf("listening on %s", s.addr) // promoted: reads as Server's own method
s.Logger.Logf("same call, explicit") // the unambiguous long form
The key claim, stated precisely: promotion is syntactic forwarding the compiler generates, not inheritance. s.Logf(...) compiles to s.Logger.Logf(...). The receiver of Logf is the Logger value, not the Server — inside Logf, l is the embedded Logger and it has no way to reach s.addr. There is no super, no virtual dispatch up a hierarchy, no base-class back-channel. An embedded value is just a field with an inferred name (the type's name), and method promotion is the rule "if the outer type doesn't define a method of this name, look one level into its embedded fields and forward." At runtime this is a struct member access plus a direct call — zero indirection beyond what writing s.Logger.Logf by hand would cost.
Two mechanical consequences worth pinning down. First, shadowing wins, and it's how you "override": if Server defines its own Logf, that method shadows the promoted one — and the embedded Logger's Logf is unaffected, reachable only as s.Logger.Logf. There is no automatic dispatch from the embedded method to the outer one, which is exactly why fragile-base-class can't happen here. Second, promotion satisfies interfaces. If some interface needs Logf, then Server satisfies it for free via the promoted method — composition feeding directly into Lesson 05's structural satisfaction.
Server embeds both Logger and Metrics and both have a Close() method, s.Close() is a compile error — ambiguous selector — until you write s.Logger.Close(). Go refuses to silently pick one (contrast C++'s diamond rules). A method defined directly on Server always wins over any promoted one regardless of depth: shallower beats deeper, and depth 0 (defined on the type itself) always wins.3 · Embedding an interface, and satisfying a contract by delegation
You can embed an interface too — in a struct or in another interface. Embedding an interface in a struct is the idiomatic way to wrap or decorate: the outer type promotes every method of the interface by forwarding to whatever concrete value is stored there, and you override exactly the one you care about.
// Wrap any io.ReadCloser so we count bytes read, overriding only Read.
type countingReader struct {
io.ReadCloser // embedded interface: promotes Read AND Close
n int64
}
func (c *countingReader) Read(p []byte) (int, error) {
k, err := c.ReadCloser.Read(p) // delegate to the wrapped value
c.n += int64(k)
return k, err
}
// Close is NOT defined here — it's promoted straight through to ReadCloser.
This is the decorator pattern with no boilerplate: countingReader is an io.ReadCloser (it has both methods, one written, one promoted) and can be passed anywhere that interface is wanted. We forwarded everything we didn't change and intercepted the one method we did. In an inheritance language you'd extend a base reader class and risk the fragile-base-class trap; here the coupling is exactly one line — c.ReadCloser.Read — and visible.
Embedding interfaces into interfaces is how the standard library composes contracts. This is not abstraction for its own sake; it is the small-interface discipline of Lesson 05 made compositional:
type Reader interface { Read(p []byte) (n int, err error) }
type Writer interface { Write(p []byte) (n int, err error) }
type ReadWriter interface { // the union of two behaviors
Reader
Writer
}
A type satisfies ReadWriter iff it satisfies both Reader and Writer — still purely structural, still no implements keyword. The lesson to carry: in Go you grow capability by composing small behaviors, never by climbing a type hierarchy.
| Concern | Inheritance answer | Go composition answer |
|---|---|---|
| "is-a" (substitutability) | extends a base class | satisfy an interface (L05), structurally |
| "has-the-behavior-of" (reuse) | inherit base methods | embed the type; methods promoted by forwarding |
| override one method | virtual method + super call | define it on the outer type (shadows); call x.Inner.M() to delegate |
| combine two contracts | multiple inheritance (diamond risk) | embed two interfaces into one; ambiguity is a compile error |
| who's the receiver of a reused method | the subclass (open recursion) | always the embedded value — no back-channel to the outer type |
4 · The escape hatch: any, type assertions, and type switches
Interfaces and embedding cover the vast majority of polymorphism. But sometimes you genuinely hold a value whose static type erased its concrete identity, and you must recover it — decoding JSON into map[string]any, a fmt.Print that accepts anything, a registry of heterogeneous values. For that, Go has the empty interface, spelled any since Go 1.18 (an alias for interface{}). It has zero methods, so every type satisfies it — it is the "I'll accept literally anything" type, and its cost is that you've thrown away all static knowledge of what you hold.
Recall from Lesson 05 that an interface value is a (type, data) pair: a word naming the dynamic type and a word pointing at the data (two words = 16 bytes for the header on a 64-bit machine, plus possibly a heap allocation if the boxed value escaped). A type assertion reads that type word back out:
var v any = "hello"
s := v.(string) // succeeds; s == "hello"
n := v.(int) // PANICS: interface holds string, not int
s, ok := v.(string) // comma-ok form: ok==true, s=="hello"
n, ok := v.(int) // comma-ok form: ok==false, n==0 (no panic)
Always prefer the comma-ok form unless a wrong type is a genuine bug worth panicking over — the single-return form is one of the few places ordinary Go code panics. When you must branch over several possible concrete types, the type switch is the idiom; it reads the type word once and binds the value at its real type in each case:
func describe(v any) string {
switch x := v.(type) {
case nil:
return "nil"
case int:
return fmt.Sprintf("int %d", x) // x is an int here
case string:
return fmt.Sprintf("string %q", x) // x is a string here
case io.Reader:
return "something readable" // matches by behavior!
default:
return fmt.Sprintf("unknown %T", x)
}
}
Note the io.Reader case: type switches can match on interfaces, not just concrete types — "is the dynamic value something that can Read?" — which is how errors.As walks a wrapped error chain (Lesson 07). Each comparison is roughly a pointer-equality check against a type descriptor; cheap, but it is a runtime decision the compiler can't help you with, which is exactly why you use it sparingly.
anyfmt) and behavior-probing (v.(io.Reader)). If you're reaching for any to model "one of a few known types," you usually wanted an interface with a method, or — since Go 1.18 — a generic type parameter (Lesson 14). "Accept interfaces, return structs" beats "accept any, assert later" almost every time.5 · Designing with composition: the rules that replace the family tree
Composition is a different design muscle than inheritance, and a few rules keep it honest:
Server that logs). If you only need substitutability, you want an interface, not embedding. Don't embed to "save typing fields."log Logger), not embedding. Embedding leaks the inner API into the outer type's public surface — only do that on purpose.The mental shift: there is no tree, only a flat bag of small pieces you snap together. A Server doesn't descend from Logger; it contains one and re-exports its method. Want metrics too? Embed a Metrics as well — a second field, not a second level. The structure stays one deep and every relationship is a field you can point at in the source, which is precisely the readability the bargain was buying.
Checkpoint exercise
extends another and inherits real behavior. Apply question four — does this depend on the base's behavior or on its concrete identity? — and rewrite it as Go-style composition: (1) split off the reused behavior into its own small type; (2) embed it in the outer type so its methods are promoted; (3) if there was an "is-a" use, define the interface that captures only the methods callers actually need, and confirm the composed type satisfies it. Then check the deletion: can the reused behavior call back into the outer type the way an overridden virtual method could? If your original design relied on that open recursion, name what broke — because that exact coupling is what Go refuses to let you build, and usually what was fragile.Where this points next
We now have the full composition toolkit — methods (04), interfaces (05), embedding and the empty interface (06) — and one one-method interface is about to do the most important job in the language. Lesson 07 reveals that error is just interface { Error() string }: failure in Go is an ordinary value returned in the signature, not a hidden exception that unwinds the stack. Everything you learned here applies directly — wrapped errors embed an inner error and promote it; errors.As is a type assertion walking that chain; sentinel and typed errors are interface satisfaction. Composition was the warm-up; errors-as-values is where it pays the rent.
x.Inner.M(); ambiguous promotion is a compile error, not a silent pick. The empty interface any plus type assertions and type switches are the dynamic-typing escape hatch — a runtime (type, data) check that trades compile-time safety for flexibility, justified only for truly heterogeneous data or behavior-probing. The transferable habit is question four — behavior or concretion? — and a design instinct for a flat bag of small composable pieces over a deep, coupled tree.Interview prompts
- Go has no inheritance — what does it give you instead, and why? (§1 — it unbundles inheritance: subtyping → interfaces, reuse → embedding, open recursion → deleted; the deletion removes the fragile-base-class coupling that made inheritance expensive to the reader.)
- When you embed
LoggerinServerand calls.Logf(), what's the receiver, and what did the compiler do? (§2 — the receiver is the embeddedLoggervalue, not theServer; the compiler forwardeds.Logf()tos.Logger.Logf(). The method cannot reachServer's fields — there is no back-channel.) - How do you "override" a promoted method, and why does that prevent the fragile base class? (§2 — define a method of the same name on the outer type; it shadows the promoted one. Since the embedded method never dispatches back into the outer type, a base change can't silently alter overridden behavior.)
- Two embedded types both have
Close()— what happens when you calls.Close()? (§2 — compile error, ambiguous selector at equal depth; you must qualify it (s.Logger.Close()). A method on the type itself, or at shallower depth, would win instead — Go never silently picks.) - What is
any, what does it cost, and when is it justified? (§4 —anyis the zero-method empty interface (interface{}), satisfied by every type; it discards static type info, moving errors to runtime panics. Justified for truly heterogeneous data (JSON,fmt) or behavior-probing — otherwise prefer an interface or a generic.) - Difference between
v.(string)andv, ok := x.(string)? (§4 — the single-return assertion panics on type mismatch; the comma-ok form returns the zero value andok==falseinstead. Prefer comma-ok unless a mismatch is a genuine bug worth panicking on.) - When would you use a named field instead of embedding, and why does it matter? (§5 — when the outer type merely uses a dependency and shouldn't re-export its API. Embedding promotes the inner methods into the outer type's public surface; a named field keeps that surface controlled — embed on purpose, not to save typing.)