Back to blog

Learning Go: Favor Composition Over Inheritance

GoDesign PatternsCompositionObject-Oriented ProgrammingBest Practices

Introduction

One of the most striking features of Go is its deliberate omission of class-based inheritance. Coming from languages like Java, C++, or Python, this might seem limiting at first. However, Go embraces the principle of "composition over inheritance" to create flexible, maintainable, and decoupled code.

In this article, we'll explore why Go made this design choice, understand the problems with traditional inheritance, and master practical composition patterns that make Go code elegant and powerful.

The Problem with Inheritance

Before diving into Go's solution, let's understand why inheritance can be problematic.

The Fragile Base Class Problem

# Python example showing inheritance issues
class Animal:
    def __init__(self, name):
        self.name = name
        self.energy = 100
    
    def move(self):
        self.energy -= 10
        print(f"{self.name} moved. Energy: {self.energy}")
 
class Bird(Animal):
    def fly(self):
        self.move()  # Relies on parent's move()
        print(f"{self.name} is flying!")
 
# Later, someone modifies Animal class
class Animal:
    def __init__(self, name):
        self.name = name
        self.energy = 100
    
    def move(self, distance=1):  # Changed signature!
        self.energy -= 10 * distance
        print(f"{self.name} moved {distance} units")
 
# Bird.fly() now breaks or behaves unexpectedly!

Problem: Changes to the base class can break all derived classes, often in subtle ways.

The Diamond Problem

class A:
    def method(self):
        print("A's method")
 
class B(A):
    def method(self):
        print("B's method")
 
class C(A):
    def method(self):
        print("C's method")
 
class D(B, C):  # Which method() does D inherit?
    pass
 
d = D()
d.method()  # Ambiguous! Depends on MRO (Method Resolution Order)

Problem: Multiple inheritance creates ambiguity and complexity.

Deep Inheritance Hierarchies

// Java example
class Vehicle { }
class LandVehicle extends Vehicle { }
class MotorizedLandVehicle extends LandVehicle { }
class Car extends MotorizedLandVehicle { }
class ElectricCar extends Car { }
 
// To understand ElectricCar, you must understand 5 classes!

Problem: Deep hierarchies are hard to understand, modify, and maintain.

Go's Approach: No Classes, No Inheritance

Go doesn't have classes or inheritance. Instead, it provides:

  1. Structs: To hold data
  2. Methods: Functions attached to types
  3. Interfaces: To define behavior contracts (implicitly satisfied)
  4. Embedding: To compose types

Let's explore each concept.

Basic Building Blocks

Structs and Methods

package main
 
import "fmt"
 
// Struct holds data
type Dog struct {
    Name   string
    Breed  string
    Energy int
}
 
// Method attached to Dog type
func (d *Dog) Bark() {
    fmt.Printf("%s says: Woof!\n", d.Name)
    d.Energy -= 5
}
 
func (d *Dog) Eat() {
    fmt.Printf("%s is eating.\n", d.Name)
    d.Energy += 20
}
 
func main() {
    dog := Dog{Name: "Buddy", Breed: "Golden Retriever", Energy: 100}
    dog.Bark()
    dog.Eat()
    fmt.Printf("Energy: %d\n", dog.Energy)
}

Output:

Buddy says: Woof!
Buddy is eating.
Energy: 115

Composition Pattern #1: Struct Embedding

Go allows you to embed one struct inside another, promoting the embedded struct's fields and methods.

package main
 
import "fmt"
 
// Base types
type Engine struct {
    Horsepower int
    Type       string
}
 
func (e Engine) Start() {
    fmt.Printf("Starting %s engine with %d HP\n", e.Type, e.Horsepower)
}
 
func (e Engine) Stop() {
    fmt.Println("Engine stopped")
}
 
// Car embeds Engine
type Car struct {
    Engine  // Embedded struct
    Make    string
    Model   string
    Year    int
}
 
func (c Car) Drive() {
    fmt.Printf("Driving %d %s %s\n", c.Year, c.Make, c.Model)
}
 
