Saltar a contenido

01 - OOP done right

What this session is

About an hour. In From Scratch you learned how to write a class and how to extend one with extends. This session is about when to do which - the single most consequential design judgment in object-oriented Java. By the end you'll know why experienced engineers reach for composition far more than inheritance, and when inheritance is still the right call.

The trap beginners fall into

Inheritance is the first "real OOP" tool you learn, so it's the one you reach for. You have a Vehicle, so you make Car extends Vehicle and Truck extends Vehicle. It feels powerful. Code reuse! Polymorphism! The textbook examples all use it.

Then real requirements arrive, and inheritance starts to hurt. Let's watch it happen.

Inheritance, and where it breaks

Say you're modeling employees. You start clean:

class Employee {
    String name;
    double baseSalary;

    double monthlyPay() {
        return baseSalary / 12;
    }
}

class Manager extends Employee {
    double bonus;

    @Override
    double monthlyPay() {
        return (baseSalary + bonus) / 12;
    }
}

Fine so far. Now the requirements grow:

  • Some employees are contractors (paid hourly, no annual salary).
  • Some managers are also engineers (they still code).
  • Some contractors become managers.
  • An employee can be a part-time intern who is also a contractor.

Try to model this with inheritance and you hit a wall. Manager extends Employee - but what about a contractor who manages? ContractorManager extends Manager? But contractors aren't salaried, and Manager assumed a baseSalary. Now you're overriding methods to undo behavior you inherited. You add EngineerManager, ContractorEngineer, PartTimeContractorIntern... the class count explodes combinatorially, and each new trait doubles it.

This is the core problem with inheritance: it forces a single, rigid "is-a" hierarchy onto things that have many independent traits. A person isn't one thing on a tree. They're a bundle of capabilities - paid this way, has these roles, works this schedule - and those vary independently.

Three specific pains you'll feel:

  1. The fragile base class. When Manager extends Employee, Manager depends on Employee's internals. Change Employee.monthlyPay() and you might silently break Manager - or every subclass. The base class can't evolve safely because subclasses reach into its behavior.

  2. You inherit everything, wanted or not. extends is all-or-nothing. You get every field and method of the parent, even the ones that don't make sense for you. A Stack extends Vector (a real, regretted decision in Java's own standard library) means a Stack exposes Vector's add(index, element) - letting you insert into the middle of a stack, which is nonsense.

  3. Single inheritance is a hard limit. A class can extend exactly one class. The moment something needs traits from two places, inheritance can't express it.

Composition: have-a instead of is-a

Composition flips the model. Instead of a class being a kind of another class, it has the pieces it needs as fields.

Here's the employee model rebuilt with composition:

// Each capability is its own small, focused type.
interface PayStrategy {
    double monthlyPay();
}

record Salaried(double annual, double bonus) implements PayStrategy {
    public double monthlyPay() { return (annual + bonus) / 12; }
}

record Hourly(double rate, double hoursPerMonth) implements PayStrategy {
    public double monthlyPay() { return rate * hoursPerMonth; }
}

// A person HAS a pay strategy and HAS a set of roles - they don't inherit them.
class Person {
    String name;
    PayStrategy pay;
    Set<String> roles;          // "manager", "engineer", "intern" - combine freely

    Person(String name, PayStrategy pay, Set<String> roles) {
        this.name = name;
        this.pay = pay;
        this.roles = roles;
    }

    double monthlyPay() {
        return pay.monthlyPay();
    }

    boolean isManager() {
        return roles.contains("manager");
    }
}

Now the combinatorial explosion is gone:

var alice = new Person("Alice",
        new Salaried(180_000, 30_000),
        Set.of("manager", "engineer"));

var bob = new Person("Bob",
        new Hourly(95, 160),
        Set.of("contractor", "manager"));     // a contractor who manages - no problem

A contractor-manager-engineer is just a Person with an Hourly pay strategy and three roles. No new class. Each trait varies independently because each is a separate field, not a fixed position on a tree.

This is the principle, and it's old enough to be a proverb:

Favor composition over inheritance.

It's the first design rule from the "Gang of Four" Design Patterns book and the most-cited piece of OOP advice for a reason. Composition gives you flexibility (mix traits freely), safety (each piece is independent - changing Hourly can't break Salaried), and testability (you can test Salaried.monthlyPay() in isolation).

