Nizam Miftahul
← All articles

Java OOP in Depth: Inheritance, Polymorphism, Sealed Types, Composition & Object Contracts

JavaBackendOOPLanguage Fundamentals

1. Inheritance

extends establishes an “is-a” relationship. A class may extend only one class.

public abstract class Account {
    protected final String id;          // protected = API surface for subclasses
    private BigDecimal balance;

    protected Account(String id, BigDecimal initialBalance) {
        this.id = Objects.requireNonNull(id);
        this.balance = Objects.requireNonNull(initialBalance);
    }

    public final BigDecimal balance() { return balance; }

    protected void setBalance(BigDecimal balance) { this.balance = balance; }

    // abstract: subclass must define this
    public abstract BigDecimal monthlyFee();

    // template method: fixed flow, details delegated to the subclass
    public final void applyMonthlyCharge() {
        setBalance(balance.subtract(monthlyFee()));
    }
}
public class SavingsAccount extends Account {
    private final BigDecimal interestRate;

    public SavingsAccount(String id, BigDecimal initialBalance, BigDecimal interestRate) {
        super(id, initialBalance);       // must be the first line
        this.interestRate = interestRate;
    }

    @Override
    public BigDecimal monthlyFee() {
        return BigDecimal.ZERO;
    }
}

Constructor chaining. Constructors are not inherited. The first line of every constructor is always super(...) or this(...); if it isn’t written, the compiler inserts a no-arg super(). Initialization order: parent static fields → child static fields → parent instance fields → parent constructor → child instance fields → child constructor.

A classic trap: calling an overridable method from a constructor.

public class Parent {
    public Parent() { init(); }         // DANGER
    protected void init() { }
}

public class Child extends Parent {
    private final String name = "budi";

    @Override
    protected void init() {
        System.out.println(name);       // null — field not yet initialized
    }
}

Rule of thumb: a constructor should only call private, static, or final methods.

Overriding vs hiding.

Behavior
Instance method Overridden, runtime dispatch
static method Hidden, compile-time dispatch
Field Hidden, compile-time dispatch
private method Invisible to subclass, not overridden
class A { static String who() { return "A"; } String name = "A"; }
class B extends A { static String who() { return "B"; } String name = "B"; }

A ref = new B();
System.out.println(ref.name);      // "A"  <- field hiding, follows the static type
System.out.println(A.who());       // "A"

Field hiding is almost always a bug. Never re-declare a parent’s field name in a child class.

Override rules:

  • The signature must match exactly (name + parameters). Different parameters means overload, not override.
  • The return type may narrow (covariant): parent Account read(), child SavingsAccount read() — valid.
  • Access may not be narrowed: public cannot become protected.
  • Checked exceptions may not be widened; they may be narrowed or dropped.
  • Always write @Override. It’s the compiler’s only way to catch a mistyped signature.

final on a method prevents overriding; on a class it prevents extending.

2. Polymorphism

An object has two types: a static type (the declaration, seen by the compiler) and a dynamic type (the actual object at runtime).

Account account = new SavingsAccount("A-1", new BigDecimal("1000"), rate);
//  ^ static              ^ dynamic
account.monthlyFee();   // dispatched to SavingsAccount at runtime

Overloading is resolved at compile time, based on the static type. This is a frequent source of confusion:

class Printer {
    void print(Account a)        { System.out.println("account"); }
    void print(SavingsAccount s) { System.out.println("savings"); }
}

Account ref = new SavingsAccount(...);
new Printer().print(ref);   // "account" — not "savings"

Practical rule: avoid overloads whose parameters are related by inheritance. If you need type-specific behavior, that’s a job for overriding or pattern matching, not overloading.

instanceof pattern matching (Java 16+):

if (payment instanceof CardPayment card && card.isExpired()) {
    // 'card' is immediately available, already cast
}

Liskov Substitution. A subclass must be able to replace its parent without breaking the caller’s expectations: don’t strengthen preconditions, don’t weaken postconditions, don’t throw new unexpected exceptions.

A famous violation: Square extends Rectangle. setWidth(5) on Rectangle promises the height stays unchanged; Square is forced to break that. The geometric relationship is correct, the behavioral one isn’t. Treat this as an alarm: if an override has to violate the parent’s contract, the hierarchy is wrong.