func main() {
    car := Car{
        Engine: Engine{Horsepower: 300, Type: "V6"},
        Make:   "Toyota",
        Model:  "Camry",
        Year:   2024,
    }
    
    // Can call Engine's methods directly on Car
    car.Start()
    car.Drive()
    car.Stop()
    
    // Can also access Engine explicitly
    fmt.Printf("Engine type: %s\n", car.Engine.Type)
}

Output:

Starting V6 engine with 300 HP
Driving 2024 Toyota Camry
Engine stopped
Engine type: V6

How Embedding Works

When you embed a type, its fields and methods are promoted to the outer type. It's like automatic delegation.

type Inner struct {
    X int
}
 
func (i Inner) Method() {
    fmt.Println("Inner method")
}
 
type Outer struct {
    Inner  // Embedding
    Y int
}
 
func main() {
    o := Outer{Inner: Inner{X: 10}, Y: 20}
    
    // Direct access to promoted fields
    fmt.Println(o.X)  // 10 (promoted from Inner)
    fmt.Println(o.Y)  // 20
    
    // Direct access to promoted methods
    o.Method()  // "Inner method" (promoted from Inner)
}

Composition Pattern #2: Has-A Relationship

Instead of inheritance's "is-a" relationship, composition uses "has-a" relationships.

package main
 
import "fmt"
 
type Logger struct {
    Prefix string
}
 
func (l Logger) Log(message string) {
    fmt.Printf("[%s] %s\n", l.Prefix, message)
}
 
type Database struct {
    logger Logger  // Has-a relationship (not embedded)
    Name   string
}
 
func (db Database) Connect() {
    db.logger.Log(fmt.Sprintf("Connecting to database: %s", db.Name))
}
 
func (db Database) Query(sql string) {
    db.logger.Log(fmt.Sprintf("Executing query: %s", sql))
}
 
func main() {
    db := Database{
        logger: Logger{Prefix: "DB"},
        Name:   "users_db",
    }
    
    db.Connect()
    db.Query("SELECT * FROM users")
}

Output:

[DB] Connecting to database: users_db
[DB] Executing query: SELECT * FROM users

Key Difference:

  • Embedded (Engine in Car): Methods promoted, can call car.Start()
  • Not Embedded (logger in Database): Must explicitly call db.logger.Log()

Composition Pattern #3: Multiple Composition

You can embed multiple types to combine their capabilities.

package main
 
import "fmt"
 
type Walker struct {
    Speed int
}
 
func (w Walker) Walk() {
    fmt.Printf("Walking at %d km/h\n", w.Speed)
}
 
type Swimmer struct {
    Speed int
}
 
func (s Swimmer) Swim() {
    fmt.Printf("Swimming at %d km/h\n", s.Speed)
}
 
type Flyer struct {
    Speed int
}
 
func (f Flyer) Fly() {
    fmt.Printf("Flying at %d km/h\n", f.Speed)
}
 
// Duck can walk, swim, and fly
type Duck struct {
    Walker
    Swimmer
    Flyer
    Name string
}
 
// Penguin can walk and swim (but not fly)
type Penguin struct {
    Walker
    Swimmer
    Name string
}
 
func main() {
    duck := Duck{
        Walker:  Walker{Speed: 5},
        Swimmer: Swimmer{Speed: 10},
        Flyer:   Flyer{Speed: 50},
        Name:    "Donald",
    }
    
    fmt.Printf("%s the duck:\n", duck.Name)
    duck.Walk()
    duck.Swim()
    duck.Fly()
    
    fmt.Println()
    
    penguin := Penguin{
        Walker:  Walker{Speed: 3},
        Swimmer: Swimmer{Speed: 15},
        Name:    "Pingu",
    }
    
    fmt.Printf("%s the penguin:\n", penguin.Name)
    penguin.Walk()
    penguin.Swim()
    // penguin.Fly() // Compile error! Penguin can't fly
}

Output:

Donald the duck:
Walking at 5 km/h
Swimming at 10 km/h
Flying at 50 km/h
 
Pingu the penguin:
Walking at 3 km/h
Swimming at 15 km/h

Composition Pattern #4: Interfaces for Polymorphism

