DEV Community

Xuan
Xuan

Posted on

You're Still Using Inheritance Wrong! This OOD Rule Will Blow Your Mind

Ever feel like your object-oriented code, which started so clean, somehow ended up as a tangled mess that’s a nightmare to change? You’re not alone. Many of us were taught to reach for inheritance as the go-to tool for code reuse, and it seems so straightforward at first. Create a base class, then just extend it! Easy, right?

Well, not always. In fact, more often than you might think, reaching for inheritance is actually the root of those future headaches. And there’s one powerful, often overlooked rule in object-oriented design that, once you truly grasp it, will fundamentally change how you build your software. It might just blow your mind.

The Siren Song of Inheritance

Let's be honest, inheritance is seductive. It promises a clear hierarchy, shared behavior, and the holy grail of "code reuse." You have a Vehicle class, and then Car and Motorcycle extend it. Makes sense. They are vehicles. Then ElectricCar extends Car. Still seems okay.

But then, what if you need a Bicycle? Does it have an engine? No. But it is a Vehicle. Suddenly, your elegant hierarchy starts to strain. Or what if a Car needs to Fly? Do you add Fly() to Vehicle? Or Car? But not all cars fly. Do you create FlyingCar? This quickly leads to:

  • Rigid Hierarchies: Your classes become tightly coupled. Changing something in a base class can unexpectedly affect many subclasses, leading to the "fragile base class" problem.
  • The "Diamond Problem": In some languages, inheriting from multiple classes with shared ancestors creates ambiguity.
  • "Is-a" vs. "Has-a" Confusion: We often force an "is-a" relationship (inheritance) when a "has-a" relationship (composition) would be much more natural and flexible. This is where things really go wrong.
  • Lack of Flexibility: Once you’ve inherited, you're stuck with that parent's behavior. It’s hard to swap out parts of an object’s functionality at runtime, or to combine behaviors in new ways.

You started with good intentions, but you built a tightly knit family where everyone knows everyone else’s business, making it impossible for anyone to move out or try something new without causing drama.

The Rule That Will Set You Free: Favor Composition Over Inheritance

This isn't just a suggestion; it's a fundamental principle of good object-oriented design, often abbreviated to "FCoI." It means that instead of trying to inherit behavior from a parent class, you should prefer to build your objects by combining simpler objects that encapsulate specific behaviors.

Think of it like building with LEGO bricks instead of carving a statue out of a single block.

What is Composition?

Composition is about creating objects that contain or have other objects. Instead of saying a Car is a Vehicle that has an Engine, you might say a Car has an Engine and has Wheels and has a Chassis. The Car then delegates tasks to these internal parts.

For example, instead of ElectricCar inheriting its drive() method from Car, ElectricCar might have an ElectricMotor object, and its drive() method would call electricMotor.run(). A GasCar would have an InternalCombustionEngine object, and its drive() would call internalCombustionEngine.run().

Why Does Composition Win?

  1. Flexibility and Reusability: This is the big one.

    • Need a FlyingCar? Create a Car that has a FlightModule object.
    • Need a Bicycle? It has Wheels, has Pedals, but doesn't have an Engine.
    • Want to upgrade an ElectricCar's battery? Just swap out its Battery object for a new one, without touching its ElectricMotor or Chassis. Try doing that with inheritance – you'd need a whole new class!
  2. Loose Coupling: Objects are less dependent on each other. If you change how an Engine works, it only affects classes that use an Engine object, not necessarily every single subclass in a deep hierarchy. This makes your code easier to maintain and less prone to unexpected breakages.

  3. Easier Testing: Because your objects are composed of smaller, more focused parts, it's much easier to test those parts in isolation. You can "mock" or "stub" the internal components during testing, making your tests faster and more reliable.

  4. Runtime Behavior Changes: With composition, you can often change an object's behavior dynamically at runtime by swapping out one of its component objects. Imagine a game character changing its attack style by swapping its Weapon object from a Sword to a Bow. Inheritance typically fixes behavior at compile time.

  5. Simpler Hierarchies: Your inheritance hierarchies become much shallower and clearer. You only use inheritance when there's a true, undeniable "is-a" relationship that genuinely benefits from polymorphism.

So, When Is Inheritance Okay?

Does this mean inheritance is evil? Absolutely not! Like any tool, it has its place. Use inheritance when:

  1. There’s a clear "is-a" relationship: A Dog is a Mammal, a Square is a Shape. This is crucial.
  2. You want to share interface and core behavior: Inheritance is great for defining a common contract or abstract behavior that all subclasses must adhere to, or for providing default implementations that are genuinely common to all subtypes without breaking the Liskov Substitution Principle (LSP). LSP simply means that if you can use an object of a base class, you should be able to use an object of any derived class without breaking anything.
  3. Polymorphism is key: You want to treat different types of objects in a uniform way through a common base type or interface. For example, a list of Shape objects, each with its own draw() method.

If you find yourself extending a class just to reuse a single method, or to customize a tiny bit of behavior, that’s a red flag. Composition is likely the better choice.

A Simple Mental Shift: Thinking Composably

Let's quickly revisit our Vehicle example.

The "Inheritance First" Approach (often problematic):

class Vehicle {
    startEngine() { ... }
    stopEngine() { ... }
    drive() { ... }
}

class Car extends Vehicle {
    // inherits engine methods, overrides drive()
}

class Bicycle extends Vehicle {
    // inherits startEngine(), stopEngine() which make no sense for a bicycle!
    // has to override them to do nothing or throw an error. Bad.
}
Enter fullscreen mode Exit fullscreen mode

The "Composition First" Approach (more flexible):

interface Drivable {
    drive();
}

interface Engined {
    startEngine();
    stopEngine();
}

class GasEngine implements Engined {
    startEngine() { console.log("Vroom!"); }
    stopEngine() { console.log("Pffft."); }
}

class ElectricMotor implements Engined {
    startEngine() { console.log("Whirr..."); }
    stopEngine() { console.log("Silence."); }
}

class Car implements Drivable {
    engine: Engined; // Car HAS-A Engine

    constructor(engine: Engined) {
        this.engine = engine;
    }

    drive() {
        this.engine.startEngine();
        console.log("Cruising...");
        this.engine.stopEngine();
    }
}

class Bicycle implements Drivable {
    drive() {
        console.log("Pedaling hard!");
    }
}

// Now you can easily create:
const gasCar = new Car(new GasEngine());
const electricCar = new Car(new ElectricMotor());
const simpleBike = new Bicycle();

gasCar.drive();      // Vroom! Cruising... Pffft.
electricCar.drive(); // Whirr... Cruising... Silence.
simpleBike.drive();  // Pedaling hard!
Enter fullscreen mode Exit fullscreen mode

Notice how Bicycle doesn't inherit unnecessary engine methods. Car has an engine, and we can easily swap what kind of engine it has without changing the Car class itself. This is immensely powerful.

Embrace the Shift

This rule—"Favor Composition Over Inheritance"—is a game-changer. It nudges you towards building systems that are more modular, more flexible, and easier to understand and maintain. It's about designing objects that work together harmoniously, each doing its part, rather than forcing them into rigid, stifling hierarchies.

It might feel a little awkward at first if you’ve been relying heavily on inheritance. But as you practice thinking in terms of "has-a" relationships and delegating responsibilities, you'll start to see your code become more elegant and adaptable. Your future self (and your teammates) will thank you.

So, next time you instinctively reach for extends, pause. Ask yourself: "Does this object truly is-a type of that object, or does it simply have-a particular capability or component?" That moment of reflection is where the magic happens. Your mind, and your code, will be better for it.

Top comments (0)