Composition over inheritance in Go: building clear & flexible code

Coming from object-oriented languages, Go’s absence of inheritance can feel like a limitation. Where were my class hierarchies? How would I model “is-a” relationships? But after time of building production Go systems, I’ve come to appreciate the clarity that comes from Go’s deliberate design choice: ditching class-based inheritance entirely in favor of composition through struct embedding and interfaces.

Let me show you why this matters for your codebase.

Why Go deliberately avoids classic inheritance

The Go team didn’t just forget to add inheritance, they actively rejected it. Here’s why that decision makes sense:

Inheritance PainGo’s Answer
Deep type hierarchies become brittle and hard to refactor. Ever tried changing a base class only to break a dozen subclasses?Flat, composable pieces you can mix and match without cascading changes.
Child types depend on parent internals, creating tight coupling.Embed only what you need; keep each piece focused on its responsibility.
Explicit “is-a” relationships resist change when requirements evolve.Implicit interface satisfaction keeps abstractions loose and adaptable.

I’ve seen codebases where a simple Animal → Mammal → Dog → GermanShepherd hierarchy seemed reasonable at first, but became a nightmare when we needed to add behaviors that cut across the hierarchy. Go sidesteps this problem entirely.

Struct embedding: reuse without constraints

Here’s a simple example of composition in action with animal types:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
// Sounder represents a behavior for making sounds
// This is a focused component with a single responsibility
type Sounder struct{
    sound string  // The sound this entity makes
}

// MakeSound outputs the characteristic sound
// This method will be promoted to any struct that embeds Sounder
func (s Sounder) MakeSound() {
    fmt.Println(s.sound)
}

// Dog represents a dog in our system
// Instead of inheriting from Animal, it EMBEDS a Sounder
type Dog struct {
    Sounder       // 👈 Embedding, not inheritance - this is an anonymous field
    Breed string  // Dog-specific fields
    Name string
}

// NewDog creates a new Dog with a properly initialized Sounder
// Note how we explicitly set up the embedded Sounder
func NewDog(name, breed string) *Dog {
    return &Dog{
        Sounder: Sounder{sound: "Woof!"},  // Initialize the embedded struct
        Name: name,
        Breed: breed,
    }
}

// Cat represents a cat in our system
// It also embeds Sounder but configures it differently
type Cat struct {
    Sounder       // Same embedding, different configuration
    Name string
    Breed string
}

// NewCat creates a new Cat with its own Sounder configuration
func NewCat(name, breed string) *Cat {
    return &Cat{
        Sounder: Sounder{sound: "Meow!"},  // Different sound for cats
        Name: name,
        Breed: breed,
    }
}

func main() {
    // Create instances of our animal types
    dog := NewDog("Rex", "German Shepherd")
    cat := NewCat("Whiskers", "Tabby")
    
    // Both can make sounds through the promoted method
    fmt.Printf("%s says: ", dog.Name)
    dog.MakeSound()  // Outputs: Rex says: Woof!
    
    fmt.Printf("%s says: ", cat.Name)
    cat.MakeSound()  // Outputs: Whiskers says: Meow!
}

What’s happening here?

Dog and Cat don’t “inherit” from Sounder; they contain a Sounder as an anonymous field. Go’s compiler then automatically promotes Sounder’s methods to Dog and Cat. This gives you method reuse without creating a rigid type hierarchy.

The key difference from inheritance: if you need to change how sound-making works, you can swap out the embedded Sounder for something else without breaking your type relationships. Each animal type can also configure its embedded Sounder differently.

Interfaces: compose capabilities, not types

Go’s interfaces are another powerful tool for composition. Unlike Java or C# interfaces that require explicit declarations, Go interfaces are satisfied implicitly:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
// Eater defines anything that can eat food
// This is a capability-focused interface with a single responsibility
type Eater interface {
    // Eat takes a food item and returns whether it was eaten
    Eat(food string) bool
}

// Sleeper defines anything that can sleep
// Another focused interface with a clear, single responsibility
type Sleeper interface {
    // Sleep puts the entity to sleep for the specified duration
    Sleep(duration time.Duration)
}

