Composition Over Inheritance

Understand why Go avoids classical inheritance, how struct and interface embedding provide composition-based code reuse, and how to apply these patterns effectively.

The Inheritance Habit and Why Go Breaks It

Languages like Java, C++, and Python organize code around classes that can extend one another through inheritance. A Dog class inherits from Animal, and Animal inherits from Object. This establishes a rigid vertical hierarchy where behavior is passed down through chains of parent-child relationships. Inheritance promises code reuse and polymorphism — and it does deliver both — but it also carries structural costs that become visible only as the system grows.

The deepest problem is that inheritance couples the child to the parent’s internal implementation. When a base class changes, every descendant can break — even if no descendant touched the changed method. This is the fragile base class problem. Inheritance also forces a single line of reuse: a type can only have one parent, which often leads to awkward hierarchies where unrelated behavior gets stuffed into a common ancestor just to be shared. The result is code that is hard to modify, test, or understand.

Go was designed by people who had spent decades maintaining large systems built with these patterns. Their decision was explicit: Go does not support inheritance at all. No classes, no extends, no virtual methods. Instead, Go provides struct embedding and interface composition — mechanisms that let you reuse code and define polymorphic behavior without building hierarchies.

A fundamental shift:

If you are coming from a class-based OOP background, the hardest mental adjustment is not the syntax — it is unlearning the instinct to design everything as a parent class with subclasses. Go asks you to think of types as pieces you assemble, not as branches on a family tree.

Composition Through Struct Embedding

The core mechanism Go gives you for reusing fields and methods is embedding one struct type inside another. When you embed a struct without a field name, all of the exported fields and methods of that embedded type become directly accessible on the outer type. This is not inheritance — it is composition with automatic delegation.

package main
import "fmt"
type Address struct {
    Street string
    City   string
}
func (a Address) FullAddress() string {
    return a.Street + ", " + a.City
}
type Customer struct {
    Address            // embedded, no field name
    CustomerID string
    Name       string
}
func main() {
    c := Customer{
        Address:    Address{Street: "12 Oak Lane", City: "Portland"},
        CustomerID: "C-9921",
        Name:       "Mira",
    }
    fmt.Println(c.Name)         // Mira
    fmt.Println(c.City)         // Portland (promoted from Address)
    fmt.Println(c.FullAddress()) // 12 Oak Lane, Portland (promoted method)
}

Customer does not extend Address. There is no is-a relationship; a customer is not an address. The relationship is has-a: a customer has an address. Embedding simply gives you the convenience of calling c.City instead of c.Address.City. The compiler rewrites the call behind the scenes, but the underlying structure is still two separate types composed together.

What makes embedding powerful is that the promoted fields and methods remain bound to the original embedded type. If Address has a method with a value receiver, that method operates on the embedded Address value — not on the outer Customer. This is a crucial difference from inheritance, where this refers to the subclass instance and the parent’s methods can call overridden methods on the child. In Go, no such dynamic dispatch happens through embedding alone.

Everything is explicit:

If you prefer to access the embedded type explicitly, you can always write c.Address.FullAddress(). Embedding never takes away your ability to be explicit — it only adds a shorthand. This is a deliberate language design choice that keeps code readable and avoids the “spooky action at a distance” of deep inheritance.

Method Shadowing — Not Overriding

In classical inheritance, a subclass can override a parent method entirely. When you call the method on an instance of the subclass, the parent’s version is completely replaced in that call path — unless the child explicitly delegates to the parent with something like super.method(). Go does not work this way.

If you define a method on the outer type that has the same name as a promoted method, the outer method shadows the embedded one. Both methods still exist, and you can access the embedded one explicitly.

package main
import "fmt"
type Logger struct{}
func (l Logger) Log(msg string) {
    fmt.Println("[LOG]", msg)
}
type FileLogger struct {
    Logger
    FilePath string
}
func (fl FileLogger) Log(msg string) {
    fmt.Printf("[FILE:%s] %s\n", fl.FilePath, msg)
}
func main() {
    fl := FileLogger{
        Logger:   Logger{},
        FilePath: "/var/log/app.log",
    }
    fl.Log("system started")         // [FILE:/var/log/app.log] system started
    fl.Logger.Log("system started")  // [LOG] system started
}

fl.Log(...) calls the FileLogger version. fl.Logger.Log(...) calls the embedded Logger version. Both are available. This is method shadowing, not overriding. The embedded method is still reachable through the embedded type’s name.

This matters because the shadowing is purely about name resolution at compile time. The inner type’s method never delegates to the outer type, and vice versa. If you write a method on the embedded Logger that internally calls Log, it will always call Logger.Log, never FileLogger.Log. The call path is determined by the receiver, not by which type the embedding chain ends at.

Shadowing is not polymorphism:

