all_lessons/Go/06 · Composition over inheritancelesson 7 / 18

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."

The thesis, here
Inheritance is the textbook case of complexity that looks free at the moment of writing and bills the reader later. A subclass three levels deep inherits state and behavior you cannot see from its own file; a change to the base ripples into descendants no one remembers exist. Go's bargain says a feature must earn its complexity against the cost to the reader and the team — and inheritance failed that test. So Go left it out and replaced it with a tool that does the 90% of reuse you actually need (forwarding methods to a contained value) while making the remaining coupling visible and local. This is "complexity must be earned" applied to the single most entrenched feature in object-oriented programming.
Linear position
Prerequisite: Lesson 04 gave you methods and receivers (behavior attached to a named type); Lesson 05 gave you interfaces (depend on a behavior, satisfied implicitly) and the interface value as a (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.
The plan
Four moves. (1) Name the engineering pain inheritance creates, so the omission reads as design. (2) Embed a struct and watch its methods get promoted — and see exactly what the compiler rewrote. (3) Embed an interface, and combine both to satisfy a contract by delegation. (4) Handle the escape hatch — 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.

inheritance bundles three things
Subtyping + code reuse + open recursion (base calls overridden subclass method), all under one keyword. Convenient to write; the coupling is invisible to the reader.
Go unbundles them
Subtyping → interfaces (L05, structural). Reuse → embedding (this lesson, mechanical forwarding). Open recursion → deleted. An embedded type never knows it's embedded.
what the deletion buys
No fragile base class: the outer type depends only on the embedded type's public method set, exactly as any other caller would. Change a private field in the embedded type and nothing three levels away breaks.

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.

The depth-and-ambiguity rule
Promotion only reaches embedded fields whose depth is unique. If 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.

ConcernInheritance answerGo composition answer
"is-a" (substitutability)extends a base classsatisfy an interface (L05), structurally
"has-the-behavior-of" (reuse)inherit base methodsembed the type; methods promoted by forwarding
override one methodvirtual method + super calldefine it on the outer type (shadows); call x.Inner.M() to delegate
combine two contractsmultiple inheritance (diamond risk)embed two interfaces into one; ambiguity is a compile error
who's the receiver of a reused methodthe 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.

When NOT to reach for any
The empty interface is dynamic typing bolted onto a statically typed language: it moves a class of errors from compile time to a runtime panic, and it costs the reader the ability to know what a value is from its type. Treat it as a smell that needs justification. Two legitimate uses — truly heterogeneous data (JSON, a generic container before generics, fmt) 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:

Embed for "has-the-behavior-of," not for "is-a"
Use embedding when the outer type genuinely wants to expose the inner type's methods as its own (a Server that logs). If you only need substitutability, you want an interface, not embedding. Don't embed to "save typing fields."
Embed interfaces to wrap; embed structs to reuse
Embedding an interface lets you decorate any implementation and override one method. Embedding a concrete struct reuses one specific implementation. Pick by whether you need to swap the inner thing.
Favor explicit fields when the relationship is "uses-a"
If the outer type merely uses a dependency and shouldn't expose its methods, give it a named field (log Logger), not embedding. Embedding leaks the inner API into the outer type's public surface — only do that on purpose.
Accept interfaces, embed/return structs
Lesson 05's rule, extended: depend on small interfaces at your boundaries; assemble concrete behavior internally by composition. Your function signatures name behaviors; your structs compose implementations.

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.

inheritance: a tree composition: a flat bag --------------------- ------------------------ Animal Server / \ ├─ Logger (embedded → Logf promoted) Dog Cat ├─ Metrics (embedded → Count promoted) / └─ addr (plain field) Puppy ← coupled up the whole chain depth 1; each piece swappable & visible

Checkpoint exercise

Try it
Find a class in your own code (any language) that 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.

Takeaway
Inheritance bundles subtyping, reuse, and an open recursion (base calling subclass) under one keyword, and the bundle bills the reader later: a subclass is coupled to the invisible implementation of everything above it — the fragile base class. Go failed inheritance against "complexity must be earned" and unbundled it: subtyping goes to interfaces (structural, L05), reuse goes to embedding (a nameless field whose methods the compiler forwards, so the receiver is always the embedded value — no back-channel, no fragile base), and the open recursion is simply deleted. You override by shadowing on the outer type and delegate explicitly with 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