Where does encapsulation or polymorphism change a design, and how do you decide between composition and inheritance?
Encapsulation earns its place when a class owns an invariant that callers would otherwise each have to enforce, and polymorphism when a conditional grows with every new requirement. Composition versus inheritance is decided on evidence: how many axes vary, and whether every inherited operation is honest.
What the interviewer is scoring
- Does the candidate tie encapsulation to a named invariant rather than to the presence of getters and setters
- Whether the polymorphism example replaces a conditional that would keep growing, instead of one branch that never will
- That the composition-versus-inheritance answer cites evidence from the requirements rather than repeating the slogan
- Whether they notice that returning an internal mutable collection undoes the encapsulation they just described
- Does the candidate name a case where a subclass is the better choice, showing the preference is reasoned and not reflexive
Answer
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;
// Every caller is now responsible for the invariant, which means it is broken
// somewhere in the codebase and you will find out from a support ticket.
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.
import java.time.Instant;
import java.util.ArrayList;
import java.util.List;
public final class LoyaltyAccount {
public record LedgerEntry(String reason, int delta, Instant at) {}
private int points;
private final List<LedgerEntry> ledger = new ArrayList<>();
public void earn(int amount, String reason) {
if (amount <= 0) throw new IllegalArgumentException("earn must be positive");
points += amount;
ledger.add(new LedgerEntry(reason, amount, Instant.now()));
}
// Refusal is an expected outcome, not an exceptional one, so it is a return
// value. The balance check exists here and nowhere else in the codebase.
public boolean redeem(int amount, String reason) {
if (amount <= 0) throw new IllegalArgumentException("redeem must be positive");
if (amount > points) return false;
points -= amount;
ledger.add(new LedgerEntry(reason, -amount, Instant.now()));
return true;
}
public int points() { return points; }
// Returning `ledger` directly would hand every caller a mutable handle on
// internal state and undo the whole exercise.
public List<LedgerEntry> ledger() { return List.copyOf(ledger); }
}
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.
import java.util.List;
record Order(long amountMinor, String category) {}
interface EarnRule {
/** Points this rule contributes for one order; zero when it does not apply. */
int pointsFor(Order order);
}
final class BaseRate implements EarnRule {
@Override public int pointsFor(Order order) {
return (int) (order.amountMinor() / 100); // one point per unit of currency
}
}
final class CategoryBonus implements EarnRule {
private final String category;
private final int extraMultiplier;
CategoryBonus(String category, int extraMultiplier) {
this.category = category;
this.extraMultiplier = extraMultiplier;
}
@Override public int pointsFor(Order order) {
if (!order.category().equals(category)) return 0;
return (int) (order.amountMinor() / 100) * extraMultiplier;
}
}
final class Earning {
private final List<EarnRule> rules;
Earning(List<EarnRule> rules) { this.rules = List.copyOf(rules); }
int pointsFor(Order order) {
int total = 0;
for (EarnRule rule : rules) total += rule.pointsFor(order);
return total;
}
}
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); // legal: inherited Hashtable.put takes Object
return p.getProperty("port"); // null: getProperty ignores non-String values
}
}
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.
Likely follow-ups
- What does Liskov substitution give you beyond "the subtype should make sense"?
- How would you enforce that invariant if the object has to be reconstructed from a database row?
- When is a package-private constructor a better boundary than a public one?
- Your rule list has grown to thirty implementations. What breaks, and what would you do about it?
Related questions
- What are the four pillars of OOP, and what is the difference between abstraction and encapsulation?easyAlso on encapsulation and polymorphism4 min
- When would you use the strategy pattern instead of inheritance?mediumAlso on composition and inheritance5 min
- How do you turn what the business tells you into a domain model that holds up once the exceptions arrive?hardAlso on invariants7 min
- When you call an overridden method through a base-class reference, how does the runtime know which implementation to run?mediumAlso on polymorphism5 min
- Merge a list of overlapping intervals, and justify the key you sorted byeasyAlso on invariants4 min
- Given a binary tree, decide whether it is a valid binary search tree.mediumAlso on invariants5 min
- Given package weights in a fixed order, find the smallest ship capacity that delivers them all within D daysmediumAlso on invariants5 min
- A customer's app shows one balance and the ATM shows another. How should available balance be defined?mediumSame kind of round: concept4 min