Composition vs Inheritance & Interfaces as Contracts π§±
One of the most famous pieces of advice in the Gang of Four design patterns book is:
βFavor object composition over class inheritance.β
Understanding why inheritance often leads to brittle systemsβand why modern languages like Go and Rust completely omitted class inheritance in favor of composition and interfacesβis a fundamental milestone in software design.
1. Inheritance: The βIs-Aβ Relationship
In classic Object-Oriented Programming, Inheritance lets a child class inherit methods and fields from a parent class:
Vehicle (startEngine, stopEngine)
β²
β (Inherits)
Car (startEngine, stopEngine, openSunroof)
β²
β (Inherits)
ElectricCar (???) ββ "Wait, electric cars don't have engines!"
The Problem: The Fragile Base Class & Gorilla-Banana Problem
As systems grow, deep inheritance trees become rigid and brittle:
- The Gorilla-Banana Problem: βYou wanted a banana, but what you got was a gorilla holding the banana and the entire jungle.β Subclasses inherit fields and behaviors they donβt want or need.
- Coupling: Changing a single method in the base class can silently break dozens of distant subclasses across the repository.
2. Composition: The βHas-Aβ Relationship (Lego Bricks)
Instead of saying what an object is (a child of another class), Composition builds complex objects by assembling smaller, focused components that the object has:
ββββββββββββββββββββββββββββββββββββββββββββββββ
β ElectricCar β
β β
β [ ElectricMotor ] [ BatteryPack ] β
β βββ start() βββ charge() β
β β
β [ GPSNavigation ] [ SoundSystem ] β
ββββββββββββββββββββββββββββββββββββββββββββββββ
// Clean composition in Go (no inheritance!)
type Battery struct {
CapacityKWh int
}
func (b *Battery) Charge() {
println("Charging battery...")
}
type ElectricCar struct {
Model string
Battery Battery // Has-A Battery!
}
func main() {
tesla := ElectricCar{
Model: "Model Y",
Battery: Battery{CapacityKWh: 75},
}
tesla.Battery.Charge()
}Why Composition Wins in Production
- Swapability: You can easily swap out
Batteryfor aMockBatteryin unit tests. - Flexibility: You can combine behaviors without being forced into an unnatural parent-child hierarchy.
3. Interfaces as Contracts
An Interface is simply a contract: it specifies what actions must be supported, without dictating how they are implemented.
Think of a standard electrical wall outlet:
- The outlet defines an interface: two prongs delivering 120V AC.
- A lamp, a laptop charger, and a toaster can all plug into the same outlet because they all adhere to the same interface.
// The Contract: Anything that knows how to Save an order is a StorageEngine!
type StorageEngine interface {
Save(orderID string, data []byte) error
}
// 1. Production implementation: Saves to PostgreSQL
type PostgresStore struct { /* db connection */ }
func (p *PostgresStore) Save(orderID string, data []byte) error {
// ... writes SQL insert ...
return nil
}
// 2. Unit test implementation: Saves in-memory (No database needed!)
type FakeStore struct {
SavedOrders map[string][]byte
}
func (f *FakeStore) Save(orderID string, data []byte) error {
f.SavedOrders[orderID] = data
return nil
}The Power of Consumer-Driven Interfaces
In Go, interfaces are satisfied implicitly (structural typing). You donβt have to write class PostgresStore implements StorageEngine. If the struct has the Save() method, it satisfies the interface automatically!
This makes writing clean unit tests and replacing dependencies (like swapping an S3 client for local disk storage) trivial.
Across the Wiki
- Go Interfaces Deep Dive β Small interfaces, composition, and avoiding interface pollution.
- Test Doubles, Mocks & Stubs β How interfaces enable robust unit testing.
- High Cohesion & Loose Coupling β Architectural benefits of decoupled components.