When inheritance is right

This isn't "never use inheritance." It's "use it deliberately, for the cases it fits." Inheritance is the right tool when:

  1. There's a genuine, stable "is-a" relationship that won't grow new dimensions. A SavingsAccount truly is an Account. A Circle truly is a Shape. If the hierarchy is shallow and the "is-a" is real and unlikely to need cross-cutting traits, inheritance is clean.

  2. You're implementing a framework's extension point. When you write class MyServlet extends HttpServlet or class MyTest extends TestCase, you're plugging into a contract the framework defined. That's inheritance used as intended.

  3. You want to share a partial implementation via an abstract base. An AbstractList provides the plumbing so concrete lists only implement a few methods. (We'll cover abstract classes in chapter 02.)

The test: if you're overriding methods to remove or undo inherited behavior, inheritance is the wrong tool. That's the code telling you the "is-a" relationship is a lie.

The third tool: program to interfaces

Composition pairs with a second habit: depend on interfaces, not concrete classes. Notice that Person holds a PayStrategy (an interface), not a Salaried or Hourly (concrete types). That one choice means:

  • Person doesn't know or care how pay is calculated. New pay schemes (Commission, Equity) drop in without touching Person.
  • You can test Person with a fake PayStrategy that returns a fixed number.
  • The dependency points at a stable contract, not a volatile implementation.

You met interfaces in From Scratch as "a contract any type can satisfy." The intermediate habit is to reach for them by default when one object needs to use another. We'll go deep on interface design in chapter 02.

Try it

  1. Feel the explosion. Sketch (on paper or in code) a Notification system using inheritance: EmailNotification, SmsNotification, then add "urgent" and "scheduled" as variations. Watch the class count: UrgentEmailNotification, ScheduledSmsNotification... Count how many classes you need for 3 channels × 2 urgencies × 2 timings.

  2. Refactor to composition. Rebuild it: a Notification that has a Channel (interface: Email, Sms, Push), a Priority field, and a Schedule field. How many types now? How do you add a new channel?

  3. Spot the lie. Find this in the wild or write it: a subclass that overrides a method to throw UnsupportedOperationException ("this operation doesn't apply to me"). That's the canonical sign of inheritance abuse - the subclass is rejecting part of what it inherited. Rewrite it with composition so the unsupported operation simply isn't there.

  4. Read Java's own mistake. Look up java.util.Stack (it extends Vector). Notice it inherits get(int), add(int, E), remove(int) - operations that violate stack semantics. Then look at ArrayDeque, the modern recommended stack, which uses composition-friendly design. This is the standard library admitting the lesson.

What you might wonder

"So inheritance is bad?" No - overused inheritance is bad. It's a precise tool for genuine, stable is-a relationships and framework extension points. The problem is reaching for it as the default reuse mechanism. Default to composition; use inheritance when the is-a is real.

"Isn't composition more boilerplate?" Sometimes slightly more upfront - you write a field and delegate a method instead of getting it free from extends. But it pays back fast: independent pieces, no fragile base class, easy testing, free mixing of traits. Records and modern Java keep the boilerplate small. The tradeoff is almost always worth it.

"What about default methods on interfaces - isn't that inheritance?" Interfaces can provide default method implementations (since Java 8), which is a limited form of shared behavior. It's safer than class inheritance - interfaces have no fields, so there's no fragile-base-class state problem - and a class can implement many interfaces. We'll cover this in chapter 02.

"How does this relate to 'SOLID'?" "Favor composition over inheritance" supports several SOLID principles at once - especially the Open/Closed Principle (extend behavior by adding new strategy implementations, not by editing existing classes) and Dependency Inversion (depend on the PayStrategy abstraction, not concrete pay types). You don't need to memorize SOLID as an acronym; you need the habits, and this is the central one.

Done

  • You know why inheritance breaks down: rigid single hierarchy, fragile base class, all-or-nothing reuse.
  • You know composition: model traits as fields (often interface-typed) that vary independently.
  • You know the rule - favor composition over inheritance - and the cases where inheritance still wins.
  • You know the tell: overriding to undo inherited behavior means you picked the wrong tool.

This is the foundation for everything in the design half of this path. Next we go deep on the contracts themselves: interfaces and abstract classes.

Next: Interfaces and abstract classes in depth →

Comments