OOP First Principles — In the Modern Context

Every Java tutorial recites the same four pillars: encapsulation, inheritance, polymorphism, abstraction. That list has been repeated since the Java 1.1 era and it is largely useless as taught. What matters is not memorizing the pillars but understanding why each one exists, what it costs, and when to reach for it. This file walks the four principles from a modern-Java perspective, then makes the honest case for composition over inheritance — the single most important OOP shift of the last two decades.

We’ll also cover immutability by default, interface segregation as a design habit, and the small set of rules that separate a maintainable class from a headache.


1. Encapsulation — The Only Pillar That Really Matters

Encapsulation is the practice of hiding data and exposing behavior. If exactly one pillar survives study scrutiny, this is it. Everything else in OOP is a consequence of taking encapsulation seriously.

The rule

A well-encapsulated class:

  1. Has private final fields wherever possible

  2. Exposes behavior, not state (methods, not getters)

  3. Validates invariants in the constructor

  4. Returns defensive copies or immutable views of any collection it holds

  5. Never leaks a reference to a mutable internal object

// BAD: pretends to be encapsulated, isn't
public class Order {
    private List<LineItem> items = new ArrayList<>();
    public List<LineItem> getItems() { return items; } // leak!
    public void setItems(List<LineItem> items) { this.items = items; } // total surrender
}

// GOOD: real encapsulation
public final class Order {
    private final List<LineItem> items;

    public Order(List<LineItem> items) {
        Objects.requireNonNull(items, "items");
        if (items.isEmpty()) throw new IllegalArgumentException("order needs at least one item");
        this.items = List.copyOf(items); // defensive copy, unmodifiable
    }

    public BigDecimal total() {
        return items.stream()
            .map(LineItem::subtotal)
            .reduce(BigDecimal.ZERO, BigDecimal::add);
    }

    public List<LineItem> items() {
        return items; // already unmodifiable
    }
}

Notice the second version exposes total() — behavior — not just data. If tomorrow we add tax calculation, the change lives inside Order. Callers don’t touch.

> ⚠️ What most people get wrong

“Encapsulation means using private fields with public getters and setters.” No. That’s just syntactic encapsulation. If your setter lets any caller put the object into an invalid state, you have no encapsulation, you have a struct with ceremony. Real encapsulation means the outside world cannot force this object into a bad state.


2. Inheritance — Use Sparingly

Inheritance (extends) creates an is-a relationship. It’s the tightest coupling Java has: a subclass depends on protected internals of every ancestor. Change a superclass, break every subclass. This is why composition over inheritance is not a slogan but a survival strategy.

When inheritance is defensible

  • The subclass genuinely is-a parent (a SavingsAccount is-a Account, and every operation on Account makes sense on SavingsAccount)

  • The hierarchy is shallow (2 levels max) and closed (you control all subclasses — use sealed)

  • The parent was designed for extension — documented protected hooks, no self-use of overridable methods

When inheritance is a trap

  • You just want code reuse — use composition or a static helper

  • The relationship is has-a or uses-a (a Car has-an Engine, not is-an Engine)

  • The parent is from a library you don’t own — extending third-party classes is fragile

  • You override to change behavior rather than fill in behavior (LSP violation waiting to happen)

The Bloch rule (Effective Java, Item 18-19)

Design and document for inheritance, or else prohibit it.

In practice, that means every class you write should be either:

  • final (default choice), or

  • sealed permitting a fixed set of subclasses, or

  • abstract and explicitly designed for extension with documented hooks

A plain non-final concrete class is almost always a mistake.


3. Polymorphism — Interfaces Do It Better

Polymorphism means one name, many behaviors, chosen at runtime. In Java, you get this three ways:

Mechanism

Coupling

Modern verdict

Class inheritance (extends)

High — subclass sees protected internals

Use only when you truly need shared implementation

Interface implementation (implements)

Low — only the contract is shared

Default choice

Sealed interface + pattern matching

Low + exhaustive

New killer combo for closed hierarchies

Prefer interfaces

// Instead of an abstract PaymentProcessor class...
public interface PaymentProcessor {
    Receipt charge(Money amount, Card card);
}

// concrete implementations don't share code they don't need
public final class StripeProcessor implements PaymentProcessor { /* ... */ }
public final class RazorpayProcessor implements PaymentProcessor { /* ... */ }

If StripeProcessor and RazorpayProcessor share some helper logic, put it in a package-private static utility class or inject a collaborator. Do not invent AbstractPaymentProcessor just to hold a helper method.

Sealed interfaces: the new option

When you do want a closed set of implementations (say, three payment methods and no more), Java 21 sealed types give you inheritance without the open-world hazard:

public sealed interface Payment permits CardPayment, UpiPayment, WalletPayment {}

public record CardPayment(String pan, YearMonth expiry) implements Payment {}
public record UpiPayment(String vpa) implements Payment {}
public record WalletPayment(String walletId) implements Payment {}

