An invariant is what makes encapsulation concrete
Start with a rule the domain imposes rather than with the word. A loyalty account has three rules that hold at every instant: the balance is never negative, every change to it is recorded with a reason, and points only move in whole units. Those rules are the invariant, and the design question is who is responsible for it.
import java.util.ArrayList;
import java.util.List;
public class LeakyAccount {
private int points;
private final List<String> ledger = new ArrayList<>();
public int getPoints() { return points; }
public void setPoints(int points) { this.points = points; }
public List<String> getLedger() { return ledger; }
}
With that shape, the check if (account.getPoints() >= cost) appears in the redemption service, in the admin tool, and in a batch job written a year later by someone who did not know about the other two. Two of the three will forget to append to the ledger. Nothing about the class stops any of it.
Moving the rule inside changes what the type means — it becomes a thing that cannot be in an illegal state rather than a bag of fields that happens to be in a legal one right now.
Notice what did not happen: no getter and setter pair for every field. The public surface is the set of operations the domain permits, and points() is exposed because callers legitimately display a balance. That is the test to apply out loud — does this accessor exist because a caller needs the value, or because the IDE generated it.
Polymorphism is how you stop editing a conditional
The other half of the pressure is variation. Loyalty programmes accumulate earning rules: a base rate, a double-points category, a launch promotion, a partner multiplier. Written as a conditional, every one of those is an edit to a method that already works, in a file that already has tests, and the method grows monotonically for the life of the product.
A new rule is now a new file plus one line in whatever assembles the list, and Earning.pointsFor is finished forever. That is the honest claim for polymorphism: not that it is more object-oriented, but that it converts a growing edit into an addition.
The claim has a condition attached, and stating it is worth more marks than the refactoring itself. Do this only when the axis genuinely varies. If there is one rule and the product has no appetite for a second, EarnRule is an interface with one implementation, one extra indirection at every call site, and a reader who has to open two files to learn what happens.
Deciding composition versus inheritance on evidence
"Favour composition over inheritance" is only useful if you can say what evidence would change your mind. Four checks, all answerable from the requirements in front of you.
Count the axes. If behaviour varies in two independent ways, a hierarchy has to pick one and duplicate the other, so the class count grows as the product while the interesting code grows as the sum. Two axes is close to decisive for composition.
Check that every inherited operation is honest. The JDK ships the standard cautionary example. Properties extends Hashtable<Object,Object>, so it inherits put, which accepts any object at all, while getProperty only ever returns values that are strings:
import java.util.Properties;
final class PropertiesWart {
static String read() {
Properties p = new Properties();
p.put("port", 8080);
return p.getProperty();
}
}
Nobody designed that behaviour; it fell out of extending a class whose interface was wider than the subtype could honour. When a subclass has to document operations that must not be called, the relation was not "is-a".
Ask what the subtype needs to reach. If the varying part needs the parent's half-built buffer, its connection pool and its counters, extraction will not help — you have a subclass whose this arrives through a parameter list. Composition works when the interface between the two pieces can stay narrow.
Ask who owns the base class. Inheriting across a library boundary couples you to which of its own methods the superclass calls internally, which is not part of its contract and can change in a patch release.
Inheritance wins when a single axis varies, the subtypes share substantial state, and the set is closed — a sealed hierarchy of AST nodes or message types you exhaustively switch over, or an abstract base that holds a genuinely shared protocol. Saying that plainly matters, because a candidate who argues composition on principle in every case is reciting, and the interviewer usually has a case ready in which it is wrong.
The accessor that quietly cancels the design
The most common way a strong-sounding answer falls apart is the collection getter. A candidate describes the invariant well, guards it in the mutating methods, and then exposes List<LedgerEntry> getLedger() returning the field. Any caller can now append an entry that no earn or redeem produced, or clear the audit trail, and the class has no way to notice. The same defect arrives via a returned Date, a mutable value object, or an array. List.copyOf above is the fix in this design; an unmodifiable view is the fix when copying is too expensive and callers can tolerate seeing later changes.
The reason interviewers watch for it is that it separates two things that look identical on a diagram: a class whose fields are private, and a class that actually owns its state.
Encapsulation is a claim about which invariants a type is responsible for, and polymorphism is a claim about which axis will keep varying. Both are worth defending in those terms, because both cost indirection and neither is free enough to apply by reflex.
© 2026 Preptima. Originally published at preptima.com (opens in a new tab).