3. sealed (Java 17+)

sealed restricts who may extend/implement a type. This is what turns an open hierarchy into a closed sum type — the compiler now knows the complete list of possibilities.

public sealed interface PaymentMethod
        permits CardPayment, BankTransfer, VirtualAccount { }

public record CardPayment(String token, YearMonth expiry)   implements PaymentMethod { }
public record BankTransfer(String bankCode, String account) implements PaymentMethod { }
public record VirtualAccount(String vaNumber, String bank)  implements PaymentMethod { }

Every subtype must be one of these three:

Modifier Meaning
final Stops here, cannot be extended further
sealed Continues restricting its own subtypes
non-sealed Reopens the hierarchy to anyone

record is automatically final, so it doesn’t need to be written. If all subtypes live in the same file (or the same package, the same module), the permits clause can be omitted.

The payoff: exhaustive switch.

String describe(PaymentMethod method) {
    return switch (method) {
        case CardPayment c    -> "Card expiring " + c.expiry();
        case BankTransfer b   -> "Transfer " + b.bankCode();
        case VirtualAccount v -> "VA " + v.vaNumber();
        // no default needed — the compiler knows the list is complete
    };
}

The value isn’t just brevity. When you add case QrisPayment, every switch that hasn’t handled it fails to compile immediately. Without sealed, you’d need a default that silently swallows the new case until it’s caught in production.

Record pattern (Java 21):

String describe(PaymentMethod method) {
    return switch (method) {
        case CardPayment(String token, YearMonth expiry)
                when expiry.isBefore(YearMonth.now()) -> "Expired card";
        case CardPayment(String token, YearMonth expiry) -> "Card " + token;
        case BankTransfer(String bank, var acc)         -> bank + "/" + acc;
        case VirtualAccount v                           -> "VA " + v.vaNumber();
    };
}

When to use sealed vs an open interface:

Situation Choice
Closed set of types, operations added frequently sealed + switch
Open set of types (plugins, extension points), stable operations plain interface + polymorphism

The sealed + record combination is how Java models operation results without exceptions:

public sealed interface Result<T> {
    record Ok<T>(T value)        implements Result<T> { }
    record Err<T>(String message) implements Result<T> { }
}

4. Composition

Inheritance ties you to the parent’s implementation. Any internal change in the parent can break the subclass without a single line changing in the child. This is the fragile base class problem.

A real case from the JDK:

public class CountingSet<E> extends HashSet<E> {
    private int addCount = 0;

    @Override public boolean add(E e)                        { addCount++; return super.add(e); }
    @Override public boolean addAll(Collection<? extends E> c) { addCount += c.size(); return super.addAll(c); }

    public int addCount() { return addCount; }
}

var set = new CountingSet<String>();
set.addAll(List.of("a", "b", "c"));
set.addCount();   // 6, not 3

The cause: HashSet.addAll internally calls add, so the count is doubled. You can’t tell that from the signature — only by reading HashSet’s source. And that implementation can change in a future JDK release.

The composition version doesn’t have this problem:

public class CountingSet<E> implements Set<E> {
    private final Set<E> delegate;
    private int addCount = 0;

    public CountingSet(Set<E> delegate) { this.delegate = Objects.requireNonNull(delegate); }

    @Override public boolean add(E e)                          { addCount++; return delegate.add(e); }
    @Override public boolean addAll(Collection<? extends E> c) { addCount += c.size(); return delegate.addAll(c); }

    @Override public int size()                { return delegate.size(); }
    @Override public boolean contains(Object o) { return delegate.contains(o); }
    // ... delegate the rest

    public int addCount() { return addCount; }
}

Bonus: it can now wrap TreeSet, LinkedHashSet, anything.

Composition as a decorator — a pattern you’ll run into often in Spring:

public interface PaymentGateway {
    PaymentResult charge(Money amount, String token);
}

public class RetryingGateway implements PaymentGateway {
    private final PaymentGateway delegate;
    private final int maxAttempts;

    public RetryingGateway(PaymentGateway delegate, int maxAttempts) {
        this.delegate = delegate;
        this.maxAttempts = maxAttempts;
    }

