You finished the beginner course: you can declare a class, hide a field behind a getter, extend a base class, override a method, implement an interface. Advanced Java starts where that stops. The syntax is no longer the hard part. The hard part is that a design can be flawless Java and still be the wrong design, and javac will never mention it.
This first article revisits the four principles, not to define them again but to ask what each one costs when it is applied mechanically. Every section has the same shape: a class that looks encapsulated, or a subclass that looks like a subclass, and then the program that shows why it is neither.
![]()
Every program, output block and compiler message below was produced by compiling and running the code on OpenJDK 21.0.6. Nothing here is quoted from memory.
What this advanced course assumes
This series assumes the whole beginner course: classes and objects, constructors, static and final, arrays and collections, exceptions, and the four principles at the level of what the keywords do. None of that is re-taught here.
What changes is the question. A beginner asks what extends gives them. This course asks what extends costs them, and what it stops them from changing six months later. Those have different answers, and the second one decides whether a codebase can absorb a new requirement or has to be rewritten around it.

The four rows above are the four sections that follow, in order. Each one starts from code that compiles today, runs today, and passes whatever test its author wrote for it.
Encapsulation is enforced by behaviour, not by accessors
An invariant is a statement about an object that must be true before and after every public call. "Stock is never negative" is an invariant. The object either enforces it or it does not, and private on the field decides nothing on its own.
Here is a warehouse whose fields are private and whose access goes through a method. It still cannot defend anything:
import java.util.HashMap;
import java.util.Map;
public class AskInventory {
private final Map<String, Integer> stock = new HashMap<>();
public AskInventory() {
stock.put("SKU-1", 3);
}
public Map<String, Integer> getStock() { // the invariant leaves the object here
return stock;
}
public static void main(String[] args) {
AskInventory inv = new AskInventory();
// call site 1: remembers the rule
if (inv.getStock().get("SKU-1") >= 2) {
inv.getStock().put("SKU-1", inv.getStock().get("SKU-1") - 2);
}
System.out.println("after call site 1: " + inv.getStock());
// call site 2: forgets it
inv.getStock().put("SKU-1", inv.getStock().get("SKU-1") - 5);
System.out.println("after call site 2: " + inv.getStock());
}
}
after call site 1: {SKU-1=1}
after call site 2: {SKU-1=-4}
The stock is negative and no method of AskInventory ran to make it so. The rule "never sell more than you hold" is not in the class; it is in call site 1, written by a developer who remembered it. Call site 2 is the same rule written by a developer who did not, and there is nowhere for the object to notice.
That is the actual test for encapsulation: not "are the fields private" but "can a caller reach an invalid state without going through my behaviour". Move the rule inside and the question answers itself:
import java.util.HashMap;
import java.util.Map;
final class Inventory {
private final Map<String, Integer> stock = new HashMap<>();
Inventory() {
stock.put("SKU-1", 3);
}
/** The rule lives here, once. Callers cannot skip it. */
boolean reserve(String sku, int qty) {
if (qty <= 0) {
throw new IllegalArgumentException("qty must be positive: " + qty);
}
int have = stock.getOrDefault(sku, 0);
if (have < qty) {
return false;
}
stock.put(sku, have - qty);
return true;
}
int available(String sku) {
return stock.getOrDefault(sku, 0);
}
}
public class TellInventory {
public static void main(String[] args) {
Inventory inv = new Inventory();
System.out.println("call site 1: " + inv.reserve("SKU-1", 2) + " left " + inv.available("SKU-1"));
System.out.println("call site 2: " + inv.reserve("SKU-1", 5) + " left " + inv.available("SKU-1"));
try {
inv.reserve("SKU-1", 0);
} catch (IllegalArgumentException e) {
System.out.println("call site 3: " + e.getMessage());
}
}
}
call site 1: true left 1
call site 2: false left 1
call site 3: qty must be positive: 0
reserve is not a setter with validation bolted on. It is one operation that reads and writes the state together, so there is no window between the check and the act for a caller to occupy. available still exposes a number, but a number is a copy of a value — handing it out gives away nothing.
The difference is not politeness. It is that the second class has exactly one place to fix when the rule changes, and the first has as many places as it has callers.
An unmodifiable view is not a snapshot
The usual repair for a leaking getter is Collections.unmodifiableList. It blocks the caller from writing, which is half the problem, and it is routinely mistaken for the other half:
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
final class Cart {
private final List<String> items = new ArrayList<>();
void add(String item) { items.add(item); }
List<String> view() { return Collections.unmodifiableList(items); } // wrapper
List<String> snapshot() { return List.copyOf(items); } // copy
}
public class LiveView {
public static void main(String[] args) {
Cart cart = new Cart();
cart.add("book");
cart.add("pen");
List<String> view = cart.view();
List<String> snap = cart.snapshot();
System.out.println("view " + view + " snapshot " + snap);
cart.add("mug"); // the object mutates itself
System.out.println("view " + view + " snapshot " + snap);
try {
for (String s : view) { // reading the "read-only" list
if (s.equals("book")) cart.add("lamp");
}
} catch (Exception e) {
System.out.println("iterating the view threw " + e.getClass().getName());
}
System.out.println("view is a wrapper? " + (view.getClass().getSimpleName()));
System.out.println("snapshot type " + (snap.getClass().getSimpleName()));
}
}
view [book, pen] snapshot [book, pen]
view [book, pen, mug] snapshot [book, pen]
iterating the view threw java.util.ConcurrentModificationException
view is a wrapper? UnmodifiableRandomAccessList
snapshot type List12
The wrapper is read-only for the caller and fully live for the owner. A caller that stores it, believing it holds a stable list, is holding a window into an object that keeps changing — and iterating that window while the owner appends throws ConcurrentModificationException, which is the caller's stack trace for the owner's write.
⚠️
Collections.unmodifiableList(items)protects the field from the caller. It does not protect the caller from the field. Only a copy does that.
| What the getter returns | Reflects later internal changes | Caller can mutate it | Allocated per call |
|---|---|---|---|
items, the field itself | yes | yes | nothing |
Collections.unmodifiableList(items) | yes | no | one wrapper object |
List.copyOf(items) | no | no | one list plus size() element copies |
| no list at all, just an answer | not applicable | no | nothing |
The last row is the one this section is arguing for. contains, total, isEmpty and count return values, and a value cannot be aliased.
What a defensive copy actually costs
"Copy on the way out" is good advice with a price tag, and the price is not a stopwatch reading — it is a count of objects created and references moved, which is the same on every machine. Instrument it and the number is exact:
import java.util.ArrayList;
import java.util.List;
final class Catalog {
static long listsAllocated = 0;
static long referencesCopied = 0;
private final List<Integer> prices = new ArrayList<>();
Catalog(int n) {
for (int i = 1; i <= n; i++) prices.add(i);
}
/** accessor style: every caller gets its own defensive copy */
List<Integer> getPrices() {
listsAllocated++;
referencesCopied += prices.size(); // new ArrayList<>(c) copies c.size() references
return new ArrayList<>(prices);
}
/** behaviour style: the question is answered where the data lives */
long totalAbove(int floor) {
long sum = 0;
for (int p : prices) if (p > floor) sum += p;
return sum;
}
}
public class CopyCost {
public static void main(String[] args) {
int size = 500, queries = 1000;
Catalog c = new Catalog(size);
long viaAccessor = 0;
for (int q = 0; q < queries; q++) {
for (int p : c.getPrices()) if (p > 400) viaAccessor += p;
}
System.out.println("accessor total=" + viaAccessor
+ " lists allocated=" + Catalog.listsAllocated
+ " references copied=" + Catalog.referencesCopied);
long viaBehaviour = 0;
for (int q = 0; q < queries; q++) viaBehaviour += c.totalAbove(400);
System.out.println("behaviour total=" + viaBehaviour
+ " lists allocated=0 references copied=0");
}
}
accessor total=45050000 lists allocated=1000 references copied=500000
behaviour total=45050000 lists allocated=0 references copied=0
Same answer, and the accessor route created a thousand lists and moved half a million references to produce it. The cost is structural: one allocation and size() reference copies per call, every call, whether or not the caller reads more than one element.
None of that makes defensive copying wrong. It makes it a decision. A getter that leaks the field is a correctness bug; a getter that copies is correct and linear in the collection size; a method that answers the caller's actual question is correct and allocates nothing. Reach for the third before you argue about the first two.
Inheritance: when extends produces a class that is wrong
A subclass adds a guard. That is the most common reason to write extends at all — take a working class, and make it stricter. BoundedBuffer allows at most three lines:
import java.util.ArrayList;
import java.util.List;
class Buffer {
private final List<String> lines = new ArrayList<>();
void append(String line) { lines.add(line); }
void insertAt(int i, String line) { lines.add(i, line); }
int size() { return lines.size(); }
String render() { return String.join(" | ", lines); }
}
class BoundedBuffer extends Buffer {
private final int capacity;
BoundedBuffer(int capacity) { this.capacity = capacity; }
@Override
void append(String line) {
if (size() == capacity) {
throw new IllegalStateException("buffer full at " + capacity);
}
super.append(line);
}
int capacity() { return capacity; }
}
public class BackDoor {
public static void main(String[] args) {
BoundedBuffer b = new BoundedBuffer(3);
b.append("a");
b.append("b");
b.append("c");
try {
b.append("d"); // the subclass's own test: passes
} catch (IllegalStateException e) {
System.out.println("append rejected: " + e.getMessage());
}
System.out.println("size " + b.size() + " [" + b.render() + "]");
b.insertAt(0, "x"); // inherited, never overridden
System.out.println("size " + b.size() + " [" + b.render() + "]");
System.out.println("over capacity? " + (b.size() > b.capacity()));
}
}
append rejected: buffer full at 3
size 3 [a | b | c]
size 4 [x | a | b | c]
over capacity? true
The guard works, and the object is over capacity anyway. insertAt is a perfectly reasonable method on Buffer, the subclass author never thought about it, and extends published it on BoundedBuffer regardless. There is no keyword for "inherit the implementation but not this method"; the surface comes as one piece.
That is the failure worth internalising: extends does not let you choose which methods your class publishes. Every public method the superclass has, and every public method it gains in its next version, is a method of yours — including the ones that write to state your guard was protecting.
The beginner series showed the other member of this family, where a subclass's counter comes out wrong because the superclass calls its own overridable methods internally. Both have the same root: a subclass is coupled to the superclass's implementation, not merely to its API. This one needs no library and no refactoring to reproduce — it is broken on the day it is written.

