Learning Go: Favor Composition Over Inheritance
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:
- Structs: To hold data
- Methods: Functions attached to types
- Interfaces: To define behavior contracts (implicitly satisfied)
- 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: 115Composition 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: V6How 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 usersKey Difference:
- Embedded (
EngineinCar): Methods promoted, can callcar.Start() - Not Embedded (
loggerinDatabase): Must explicitly calldb.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/hComposition 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: 42Important: 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 implementations4. 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.
Related Posts
Go Learning Roadmap:
- Go Learning Roadmap - Complete series overview
- Phase 1: Go Fundamentals - Variables, types, structs, and embedding
- Go Goroutines and Concurrency - Concurrent programming
- Go Channels and Communication - Channel patterns
Other Go Posts:
- Pointer Receiver vs Value Receiver - Choosing the right receiver type
- Never Use Arrays in Go - Why slices are almost always better
Further Reading
- Effective Go - Embedding
- Go Blog - Composition with Go
- Design Patterns in Go
- Go Proverbs - "The bigger the interface, the weaker the abstraction"
📬 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.