Combine composition with interfaces for flexible polymorphism.

package main
 
import "fmt"
 
// Interface defines behavior
type Notifier interface {
    Notify(message string)
}
 
// Email notifier
type EmailNotifier struct {
    EmailAddress string
}
 
func (e EmailNotifier) Notify(message string) {
    fmt.Printf("Sending email to %s: %s\n", e.EmailAddress, message)
}
 
// SMS notifier
type SMSNotifier struct {
    PhoneNumber string
}
 
func (s SMSNotifier) Notify(message string) {
    fmt.Printf("Sending SMS to %s: %s\n", s.PhoneNumber, message)
}
 
// User embeds a notifier
type User struct {
    Name     string
    Notifier // Embedded interface!
}
 
func (u User) Alert(message string) {
    fmt.Printf("Alerting %s:\n", u.Name)
    u.Notify(message)
}
 
func main() {
    // User with email notifications
    user1 := User{
        Name:     "Alice",
        Notifier: EmailNotifier{EmailAddress: "alice@example.com"},
    }
    user1.Alert("Your order has shipped!")
    
    fmt.Println()
    
    // User with SMS notifications
    user2 := User{
        Name:     "Bob",
        Notifier: SMSNotifier{PhoneNumber: "+1-555-0123"},
    }
    user2.Alert("Your package is delivered!")
}

Output:

Alerting Alice:
Sending email to alice@example.com: Your order has shipped!
 
Alerting Bob:
Sending SMS to +1-555-0123: Your package is delivered!

Powerful Pattern: By embedding an interface, you can swap implementations at runtime!

Composition Pattern #5: Method Overriding

You can "override" embedded methods by defining a method with the same name on the outer type.

package main
 
import "fmt"
 
type Base struct {
    Value int
}
 
func (b Base) Display() {
    fmt.Printf("Base value: %d\n", b.Value)
}
 
func (b Base) Process() {
    fmt.Println("Processing in Base")
    b.Display()
}
 
type Extended struct {
    Base
    Extra string
}
 
// "Override" Display method
func (e Extended) Display() {
    fmt.Printf("Extended value: %d, extra: %s\n", e.Value, e.Extra)
}
 
func main() {
    ext := Extended{
        Base:  Base{Value: 42},
        Extra: "hello",
    }
    
    ext.Display()  // Calls Extended.Display()
    ext.Process()  // Calls Base.Process(), which calls Base.Display()!
    
    // To call the overridden Display from Process, you'd need explicit forwarding
}

Output:

Extended value: 42, extra: hello
Processing in Base
Base value: 42

Important: Unlike inheritance, the embedded type's methods still call the embedded type's methods, not the "overridden" ones. This prevents the fragile base class problem!

Real-World Example: HTTP Handler Middleware

Let's build a practical example: HTTP middleware using composition.

package main
 
import (
    "fmt"
    "log"
    "net/http"
    "time"
)
 
// Base handler
type Handler struct {
    Pattern string
}
 
func (h Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
    fmt.Fprintf(w, "Hello from %s\n", h.Pattern)
}
 
// Logging middleware
type LoggingHandler struct {
    http.Handler
}
 
func (l LoggingHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
    start := time.Now()
    log.Printf("Started %s %s", r.Method, r.URL.Path)
    
    l.Handler.ServeHTTP(w, r)  // Delegate to wrapped handler
    
    log.Printf("Completed in %v", time.Since(start))
}
 
// Authentication middleware
type AuthHandler struct {
    http.Handler
    APIKey string
}
 
func (a AuthHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
    key := r.Header.Get("X-API-Key")
    if key != a.APIKey {
        http.Error(w, "Unauthorized", http.StatusUnauthorized)
        return
    }
    
    a.Handler.ServeHTTP(w, r)  // Delegate to wrapped handler
}
 
func main() {
    // Compose handlers
    baseHandler := Handler{Pattern: "/api"}
    
    // Wrap with auth
    authHandler := AuthHandler{
        Handler: baseHandler,
        APIKey:  "secret123",
    }
    
    // Wrap with logging
    loggingHandler := LoggingHandler{
        Handler: authHandler,
    }
    
    http.Handle("/api", loggingHandler)
    
    fmt.Println("Server starting on :8080")
    log.Fatal(http.ListenAndServe(":8080", nil))
}