    @Override
    public PaymentResult charge(Money amount, String token) {
        RuntimeException last = null;
        for (int i = 0; i < maxAttempts; i++) {
            try { return delegate.charge(amount, token); }
            catch (TransientGatewayException e) { last = e; }
        }
        throw last;
    }
}

// composed freely at runtime
PaymentGateway gateway =
    new LoggingGateway(new RetryingGateway(new MidtransGateway(config), 3));

Inheritance locks in at compile time; composition is assembled at runtime.

Choosing between them:

Use inheritance when Use composition when
The relationship is genuinely “is-a” and survives the LSP test The relationship is “has-a” or “uses-a”
The parent was designed to be inherited (and documented as such) You’re inheriting a third-party class
You control both parent and child You need to swap behavior at runtime
A template method with fixed steps You need to combine multiple behaviors

The principle: design for inheritance and document it, or forbid it with final. Don’t leave it in between.

5. equals / hashCode / toString

All three come from Object. The default equals implementation is reference comparison, and the default hashCode is identity-based.

The equals contract:

Property Meaning
Reflexive x.equals(x) is always true
Symmetric x.equals(y) ⟺ y.equals(x)
Transitive x=y and y=z ⟹ x=z
Consistent The result doesn’t change while the object is unchanged
Non-null x.equals(null) is always false

The hashCode contract:

  • Objects that are equals must have the same hashCode.
  • Different objects may share a hashCode (collision), but ideally they’re well distributed.
  • Consistent as long as the object doesn’t change.

Violating the first rule makes objects “disappear” from HashMap/HashSet — you put and then get returns null.

Standard implementation:

public final class Customer {
    private final String id;
    private String name;

    @Override
    public boolean equals(Object o) {
        if (this == o) return true;                        // fast path
        if (!(o instanceof Customer other)) return false;  // null check + cast in one
        return id.equals(other.id);
    }

    @Override
    public int hashCode() {
        return Objects.hash(id);        // or id.hashCode() for a single field
    }

    @Override
    public String toString() {
        return "Customer[id=%s, name=%s]".formatted(id, name);
    }
}

Objects.hash(...) allocates a varargs array on every call. For heavily used classes, write it by hand:

@Override
public int hashCode() {
    int result = id.hashCode();
    result = 31 * result + Integer.hashCode(version);
    result = 31 * result + (name == null ? 0 : name.hashCode());
    return result;
}

The symmetry problem with inheritance. This is why equals and inheritance don’t coexist well:

class Point { int x, y;
    public boolean equals(Object o) {
        if (!(o instanceof Point p)) return false;
        return x == p.x && y == p.y;
    }
}

class ColorPoint extends Point { Color color;
    public boolean equals(Object o) {
        if (!(o instanceof ColorPoint cp)) return false;
        return super.equals(o) && color == cp.color;
    }
}

Point p = new Point(1, 2);
ColorPoint cp = new ColorPoint(1, 2, RED);

p.equals(cp);    // true
cp.equals(p);    // false  <- symmetry broken

Replacing instanceof with getClass() fixes symmetry but violates LSP (a subclass with no added fields can never equal its parent). There’s no clean solution. Safe practices:

  • Classes with value-based equals should be final, or use record.
  • If you need to add fields, use composition, not inheritance.
  • For entities (e.g. JPA), base equals on a single identifier, not all fields.

Mutable fields as the basis of equals break HashMap:

var key = new MutableKey("a");
var map = new HashMap<MutableKey, String>();
map.put(key, "value");
key.setName("b");
map.get(key);   // null — the bucket no longer matches

Base equals/hashCode only on final fields.

toString. Not cosmetic — this is what shows up in logs and exception messages. Include fields that help diagnosis, exclude sensitive data (passwords, tokens, national IDs). For record, the default toString prints all components; override it when something needs to be hidden:

public record CardPayment(String token, YearMonth expiry) implements PaymentMethod {
    @Override
    public String toString() {
        return "CardPayment[token=***, expiry=%s]".formatted(expiry);
    }
}

Implementation summary:

Type equals/hashCode
record Automatic, based on all components
Non-record value object Manual, all fields, final class
Entity (has an ID) Manual, ID only
Service, controller, config Don’t write one — leave the default identity-based behavior