Java OOP in Depth: Inheritance, Polymorphism, Sealed Types, Composition & Object Contracts
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(), childSavingsAccount read()— valid. - Access may not be narrowed:
publiccannot becomeprotected. - 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
equalsmust have the samehashCode. - 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
equalsshould befinal, or userecord. - If you need to add fields, use composition, not inheritance.
- For entities (e.g. JPA), base
equalson 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 |