The delegation rewrite
Composition means holding the object and forwarding to it, so the surface is a list you wrote rather than a list you inherited:
import java.util.ArrayList;
import java.util.List;
class Buffer {
private final List<String> lines = new ArrayList<>();
void append(String line) { lines.add(line); }
void insertAt(int i, String line) { lines.add(i, line); }
int size() { return lines.size(); }
String render() { return String.join(" | ", lines); }
}
final class BoundedLog {
private final Buffer inner = new Buffer(); // held, not inherited
private final int capacity;
BoundedLog(int capacity) { this.capacity = capacity; }
void append(String line) {
if (inner.size() == capacity) {
throw new IllegalStateException("buffer full at " + capacity);
}
inner.append(line);
}
int size() { return inner.size(); }
String render() { return inner.render(); }
}
public class Composed {
public static void main(String[] args) {
BoundedLog log = new BoundedLog(3);
log.append("a");
log.append("b");
log.append("c");
try {
log.append("d");
} catch (IllegalStateException e) {
System.out.println("append rejected: " + e.getMessage());
}
System.out.println("size " + log.size() + " [" + log.render() + "]");
System.out.println("over capacity? " + (log.size() > 3));
}
}
append rejected: buffer full at 3
size 3 [a | b | c]
over capacity? false
Buffer is unchanged, insertAt still exists on it, and it is unreachable from outside BoundedLog because nothing forwards to it. Writing log.insertAt(0, "x") is no longer a design mistake that runs — it is a compile error:
NoInsert.java:5: error: cannot find symbol
log.insertAt(0, "x");
^
symbol: method insertAt(int,String)
location: variable log of type BoundedLog
1 error
The invariant moved from "everyone please avoid this method" to "this method does not exist here", which is the only version a compiler can help you with.
The question to ask before writing extends
The usual advice is to test the sentence "a BoundedBuffer is a Buffer". It is true here, and it did not save the class. A sharper test:
| Question | If the answer is | Then |
|---|---|---|
| Does every method the superclass publishes preserve my invariant? | no | compose |
| Do I need to pass my object where the supertype is expected? | yes | inherit, and read the substitutability section below |
| Will the superclass keep growing across versions? | yes | compose |
| Do I want the superclass's entire public API on my class, forever? | no | compose |
Delegation is not free, and the cost is honest and visible: one forwarding method per operation you re-expose. BoundedLog writes three. A wrapper around a forty-method interface writes forty, which is a real reason to hesitate — but notice what you are buying, because those forty methods are also forty places you can refuse, rename, narrow or validate. Inheritance charges nothing up front and takes the choice away.
Polymorphism: the type you own versus the type you do not
Adding a case to a system means adding a class, and adding an operation means adding a method. Whether either is cheap depends on one thing: who owns the type.
When you own it, adding an operation is a compile-time task list. Put label() on the interface and every implementation is required to answer:
import java.util.List;
interface PaymentMethod {
String authorize(long cents);
String label(); // added in version 2
}
final class Card implements PaymentMethod {
private final String last4;
Card(String last4) { this.last4 = last4; }
public String authorize(long cents) { return "captured " + cents; }
public String label() { return "Card ****" + last4; }
}
final class BankTransfer implements PaymentMethod {
public String authorize(long cents) { return "queued " + cents; }
public String label() { return "Bank transfer"; }
}
public class Own {
static void checkout(List<PaymentMethod> methods, long cents) {
for (PaymentMethod m : methods) { // one call site, every implementation
System.out.println(m.label() + " -> " + m.authorize(cents));
}
}
public static void main(String[] args) {
checkout(List.of(new Card("4242"), new BankTransfer()), 1999);
}
}
Card ****4242 -> captured 1999
Bank transfer -> queued 1999
Delete label() from BankTransfer and the build stops with the exact class named:
Broken.java:6: error: BankTransfer is not abstract and does not override abstract method label() in PaymentMethod
class BankTransfer implements PaymentMethod {
^
1 error
That error is the whole value of designing against a type you own. The set of implementations is knowable, the compiler walks it for you, and "we forgot to update one of them" stops being a category of production bug.
When a switch over types is honest
The advice "replace type checks with polymorphism" is right often enough to be repeated everywhere, and it has two real exceptions.
The first is an operation that does not belong on the type. A document tree should not know about HTML, about plain-text export, about word counts, about your logging format. Push all of that onto the node classes and every rendering concern in the system ends up living inside your domain model. Declare the hierarchy sealed instead, and the compiler proves the switch covers every case without the operation moving in:
import java.util.List;
sealed interface Node permits Text, Bold, Group {}
record Text(String value) implements Node {}
record Bold(Node child) implements Node {}
record Group(List<Node> children) implements Node {}
public class Doc {
static String html(Node n) {
return switch (n) { // no default: the compiler proves coverage
case Text t -> t.value();
case Bold b -> "<b>" + html(b.child()) + "</b>";
case Group g -> g.children().stream().map(Doc::html).reduce("", String::concat);
};
}
static int words(Node n) {
return switch (n) {
case Text t -> t.value().isBlank() ? 0 : t.value().trim().split("\\s+").length;
case Bold b -> words(b.child());
case Group g -> g.children().stream().mapToInt(Doc::words).sum();
};
}
public static void main(String[] args) {
Node doc = new Group(List.of(new Text("hello "), new Bold(new Text("brave")), new Text(" world")));
System.out.println(html(doc));
System.out.println("words = " + words(doc));
}
}
hello <b>brave</b> world
words = 3
There is no default branch, and that is the point. Add Link to the permits clause without touching html, and the switch stops compiling:
Doc2.java:12: error: the switch expression does not cover all possible input values
return switch (n) {
^
1 error
A sealed hierarchy plus an exhaustive switch gives the same "you missed one" guarantee an abstract method gives, while leaving the operation outside the type. That is a legitimate design, not a fallback.
The second exception is simpler: you cannot add a method to a type you did not write. Values arriving from a parsed configuration are Object, and no amount of good design puts a describe() method on java.lang.Integer:
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
public class Foreign {
static String describe(Object v) {
return switch (v) { // default is mandatory: Object is open
case null -> "absent";
case Integer i -> "int " + i;
case Boolean b -> "flag " + b;
case String s -> "text " + s;
case List<?> l -> "list of " + l.size();
default -> v.getClass().getSimpleName() + " (unhandled)";
};
}
public static void main(String[] args) {
Map<String, Object> config = new LinkedHashMap<>();
config.put("port", 8080);
config.put("debug", true);
config.put("host", "localhost");
config.put("tags", List.of("a", "b"));
config.put("ratio", 0.5d);
config.put("missing", null);
config.forEach((k, v) -> System.out.println(k + " = " + describe(v)));
}
}
port = int 8080
debug = flag true
host = text localhost
tags = list of 2
ratio = Double (unhandled)
missing = absent
Object is not sealed, so the default is mandatory and ratio falls into it. The switch is honest because there is no alternative: the types belong to the JDK, the operation belongs to you, and the boundary between them has to be crossed somewhere.
When a switch is a missing abstraction
The dishonest version looks almost identical and differs in one respect: the types are yours, so the branches could have been methods. Here channel and retries are decided outside the classes they describe:
abstract class Notification {
abstract String recipient();
}
class EmailNotification extends Notification { String recipient() { return "a@example.com"; } }
class PushNotification extends Notification { String recipient() { return "device-7"; } }
class SmsNotification extends Notification { String recipient() { return "+84900000000"; } } // added later
public class Silent {
static String channel(Notification n) {
if (n instanceof EmailNotification) return "smtp";
if (n instanceof PushNotification) return "fcm";
return "unknown";
}
static int retries(Notification n) {
if (n instanceof EmailNotification) return 3;
if (n instanceof PushNotification) return 1;
return 0;
}
public static void main(String[] args) {
Notification[] all = { new EmailNotification(), new PushNotification(), new SmsNotification() };
for (Notification n : all) {
System.out.printf("%-18s channel=%-8s retries=%d%n",
n.getClass().getSimpleName(), channel(n), retries(n));
}
}
}
EmailNotification channel=smtp retries=3
PushNotification channel=fcm retries=1
SmsNotification channel=unknown retries=0
SmsNotification compiles, runs, and is silently misrouted. The last branch of an instanceof chain over an open hierarchy is not a default — it is a promise that no future type will ever reach it, and nothing checks that promise. Two chains here, two branches missed, zero warnings.
Move both operations onto the type and the same omission becomes a build failure that names the class and the method:
Fixed.java:19: error: SmsNotification is not abstract and does not override abstract method retries() in Notification
class SmsNotification extends Notification { // added later, nothing else edited
^
1 error
The test between the two cases is not "does this code use instanceof". It is: is the set of types closed, and is the operation something the type should know about? Closed and yes means a method. Closed and no means a sealed hierarchy with an exhaustive switch. Open and no means a default clause and the discipline to keep it defensive. Open and yes — an unbounded hierarchy of your own types, dispatched by a chain of instanceof — is the missing abstraction, and it costs you one edit per type per call site, forever.
Abstraction: choosing what not to expose
An abstraction is defined by what it refuses to say. Every type in a signature is a promise, and a promise you did not mean to make is a coupling you will pay for. This interface makes two of them in one line:
import java.sql.SQLException;
import java.util.HashMap;
interface UserStore {
HashMap<String, String> loadAll() throws SQLException; // two leaks in one signature
}
class JdbcUserStore implements UserStore {
public HashMap<String, String> loadAll() throws SQLException {
HashMap<String, String> rows = new HashMap<>();
rows.put("u1", "Mai");
return rows;
}
}
public class Leaky {
static void report(UserStore store) {
try {
HashMap<String, String> users = store.loadAll(); // caller names HashMap
System.out.println("users " + users);
} catch (SQLException e) { // caller catches SQL
System.out.println("db error " + e.getMessage());
}
}
public static void main(String[] args) {
report(new JdbcUserStore());
}
}
users {u1=Mai}
It works, and the interface has told every caller two things it had no business knowing: that the result is a HashMap rather than some Map, and that the data comes from a database. Both are implementation detail wearing a public type.
The bill arrives when a second implementation appears. A file-backed store returns a Map and fails with an IOException, which is a description of exactly the same operation:
import java.io.IOException;
import java.sql.SQLException;
import java.util.HashMap;
import java.util.Map;
interface UserStore {
HashMap<String, String> loadAll() throws SQLException;
}
class FileUserStore implements UserStore {
public Map<String, String> loadAll() throws IOException {
return Map.of("u1", "Mai");
}
}
Swap.java:10: error: FileUserStore is not abstract and does not override abstract method loadAll() in UserStore
class FileUserStore implements UserStore {
^
Swap.java:11: error: loadAll() in FileUserStore cannot implement loadAll() in UserStore
public Map<String, String> loadAll() throws IOException {
^
return type Map<String,String> is not compatible with HashMap<String,String>
2 errors
Two errors, and neither is fixable inside the new class. Map.of returns an immutable map that is not a HashMap, so the return type forces a copy into a HashMap for no reason; the throws clause forces the file store to catch IOException and rethrow it as something the interface allows. This is what a leaky abstraction costs, and the unit is call sites: every place that named HashMap or caught SQLException is a place that changes.
Narrowing the signature until nothing leaks
The repair is to say less. The interface promises a Map and a failure type that belongs to the abstraction, not to any one implementation of it:
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.LinkedHashMap;
import java.util.Map;
class StoreException extends Exception {
StoreException(String message, Throwable cause) { super(message, cause); }
}
interface UserStore {
Map<String, String> loadAll() throws StoreException; // says nothing about the medium
}
class MemoryUserStore implements UserStore {
public Map<String, String> loadAll() { return Map.of("u1", "Mai"); } // may narrow throws
}
class FileUserStore implements UserStore {
private final Path path;
FileUserStore(Path path) { this.path = path; }
public Map<String, String> loadAll() throws StoreException {
Map<String, String> rows = new LinkedHashMap<>();
try {
for (String line : Files.readAllLines(path)) {
String[] parts = line.split("=", 2);
rows.put(parts[0], parts[1]);
}
} catch (IOException e) {
throw new StoreException("cannot read " + path, e);
}
return rows;
}
}
public class Ports {
static void report(UserStore store) { // unchanged for every implementation
try {
System.out.println("users " + store.loadAll());
} catch (StoreException e) {
System.out.println(e.getMessage() + " (cause " + e.getCause().getClass().getSimpleName() + ")");
}
}
public static void main(String[] args) {
report(new MemoryUserStore());
report(new FileUserStore(Path.of("users.txt")));
report(new FileUserStore(Path.of("missing.txt")));
}
}
users {u1=Mai}
users {u1=Mai, u2=Khanh}
cannot read missing.txt (cause NoSuchFileException)
One report method, three different storage situations, no edits. MemoryUserStore declares no throws at all, which is legal because an override may narrow the checked exceptions it throws, and FileUserStore returns a LinkedHashMap because the interface never demanded otherwise. The original IOException is still there as the cause, so nothing is lost — it is simply no longer part of the public promise.
The rule worth keeping: the return type should be the widest type that answers the question, and the exception type should name the abstraction, not the mechanism. A signature is the one part of a class that cannot be changed quietly later.
Substitutability: the subclass that compiles and still breaks its caller
Every subclass makes a claim: wherever the supertype is accepted, I can be passed instead. javac checks the method signatures of that claim and nothing else. The behaviour is on you.
Square is the canonical demonstration because it is so obviously reasonable. A square is a rectangle with equal sides, so keep the sides equal:
class Rectangle {
private int width, height;
Rectangle(int width, int height) { this.width = width; this.height = height; }
void setWidth(int width) { this.width = width; }
void setHeight(int height) { this.height = height; }
int area() { return width * height; }
}
class Square extends Rectangle {
Square(int side) { super(side, side); }
@Override void setWidth(int w) { super.setWidth(w); super.setHeight(w); } // keeps the sides equal
@Override void setHeight(int h) { super.setWidth(h); super.setHeight(h); }
}
public class Substitution {
/** Square's own test, written by the author of Square. */
static void squareOwnTest() {
Square s = new Square(3);
System.out.println("square 3x3 area " + s.area() + (s.area() == 9 ? " PASS" : " FAIL"));
s.setWidth(4);
System.out.println("square resized to 4 " + s.area() + (s.area() == 16 ? " PASS" : " FAIL"));
}
/** A caller that has only ever seen the Rectangle API. */
static void resize(Rectangle r) {
r.setWidth(5);
r.setHeight(4);
boolean ok = r.area() == 20;
System.out.printf("%-10s after 5x4 -> area %2d %s%n",
r.getClass().getSimpleName(), r.area(), ok ? "PASS" : "FAIL");
}
public static void main(String[] args) {
squareOwnTest();
resize(new Rectangle(1, 1));
resize(new Square(3));
}
}
square 3x3 area 9 PASS
square resized to 4 16 PASS
Rectangle after 5x4 -> area 20 PASS
Square after 5x4 -> area 16 FAIL
Read the four lines in order, because each one matters. Square compiles. Square passes the tests its own author wrote — a square of side 4 really does have area 16. And a method that never mentions Square, that was written and tested before Square existed, produces a different answer when one is handed to it.

Nobody wrote a bug. Rectangle never documented "setting the width leaves the height alone", because it is the kind of thing nobody writes down. Square could not honour it and still be a square. The two classes are individually defensible and cannot both be right, which means the inheritance was the mistake — not the code inside either class.
Strengthening a precondition breaks the same way
The same failure appears without any geometry. A subclass that adds a rule is adding a condition the caller must satisfy, and the caller is holding the supertype:
import java.util.List;
class Account {
private long balance;
Account(long balance) { this.balance = balance; }
/** Contract: any amount up to the balance is accepted. */
void withdraw(long amount) {
if (amount > balance) {
throw new IllegalArgumentException("insufficient funds: " + amount + " > " + balance);
}
balance -= amount;
}
long balance() { return balance; }
}
class DailyLimitAccount extends Account {
private static final long LIMIT = 100;
DailyLimitAccount(long balance) { super(balance); }
@Override
void withdraw(long amount) {
if (amount > LIMIT) { // a rule the supertype never had
throw new IllegalStateException("daily limit " + LIMIT + " exceeded by " + amount);
}
super.withdraw(amount);
}
}
public class Preconditions {
static void payroll(List<Account> accounts) {
for (Account a : accounts) {
a.withdraw(150); // legal for every Account: balance is 1000
System.out.println("paid from " + a.getClass().getSimpleName() + ", left " + a.balance());
}
}
public static void main(String[] args) {
DailyLimitAccount d = new DailyLimitAccount(1000);
d.withdraw(50);
System.out.println("own test: withdrew 50, left " + d.balance() + " PASS");
payroll(List.of(new Account(1000), d));
}
}
own test: withdrew 50, left 950 PASS
paid from Account, left 850
Exception in thread "main" java.lang.IllegalStateException: daily limit 100 exceeded by 150
at DailyLimitAccount.withdraw(Preconditions.java:27)
at Preconditions.payroll(Preconditions.java:36)
at Preconditions.main(Preconditions.java:46)
payroll iterates a List of Account, withdraws an amount every balance can cover, and dies on the second element with an exception type it has no reason to catch. The daily limit is a real requirement. It is not a subclass, because a caller holding an Account cannot be expected to know it exists.
Rules that keep a subclass substitutable
Four constraints, each of which the examples above break:
- Do not strengthen what the caller must satisfy before the call. If the supertype accepts any amount up to the balance, so must you.
- Do not weaken what the caller may rely on after it.
setWidthmust leave the height where the caller left it. - Do not throw what the supertype's contract never mentions, including unchecked exceptions. The compiler does enforce this for checked ones and cannot for the rest.
- Do not change state the caller did not ask you to change. A side effect the supertype has no equivalent for is not an enhancement, it is a different method wearing the same name.
When a subclass cannot meet all four, the relationship is not subtyping. Hold the object in a field, expose the operations that are genuinely yours, and let the two types stay separate. A later article in this series gives this constraint its usual name and sets it beside the other design principles it belongs with; here it is enough that you can recognise it in a stack trace.
The four principles as design questions
| Principle | The question the basics answer | The question a design answers |
|---|---|---|
| Encapsulation | which fields are private? | can a caller reach an invalid state without going through my behaviour? |
| Inheritance | what does extends give me? | does every method I inherit still preserve my invariant? |
| Polymorphism | which override runs? | who owns this type, and can I put the operation on it? |
| Abstraction | what does abstract mean? | what does my signature promise, and what is it leaking? |
The left column is checkable by reading one class. The right column is only checkable by reading the callers, which is why it survives code review so easily and production so poorly.
Common mistakes
Treating private as encapsulation. Every field in AskInventory is private, and the stock still went negative. The question is whether an invalid state is reachable, not whether a field is directly readable.
Returning an unmodifiable view and calling it immutable. The wrapper stops the caller writing; it does not stop the owner writing, and the caller sees every one of those writes. List.copyOf when the caller needs stability, a value-returning method when it just needs an answer.
Writing extends to reuse an implementation. Reuse is has a, and forwarding three methods is cheaper than publishing an API surface you did not choose and cannot shrink.
Ending an instanceof chain with a fallback branch. Over a hierarchy that can grow, that branch is a silent handler for every type that does not exist yet. SmsNotification was routed to unknown with retries=0 and nothing reported it.
Putting an implementation type in a signature. HashMap instead of Map, SQLException instead of a storage-level exception. Both compile perfectly and both charge you on the day a second implementation appears.
FAQ
Is encapsulation just making fields private and adding getters?
No. A getter that returns a mutable field, or a pair of getters that lets a caller do the check and the act in two separate steps, leaves the invariant outside the object. Encapsulation is the property that no sequence of public calls can put the object in an invalid state.
Should every getter return a defensive copy?
Only when the caller genuinely needs the collection. A copy is one allocation and size() reference copies per call — the instrumented run above shows a thousand calls over a 500-element list costing 1000 lists and 500,000 reference copies. A method that answers the caller's question returns a value and allocates nothing, so try that first.
When is composition better than inheritance in Java?
Whenever you wanted the code rather than the type. Inheritance publishes the superclass's whole API on your class, now and in every future version of it, so a guard you add can be walked around by a method you never considered. Compose unless you specifically need your object to be accepted where the supertype is expected.
Is a switch over types always bad design?
No. Over a sealed hierarchy the compiler proves the switch is exhaustive, which is the same guarantee an abstract method gives, and it keeps operations like HTML rendering out of your domain model. Over types you did not write it is the only option available. It is a design smell exactly when the types are yours, unbounded, and the operation belongs on them.
What makes an abstraction leaky in Java?
A signature that names something only one implementation could satisfy. HashMap instead of Map forces every implementation into one data structure; throws SQLException tells every caller the data lives in a database. The cost is counted in call sites that must be edited when the second implementation arrives.
How do I know a subclass is safe to pass where its superclass is expected?
Take a method that only knows the supertype, written before your subclass existed, and run it against both. If the results differ in any way the supertype's contract did not allow for — a different value, a new exception, extra state changed — the subclass is not substitutable, however cleanly it compiles.
Conclusion
None of the failures in this article is a compiler error. A private field that still lets a caller reach a negative stock, a guard walked around by an inherited method, an instanceof chain that silently swallows a new type, a HashMap in a signature, a subclass that passes its own tests and breaks a caller — all of it compiles, and all of it is a decision the code has already made on your behalf. Reading those decisions is the skill this course is about.
The next article moves from principles to a language feature that carries them: nested and inner classes. Static nested classes, inner classes and the reference to the enclosing instance they hold, local classes, and anonymous classes — what each one actually compiles to, and what it costs to choose the wrong one.