0

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, including addAt(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 Vehicle with a pluggable Engine.
  • 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)); }
}