This pattern is used throughout Go's standard library!

Benefits of Composition Over Inheritance

1. Flexibility

You can easily mix and match components without being locked into a hierarchy.

2. Clarity

It's clear what each type contains. No hidden inherited behavior.

3. No Fragile Base Class Problem

Changing a component only affects types that explicitly use it.

4. Easy Testing

You can test components independently and mock embedded interfaces.

type MockNotifier struct {
    Messages []string
}
 
func (m *MockNotifier) Notify(message string) {
    m.Messages = append(m.Messages, message)
}
 
// Use in tests
func TestUser(t *testing.T) {
    mock := &MockNotifier{}
    user := User{Name: "Test", Notifier: mock}
    
    user.Alert("Test message")
    
    if len(mock.Messages) != 1 {
        t.Errorf("Expected 1 message, got %d", len(mock.Messages))
    }
}

5. No Diamond Problem

Field name conflicts are resolved explicitly.

type A struct {
    Value int
}
 
type B struct {
    Value string
}
 
type C struct {
    A
    B
}
 
func main() {
    c := C{}
    // c.Value  // Compile error: ambiguous
    c.A.Value = 10      // Explicit
    c.B.Value = "hello" // Explicit
}

Best Practices

1. Keep Interfaces Small

// Good: Small, focused interfaces
type Reader interface {
    Read(p []byte) (n int, err error)
}
 
type Writer interface {
    Write(p []byte) (n int, err error)
}
 
// Compose when needed
type ReadWriter interface {
    Reader
    Writer
}

2. Accept Interfaces, Return Structs

// Good
func ProcessData(r io.Reader) (*Result, error) {
    // ...
}
 
// Less flexible
func ProcessData(f *os.File) (*Result, error) {
    // ...
}

3. Embed Interfaces for Flexibility

type Service struct {
    Storage interface {
        Save(data []byte) error
        Load() ([]byte, error)
    }
}
 
// Easy to swap implementations

4. Use Composition for Cross-Cutting Concerns

Logging, metrics, authentication, etc. are perfect for composition.

5. Prefer Explicit Over Implicit

If embedding makes code unclear, use explicit fields and methods.

// If this is confusing:
type Confusing struct {
    ComponentA
    ComponentB
    ComponentC
}
 
// Consider this:
type Clear struct {
    compA ComponentA
    compB ComponentB
    compC ComponentC
}
 
func (c Clear) DoSomething() {
    c.compA.MethodA()
    c.compB.MethodB()
}

Common Patterns

Decorator Pattern

type Component interface {
    Operation() string
}
 
type ConcreteComponent struct{}
 
func (c ConcreteComponent) Operation() string {
    return "Base"
}
 
type Decorator struct {
    Component
}
 
func (d Decorator) Operation() string {
    return "Decorated(" + d.Component.Operation() + ")"
}

Strategy Pattern

type Strategy interface {
    Execute(data []int) int
}
 
type Context struct {
    Strategy
}
 
func (c Context) Process(data []int) int {
    return c.Strategy.Execute(data)
}

Conclusion

Go's composition model is a powerful alternative to classical inheritance:

  • Structs + Methods: Basic building blocks
  • Embedding: Automatic delegation and promotion
  • Interfaces: Polymorphism without tight coupling
  • Composition: Flexible "has-a" relationships

By favoring composition, Go encourages:

  • Simpler, flatter type relationships
  • More explicit code
  • Better testability
  • Greater flexibility

The absence of inheritance isn't a limitation—it's a deliberate design choice that leads to more maintainable and flexible code. Once you embrace composition, you'll find it's often a better solution than inheritance ever was.


Go Learning Roadmap:

Other Go Posts:


Further Reading

📬 Subscribe to Newsletter

Get the latest blog posts delivered to your inbox every week. No spam, unsubscribe anytime.

We respect your privacy. Unsubscribe at any time.

💬 Comments

Sign in to leave a comment

We'll never post without your permission.