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:
Has private final fields wherever possible
Exposes behavior, not state (methods, not getters)
Validates invariants in the constructor
Returns defensive copies or immutable views of any collection it holds
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
SavingsAccountis-aAccount, and every operation onAccountmakes sense onSavingsAccount)The hierarchy is shallow (2 levels max) and closed (you control all subclasses — use
sealed)The parent was designed for extension — documented
protectedhooks, 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
Carhas-anEngine, not is-anEngine)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), orsealedpermitting a fixed set of subclasses, orabstractand 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 ( |
High — subclass sees protected internals |
Use only when you truly need shared implementation |
Interface implementation ( |
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 aget; if there’s anadd, there’s aremovePredictable 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 (
Xis-aYin 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¶
Fields are
private finalClass is
final(orsealed)No setters. Ever. Use “wither” methods that return new instances if you need change:
order.withStatus(SHIPPED)Any collection field is stored via
List.copyOf(),Set.copyOf(),Map.copyOf()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 isfinalunless you have a written reason otherwise.Constructor validates all invariants.
nullfails 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,toStringare generated (record), or written together, or absent for good reason.No
extends AbstractFooBaseunless you ownAbstractFooBaseand 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¶
Take any class from your Phase 01 projects. Add
finalto the class and every field. Fix what breaks.Find any
extendsin your code. Ask: can I make this composition instead? Try it.Write a value object as a
record. Then rewrite the same object as a pre-Java-16 class. Feel the difference.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