A common mistake is to assume that shadowing makes the outer type substitutable for the embedded type in an interface context. It does not. Promoted methods can satisfy interfaces for the outer type only if the outer type does not shadow them. If the outer type defines its own version, only the outer method participates in interface satisfaction. The embedded method remains hidden from the interface perspective.

Embedding Interfaces for Composable Behavior

Struct embedding gives you access to concrete fields and methods. Interface embedding gives you something different: a way to compose behavior contracts and to wrap existing implementations with new functionality.

When you embed an interface type as a field in a struct, you are declaring that the struct depends on some behavior without specifying which concrete implementation will provide it. The struct can then use the interface’s methods in its own methods, adding extra behavior around them. This is the foundation of the decorator and middleware patterns in Go.

package main
import (
    "fmt"
    "strings"
)
// CountingWriter wraps an io.Writer and counts bytes written.
type CountingWriter struct {
    Writer      strings.Builder // concrete, but we could embed io.Writer
    BytesWritten int
}
func (cw *CountingWriter) Write(p []byte) (int, error) {
    n, err := cw.Writer.Write(p)
    cw.BytesWritten += n
    return n, err
}
func main() {
    var cw CountingWriter
    fmt.Fprintln(&cw, "hello")
    fmt.Fprintln(&cw, "world")
    fmt.Println("bytes written:", cw.BytesWritten) // 12
    fmt.Println("content:", cw.Writer.String())
}

The CountingWriter composes a strings.Builder (which implements io.Writer) and adds byte counting on top. It does not inherit from a base writer class. It simply wraps an existing type and delegates to it, adding its own behavior. This pattern scales to HTTP handlers, database connections, loggers, and any place where you want to add cross-cutting concerns without altering the wrapped type.

Even more common is embedding an http.Handler interface inside a struct that adds logging:

type LoggingHandler struct {
    Handler http.Handler
    Logger  *log.Logger
}
func (lh *LoggingHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
    start := time.Now()
    lh.Handler.ServeHTTP(w, r)
    lh.Logger.Printf("%s %s %v", r.Method, r.URL.Path, time.Since(start))
}

Here Handler is an interface field, not a concrete type. The struct does not care what the underlying handler is — it could be a mux, a file server, or another middleware. The composing struct adds behavior before and after delegating to the wrapped handler. This is the compositional equivalent of a chain of responsibility built without any base class.

Embedded interfaces vs embedded structs:

When you embed a struct, you get fields and methods for free. When you embed an interface, you get only the method set — no fields, no initialization. The interface field must be set to a concrete implementation before use. This distinction is important: embedding a sync.Mutex gives you its Lock and Unlock methods directly; embedding an io.Writer just states that the struct will satisfy that contract if a concrete value is assigned to the field.

Composition vs Inheritance — A Design Refactoring

To see the difference concretely, consider a classic inheritance-based design for vehicles, translated into Go’s compositional style.

In an inheritance language, you might build:

Vehicle (wheels, speed)
  -> Car (doors, fuelType)
      -> ElectricCar (batteryCapacity)

Each level extends the previous one, creating a fixed hierarchy. In Go, you instead compose the capabilities you need:

package main
type Wheels struct {
    Count int
}
type Engine struct {
    FuelType string
}
func (e Engine) Start() string {
    return "engine started"
}
type ElectricBattery struct {
    CapacityKWh float64
}
func (eb ElectricBattery) Start() string {
    return "electric motor engaged silently"
}
type Car struct {
    Wheels
    Engine
    Doors int
}
type ElectricCar struct {
    Wheels
    ElectricBattery
    Doors int
}
func main() {
    petrol := Car{
        Wheels: Wheels{Count: 4},
        Engine: Engine{FuelType: "gasoline"},
        Doors:  4,
    }
    electric := ElectricCar{
        Wheels:          Wheels{Count: 4},
        ElectricBattery: ElectricBattery{CapacityKWh: 75},
        Doors:           4,
    }
    _ = petrol.Engine.Start()          // "engine started"
    _ = electric.ElectricBattery.Start() // "electric motor engaged silently"
}

Car and ElectricCar share no parent class. They share Wheels because both have wheels. Car has an Engine, ElectricCar has an ElectricBattery. There is no forced common ancestor, no fragile base class. If the requirements change and suddenly a hybrid car needs both an engine and a battery, you just embed both:

type HybridCar struct {
    Wheels
    Engine
    ElectricBattery
    Doors int
}

You add capability by composing more types, not by restructuring a hierarchy. The design remains flat and explicit.

Flexible by construction:

This flat composition means you can mix and match behaviors without worrying about diamond problems or protected access modifiers. Each piece is self-contained. Testing each piece in isolation is trivial because there are no inherited dependencies.