The compiler now guarantees that a switch (payment) handling all three cases is exhaustive. No default clause. No hidden fourth type sneaking in from a library. See 04_records_sealed_pattern_matching.md.


4. Abstraction — The Vague One

Abstraction is the most abused word in OOP. In practice it means: hide the how, show the what. Every good API is an abstraction. The List interface is an abstraction over ArrayList, LinkedList, and CopyOnWriteArrayList.

Good abstractions have these traits:

  • Minimal surface — 5 methods, not 50

  • Orthogonal — each method does one thing not achievable by combining the others

  • Symmetric — if there’s a put, there’s a get; if there’s an add, there’s a remove

  • Predictable performance — the contract states which operations are O(1) vs O(n)

Bad abstractions leak. java.io.InputStream leaks the fact that reads can block, throw IOException, return -1 for EOF, and require closing. Every caller now knows about all four concerns. That’s why Java 11 added InputStream.readAllBytes() — a better abstraction for a common case.

The abstraction test

Before adding a method to an interface, ask: if I remove this method, can the caller still achieve the goal with the remaining methods? If yes, the method is redundant. If no but the goal is uncommon, the method belongs in a helper, not the interface.


5. Composition Over Inheritance — The Honest Argument

This is the single most important OOP shift you need to internalize. The phrase gets thrown around like a mantra; here is the actual reasoning.

Why inheritance breaks

A subclass depends on:

  • The parent’s public API (fine, that’s a contract)

  • The parent’s protected members (fragile — they can change)

  • The parent’s self-use patterns (which methods call which — rarely documented, always assumed)

The classic example (from Bloch, Item 18): you extend HashSet to count how many elements have been added. You override add(e) and addAll(c) to increment a counter. It fails. Why? Because HashSet.addAll internally calls add, so every element counts twice. You now depend on an implementation detail of HashSet that Sun never promised.

Composition

Rather than extending HashSet, you wrap it:

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

    @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);
    }

    public int addCount() { return addCount; }

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

Yes, it’s more typing. That’s the cost. The benefit is you own the counting logic and it doesn’t depend on HashSet’s internal call graph. If Oracle rewrites HashSet.addAll tomorrow, your code still works.

The rule

Use inheritance when you truly need substitutability (X is-a Y in the LSP sense) and you own or trust the parent. Use composition for everything else, especially code reuse.


6. Immutability by Default

Modern Java strongly nudges you toward immutable objects. record is the most obvious signal, but the discipline predates records.

Why immutable

  • Thread-safe for free — no locks, no memory barriers to reason about

  • Safe to share — pass around, put in maps, cache freely

  • Simpler equals/hashCode — no risk of a key mutating inside a map

  • Easier to test — no order-of-operations bugs

The rules

  1. Fields are private final

  2. Class is final (or sealed)

  3. No setters. Ever. Use “wither” methods that return new instances if you need change: order.withStatus(SHIPPED)

  4. Any collection field is stored via List.copyOf(), Set.copyOf(), Map.copyOf()

  5. Any date/time uses java.time (Instant, LocalDate — already immutable)

When to break the rule

  • Very large objects where wither-methods would allocate too much — use a builder

  • Objects whose lifecycle is short and single-threaded (e.g., a request-scoped accumulator)

  • Interoperability with frameworks that require setters (older JPA — modern Hibernate is fine with records-ish patterns)


7. Interface Segregation as a Habit

ISP is one letter of SOLID, covered in the next file. But it deserves a mention here because it’s the daily habit that keeps your interfaces honest.

Rule of thumb: if a caller implements your interface and has to throw UnsupportedOperationException from a method, your interface is wrong.

java.util.List violates this itself — List.of(1,2,3).add(4) throws. That’s a legacy wart, not a role model. When you design your own interfaces, split them until every method makes sense for every implementation.


8. The “Would I Hire This Code?” Checklist

Before you commit a new class, run it through this list:

  • Every field is private. Every field is final unless you have a written reason otherwise.

  • Constructor validates all invariants. null fails fast with a clear message.

  • No public getter returns a mutable internal object without a defensive copy.

  • The class is final, sealed, or explicitly designed and documented for extension.

  • It implements an interface if it will have more than one impl — not “just in case.”

  • equals, hashCode, toString are generated (record), or written together, or absent for good reason.

  • No extends AbstractFooBase unless you own AbstractFooBase and it earns its keep.

  • The class has one clear responsibility you can state in one sentence.

If you can’t check at least 7 of 8, refactor before you push.


What to Practice This Week

  1. Take any class from your Phase 01 projects. Add final to the class and every field. Fix what breaks.

  2. Find any extends in your code. Ask: can I make this composition instead? Try it.

  3. Write a value object as a record. Then rewrite the same object as a pre-Java-16 class. Feel the difference.

  4. Read Effective Java items 15 through 22. That’s the encapsulation and inheritance chapter, and it’s the single densest 40 pages in Java literature.


Return to README.md · Next: 02_solid_in_practice.md