Composition vs inheritance — why 'favour composition' is real advice
Intermediate5 min read·eng-12-010
interviewsolid
Concept
Composition vs inheritance — two ways to reuse behavior. "Favour composition over inheritance" is one of the most cited principles in OOP (from the Gang of Four book).
Inheritance: A class extends another class to get its behavior. "Is-a" relationship. Dog extends Animal.
Composition: A class contains instances of other classes to get their behavior. "Has-a" relationship. Dog has-a Voice, Dog has-a Legs.
Why inheritance can go wrong:
- Tight coupling: Subclass depends on parent's implementation. Parent changes break the subclass.
- Fragile base class: A change in the base class can unexpectedly break all subclasses.
- Deep hierarchies: A
AdminPremiumUserWithMobileAccess extends PremiumUser extends AuthenticatedUser extends User— changes at the top ripple down. - Wrong abstraction: You want to reuse ONE behavior, but you inherit everything. A
Stack extends ArrayList— Stack gets all of ArrayList's methods, includingaddAt(index, item)which breaks the stack invariant. - Single inheritance limit: PHP only allows one base class. If you need behaviors from two unrelated classes, you're stuck.
Why composition wins:
- Flexible: Swap behaviors at runtime (Strategy pattern). A
Vehiclewith a pluggableEngine. - Explicit dependencies: You can see what a class depends on from its constructor.
- Testable: Inject mock implementations of composed behaviors.
- No "inheritance pollution": A class gets only the behaviors it needs.
When inheritance IS correct: The relationship genuinely is "is-a" AND the subclass is truly a subtype (Liskov Substitution Principle). Circle and Rectangle both genuinely are shapes. EloquentOrderRepository is an OrderRepository.
Code Example
php
<?php
// ❌ INHERITANCE used for code reuse (wrong reason)
class Logger
{
public function log(string $msg): void { echo "[LOG] {$msg}\n"; }
}
class OrderService extends Logger // WRONG: OrderService IS-NOT-A Logger!
{
public function createOrder(array $data): Order
{
$this->log('Creating order...'); // reusing Logger, but wrong relationship
return Order::create($data);
}
}
// Problem: OrderService now inherits all of Logger's methods as public API
// You can call $orderService->log("anything") from outside — leaking implementation
// ✅ COMPOSITION — inject the dependency
class OrderService
{
public function __construct(
private readonly LoggerInterface $logger, // has-a logger
) {}
public function createOrder(array $data): Order
{
$this->logger->log('Creating order...');
return Order::create($data);
}
}
// Logger is an internal detail, not part of OrderService's public interface
// Swap FileLogger for NullLogger in tests — no subclassing needed
// ❌ DEEP INHERITANCE HIERARCHY — fragile
class User {}
class AuthenticatedUser extends User {}
class PremiumUser extends AuthenticatedUser {}
class AdminUser extends PremiumUser {} // AdminUser gets ALL PremiumUser behavior — is that right?
// ✅ COMPOSITION — mix capabilities
class User
{
public function __construct(
private readonly UserProfile $profile,
private readonly Subscription $subscription, // has-a subscription
private readonly AccessControl $access, // has-a access control
) {}
public function isPremium(): bool { return $this->subscription->isPremium(); }
public function can(string $perm): bool { return $this->access->has($perm); }
}
// Swap subscription type without touching User:
$freeUser = new User($profile, new FreeSubscription(), new BasicAccess());
$premiumUser = new User($profile, new PremiumSubscription(), new FullAccess());
$adminUser = new User($profile, new PremiumSubscription(), new AdminAccess());
// ✅ WHEN INHERITANCE IS CORRECT
abstract class DataExporter
{
final public function export(): string // Template Method — skeleton
{
return $this->format($this->fetchData());
}
abstract protected function fetchData(): array;
abstract protected function format(array $data): string;
}
class CsvExporter extends DataExporter // IS-A DataExporter — correct!
{
protected function fetchData(): array { return \DB::table('users')->get()->toArray(); }
protected function format(array $data): string { return implode("\n", array_map(fn($r) => implode(',', (array)$r), $data)); }
}