When to Use Composition (and When to Name the Field)

Struct embedding is convenient, but convenience is not always the right tool. The rule of thumb is: use embedding when the embedded type’s methods and fields are genuinely part of the outer type’s API — when accessing them directly makes sense to the caller. If you embed sync.Mutex in a struct that provides thread-safe access to its own data, callers expect to call Lock and Unlock on the struct directly. That is appropriate.

But if the relationship is purely internal — a helper struct that callers should not know about — use a named field instead. A Server struct that uses a connectionPool internally should expose Start() and Stop(), not connectionPool.Acquire(). Naming the field keeps the API surface small and clear.

A concrete test: if a user of your type should never call myStruct.SomeEmbeddedMethod() directly, don't embed. Use a named field. Embedding broadcasts methods into the public interface; it is a deliberate API decision, not just an implementation shortcut.

Embedding is an API promise:

Once you embed a type, its promoted methods become part of your type’s method set forever — or at least until the next breaking change. Removing an embedded field later can break callers that relied on promoted methods. Embed only what you intend to support as part of your type’s public surface.

Real-World Patterns Built on Composition

The standard library and many production Go codebases rely heavily on composition. These patterns are not edge cases — they are the default way to extend functionality.

Extending synchronization primitives. You can embed sync.Mutex to add logging or naming around lock operations:

type NamedMutex struct {
    sync.Mutex
    name string
}
func (nm *NamedMutex) LockWithLog() {
    fmt.Printf("locking %s\n", nm.name)
    nm.Mutex.Lock()
}

Here NamedMutex is not a “subclass of Mutex.” It is a struct that has a mutex and adds a logging method. The embedded mutex’s Lock and Unlock are still available directly if needed, but the primary value is the composed behavior.

Base configuration structs. A common pattern is to define a BaseConfig with shared settings and embed it into more specific configuration types:

type BaseConfig struct {
    Debug   bool
    Timeout time.Duration
}
type ServerConfig struct {
    BaseConfig
    Port int
}
type ClientConfig struct {
    BaseConfig
    ServerURL string
}

Both server and client configurations share debug and timeout settings without inheriting from a common class. Adding a new shared field requires changing only BaseConfig.

HTTP middleware chaining. The earlier LoggingHandler example shows composition of http.Handler interfaces. Middleware stacks are built by wrapping handlers with other handlers, each adding a concern: logging, authentication, rate limiting. This is composition in its purest form — each layer composes the next with no class hierarchy.

Common Mistakes and How to Avoid Them

Composition through embedding is straightforward, but its simplicity can mislead developers accustomed to inheritance.

Do not try to simulate inheritance with embedding:

Embedding a struct and then shadowing its methods while still expecting the original type’s internal dispatch to call your shadowed methods will not work. The embedded type’s methods always call their own type’s methods on the same receiver. If you need that kind of dynamic dispatch, use interfaces and explicit delegation — not embedding.

Nil embedded pointers crash at runtime:

If you embed a pointer type (*Engine) and do not initialize it before calling a promoted method, your program will panic with a nil pointer dereference. Always check that pointer-embedded fields are set before use, or provide a constructor that guarantees initialization.

Interface satisfaction is not always automatic with shadowing:

If you embed a type that satisfies an interface, the outer type satisfies that interface too — as long as you don’t shadow the relevant methods. If you define a method with the same name on the outer type, that outer method determines interface satisfaction, and the embedded method is hidden from the interface. This is a frequent source of confusion when debugging why a type no longer satisfies an interface after adding a method.

Correct composition leads to decoupled, testable code:

When you design types by composing small, focused pieces and use interfaces to define contracts between them, your code becomes easier to test. You can replace any piece with a mock or stub during testing because each piece is self-contained. This is the practical payoff of composition over inheritance.

Ambiguous selectors are another compile-time trap. If you embed two structs that both export a field or method with the same name, and the outer type does not define its own, the compiler will refuse to compile the ambiguous reference. The fix is to either rename one of the embedded types’ fields, or access the field explicitly through the embedded type name (e.g., c.A.Name). Go forces you to be explicit about which path you mean.

Summary

Composition over inheritance is not a slogan Go adopted lightly — it is a structural consequence of the language’s design. Without classes and extends, every mechanism for code reuse must be built from assembling independent pieces. Struct embedding provides a syntactic convenience that makes composition feel natural without introducing inheritance’s coupling. Interface embedding and delegation give you polymorphic behavior without hierarchy.

The insight to carry forward is this: in Go, you build behavior by combining types, not by extending them. When you need shared fields and methods, embed a struct. When you need to add behavior around an existing capability, wrap an interface. When you need polymorphism, define an interface and have multiple types satisfy it. None of these patterns require a parent class, and all of them keep your types decoupled and replaceable.