// Pet combines the capabilities of both Eater and Sleeper
// This demonstrates interface composition - building larger interfaces
// from smaller, focused ones
type Pet interface {
    Eater   // Embeds all methods from Eater
    Sleeper // Embeds all methods from Sleeper
    GetName() string  // Pets also have names
}

// FeedPet accepts anything that satisfies the Eater interface
// The function doesn't care about the concrete type, only that it can Eat
func FeedPet(e Eater, food string) {
    if e.Eat(food) {
        fmt.Println("Pet enjoyed the food!")
    } else {
        fmt.Println("Pet didn't like that food.")
    }
    
    // Note: We can pass a Dog, Cat, Hamster, or any other type
    // that implements Eat() - without those types knowing about this function
}

// PutPetToBed accepts anything that can both eat and sleep
// This demonstrates accepting a composed interface
func PutPetToBed(p Pet) {
    fmt.Printf("Putting %s to bed...\n", p.GetName())
    p.Sleep(8 * time.Hour)
}

Your types don’t need to know about the interfaces they satisfy. This means you can define new abstractions without modifying existing code. Any type that implements the required methods automatically satisfies the interface, allowing for flexible design.

Real-world benefits

  • Refactoring becomes less scary: You can swap embedded structs or change interface implementations with minimal ripple effects.
  • Testing gets easier: Instead of mocking complex class hierarchies, you can build simple mocks for focused interfaces.
  • Onboarding new team members is faster: New developers can understand isolated components without tracing through layers of inheritance.

Here’s a practical example adding health monitoring to our animal care system:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
// AnimalRepository defines the interface for animal data access
// Any repository implementation must satisfy these methods
type AnimalRepository interface {
    FindAnimal(id string) (*Animal, error)
    // Other repository methods would be defined here...
}

// PostgresAnimalRepo is our original repository implementation
type PostgresAnimalRepo struct {
    db *sql.DB  // Database connection
}

// FindAnimal retrieves an animal from the database
func (p *PostgresAnimalRepo) FindAnimal(id string) (*Animal, error) {
    // Implementation that queries the database
    // ...
    return animal, nil
}

// HealthMonitorWrapper implements the AnimalRepository interface by wrapping another repository
// This demonstrates the Decorator pattern, a powerful composition technique
type HealthMonitorWrapper struct {
    next AnimalRepository  // The wrapped repository that does the actual work
    monitor *HealthMonitor // Component for recording health metrics
}

// FindAnimal implements the AnimalRepository interface by:
// 1. Checking if the animal lookup is for health reasons
// 2. Delegating the actual work to the wrapped repository
// 3. Recording health metrics before returning the result
func (h HealthMonitorWrapper) FindAnimal(id string) (*Animal, error) {
    // Log that we're checking on this animal
    h.monitor.RecordCheck(id)
    
    // Call the wrapped repository's implementation
    animal, err := h.next.FindAnimal(id)
    
    // If we found the animal, update its health status
    if err == nil && animal != nil {
        h.monitor.UpdateHealthStatus(animal)
    }
    
    // Return the same result from the wrapped repository
    return animal, err
}

// Usage example:
// baseRepo := &PostgresAnimalRepo{db: database}
// monitoredRepo := &HealthMonitorWrapper{next: baseRepo, monitor: healthMonitor}
// animal, err := monitoredRepo.FindAnimal("a123") // This call now includes health monitoring

With composition, we added health monitoring without modifying the original repository code at all. No inheritance required. This pattern allows us to layer on additional functionality like logging, caching, or validation without changing the core implementation.

Key takeaways

  1. Prefer composition over inheritance. In Go, it’s not just a design pattern, it’s baked into the language itself.
  2. Use struct embedding for straightforward reuse. You get method promotion without the baggage of type hierarchies.
  3. Design small, focused interfaces. The standard library is full of tiny interfaces like io.Reader and io.Writer that compose beautifully.
  4. Keep your designs flat and explicit. Your future self (and teammates) will thank you when requirements inevitably change.

Next time you find yourself reaching for an inheritance-based solution out of habit, try the Go way instead. You might be surprised at how much cleaner your code becomes.