OOD Foundations & SOLID — The Precise Forms & Interview Tripwires (Deep Dive)
Most OOD interview failures are not "doesn't know the pattern" — they are the loose, hand-wavy version of a rule where the interviewer is listening for the precise one. "Interfaces are for multiple inheritance" is the loose version; "an override may widen its parameter types and narrow its return type" is the precise one. "SRP means one responsibility" is the loose version; "cohesion is a countable number, and here is the count" is the precise one. This page drills the exact mechanism behind the four questions interviewers ask before they even reach a design problem — abstract class vs interface, overloading vs overriding, coupling/cohesion, the diamond problem — and then the exact, typed forms of the three SOLID principles that are almost always tested loosely and answered loosely: LSP's contravariance/covariance rule, ISP's empty-interface trap, and SRP's measurable cohesion.
1. Abstract class vs interface — the most-asked foundations question
The mechanism, not the vocabulary: an abstract class gives subclasses shared
state (instance fields) and shared, already-written behavior (concrete method
bodies), and a subclass may extend only one — because a field layout and a constructor chain can
only come from one place without ambiguity. An interface gives implementers a pure
contract — method signatures with no state — and a class may implement many, because
"here is a set of methods you promise to provide" does not collide the way shared state does.
Java's default methods blur the second half of that line (an interface can now ship a
method body too) but never the first: interfaces still cannot hold instance fields, so they still
cannot carry shared, mutable state across implementers.
| Abstract class | Interface | |
|---|---|---|
| Shared state (fields) | yes | no (constants only) |
| Shared implementation | yes — concrete methods | partial, via default |
| Inheritance count | single (one parent) | multiple (implement many) |
| Constructor | yes (runs on subclass instantiation) | no |
| Answers | "what am I, fundamentally" | "what can I do" |
// Java — abstract class: shared state + partial implementation, single inheritance
abstract class PaymentMethod {
protected double balance; // shared STATE
PaymentMethod(double balance) { this.balance = balance; }
boolean hasFunds(double amt) { return balance >= amt; } // shared BEHAVIOR
abstract void charge(double amt); // still forces each subclass to specialize
}
// Java — interface: pure contract, a class can implement many
interface Refundable { void refund(double amt); }
interface Auditable { void logTransaction(String note); }
class CreditCard extends PaymentMethod implements Refundable, Auditable {
CreditCard(double balance) { super(balance); }
void charge(double amt) { balance -= amt; }
public void refund(double amt) { balance += amt; }
public void logTransaction(String note) { /* ... */ }
}
Go has no classes at all — there is no extends, no shared-state inheritance,
and therefore no "abstract class" concept to reach for. Go only has interfaces (pure method-set
contracts, satisfied implicitly — no implements keyword) and struct
embedding, which is composition dressed up to look like inheritance: an embedded struct's
fields and methods are promoted onto the outer struct, but it is a real field under the
hood, not a parent class.
// Go — the interface half is the same idea as Java
type Refundable interface { Refund(amt float64) }
type Auditable interface { LogTransaction(note string) }
// Go — "shared state + shared behavior" is done via embedding, NOT class inheritance
type PaymentMethod struct { Balance float64 } // shared STATE, held by value
func (p *PaymentMethod) HasFunds(amt float64) bool { return p.Balance >= amt }
type CreditCard struct {
PaymentMethod // embedded — HasFunds() is PROMOTED, this is composition
}
func (c *CreditCard) Refund(amt float64) { c.Balance += amt } // satisfies Refundable implicitly
When to choose each (the judgment): reach for an abstract class when subclasses are
genuinely "kinds of the same thing" that share real state and you want to write shared logic once
(a Shape base holding a color field and a concrete describe()
that calls an abstract area()). Reach for an interface when unrelated classes need to
promise the same capability without being the same kind of thing at all — a
Car, a Duck, and a PdfExporter can all be
Comparable despite sharing nothing else. The tie-break question a senior engineer
actually asks: "do these types share state and identity, or only a promise?" — state and
identity ⟹ abstract class (or, in Go, embedding); promise only ⟹ interface.
2. Overloading vs overriding — the canonical binding tripwire
Overloading is same method name, different parameter list, and it is resolved by the compiler at compile time — this is called static binding, and critically it binds by the variable's declared type, never its runtime contents. Overriding is a subclass replacing a parent's method with the identical signature, and it is resolved by the runtime at runtime — dynamic binding (a.k.a. dynamic dispatch), by the object's actual type. Same variable, two different resolution rules depending on which mechanism is in play — this split is exactly what the diagram below traces.
Traced worked example — the exact line where declared type and actual type disagree
class Animal {
void sound() { System.out.println("some sound"); } // to be overridden
}
class Dog extends Animal {
@Override void sound() { System.out.println("Woof"); } // OVERRIDE (same signature)
}
class Vet {
void feed(Animal x) { System.out.println("generic feed"); } // OVERLOAD #1
void feed(Dog x) { System.out.println("dog treat"); } // OVERLOAD #2 (different param type)
}
public class Main {
public static void main(String[] args) {
Animal a = new Dog(); // declared type: Animal. actual/runtime type: Dog.
Vet v = new Vet();
v.feed(a); // which feed() runs?
a.sound(); // which sound() runs?
}
}
| Call | Resolution mechanism | Decided by | Output |
|---|---|---|---|
v.feed(a) | overload resolution — compile time | a's declared type, Animal | generic feed |
a.sound() | override dispatch — runtime | a's actual type, Dog | Woof |
This is the exact tripwire interviewers set: the same variable a gives one answer
for the overload call and the opposite-feeling answer for the override call, because they are not
the same mechanism — one is a compiler-time lookup keyed by the type written in the source, the
other is a runtime lookup keyed by what is actually sitting on the heap. If feed were
instead @Override-able (i.e. if Vet itself were subclassed and
feed overridden in the subclass), that call would flip to dynamic binding too — the
distinguishing question is never "which variable," it is "overload (new signature) or override
(same signature, subclass replaces it)."
3. Coupling & cohesion — the vocabulary behind "is this a good design?"
Coupling measures how much one module knows about, and depends on, another — low coupling means a module has few dependencies and those dependencies are stable (changing rarely, hidden behind an interface). Cohesion measures whether the things inside one module belong together — high cohesion means every method and field in a class serves the same single responsibility, so a change to that responsibility only ever touches this one class. These two are the actual criteria behind almost every "why is this a bad design" interview answer: a class that reaches directly into three other classes' internals is tightly coupled (a ripple change in any of them breaks it); a class whose methods use disjoint subsets of its own fields is low cohesion (it is secretly two or three classes wearing one name). The goal pair — low coupling, high cohesion — is not a slogan; §8 below shows cohesion measured as an actual number.
4. The diamond problem — the crisp code consequence
Strip the theory down to the concrete question: if a type inherits the same method along two different paths, and those two paths disagree on what it does, which one wins? The diagram traces the canonical shape — a common interface with a default method, two sub-interfaces that each override it differently, and a class that implements both.
The resolution rule, precisely:
- Java, multiple class inheritance (state): forbidden outright — a class may
extendsonly one class, specifically because the field-inheritance version of this ambiguity (which parent's field wins) has no principled answer, so the language removes the possibility entirely. - Java, default-method diamond: allowed to occur, but the compiler refuses to silently
pick a winner — if a class implements two interfaces with conflicting default implementations of
the same method, it is a compile error until the class provides its own override (optionally
delegating to one parent explicitly via
Interface.super.method()). - C++, virtual inheritance: C++ allows true multiple class inheritance, so the diamond can
duplicate a shared base's data (two copies of the base subobject) unless the base is inherited
virtual— which collapses it back to one shared subobject, at the cost of a small runtime indirection to locate it. - Go, embedding ambiguity: the compiler does not guess either — embedding two types that promote the same method name makes any call through the outer type a compile-time "ambiguous selector" error, forcing the author to write an explicit method that picks one embedded field's version.
The pattern across all four: no language silently guesses which of two conflicting inherited things wins — it either forbids the ambiguity structurally (single class inheritance), or forces a compile-time error until a human resolves it explicitly. That is the actual lesson the "diamond problem" question is testing, not the shape of the letter D.
5. LSP typed form — contravariant parameters, covariant returns, unbroken contracts
The Liskov Substitution Principle in prose ("subtypes must be substitutable for their base type") is not the testable form. The testable form is a rule about method signatures and contracts:
- Parameter types may only widen (be the same or a supertype of the base method's parameter) — this is contravariance. A caller holding the base type only ever passes base-type arguments; an override that accepts a wider type still accepts everything the caller could send, so nothing breaks. An override that narrows a parameter type would reject arguments the base contract promised to accept.
- Return types may only narrow (be the same or a subtype of the base method's return type) — this is covariance. A caller expecting the base return type can always accept a more specific subtype in its place; widening the return type would hand the caller something it didn't promise to handle.
- Preconditions must not be strengthened — an override cannot demand more from the caller than the base method did (e.g. base accepts any int, override suddenly requires positive-only).
- Postconditions must not be weakened — an override cannot deliver less than the base promised (e.g. base guarantees a sorted result, override returns unsorted).
Java enforces covariant return types at compile time for true overrides (an override may narrow what it returns). Go has no overriding at all — interface satisfaction requires an exact method-signature match (Go is actually stricter here, permitting no covariance whatsoever), and embedding-based method shadowing enforces no signature relationship at all, since the outer method is an independent declaration rather than a checked override. Neither language enforces contravariant parameters for overriding — widening a parameter type in Java turns the method into a different overload entirely (§2), not an override of the same one; true parameter contravariance for true overrides is a feature some languages (e.g. Eiffel) support and Java/Go do not. This is precisely why the precondition/postcondition half of LSP is a design discipline, not something the compiler checks for you — the classic violation below compiles fine and still breaks callers.
The Rectangle/Square case — and the judgment call about when it actually violates LSP
class Rectangle {
protected int width, height;
void setWidth(int w) { width = w; }
void setHeight(int h) { height = h; }
int area() { return width * height; }
}
class Square extends Rectangle {
@Override void setWidth(int w) { width = height = w; } // keeps "square" invariant
@Override void setHeight(int h) { width = height = h; } // but breaks the base's postcondition
}
void test(Rectangle r) {
r.setWidth(5);
r.setHeight(4);
assert r.area() == 20; // TRUE for any Rectangle... FALSE for a Square (area() == 16)
}
The base Rectangle contract implicitly promises "width and height vary
independently." Square cannot honor that promise and still be a square, so it
silently changes the postcondition of setWidth/setHeight — a
caller that only knows the base type gets a wrong answer. That is a real LSP violation because
test() exercises the exact behavior the substitution broke.
The judgment layer: this is only a violation if callers actually need independent width/height mutation. If the objects are immutable (`Rectangle(int w, int h)` with no setters, only a `withWidth(int w)` that returns a new instance) there is no mutable postcondition to break in the first place — a `Square` that simply always constructs with `w == h` substitutes perfectly, because nothing ever observed "set width without touching height." The senior-engineer answer is not "Square should never extend Rectangle" as a blanket rule — it is "model mutable 2D-shape hierarchies as immutable, or don't model the is-a relationship as inheritance at all"; composition (`Square` has-a `Rectangle`-shaped side length, doesn't is-a one) sidesteps the question entirely when independence genuinely doesn't apply.
6. ISP — a worked, consistent empty-handling example
The Interface Segregation Principle's real payoff is not "smaller interfaces are nicer" — it is
that a fat interface forces every implementer to answer for behavior it may not have, and the
sloppiest possible answer is letting an unrelated exception leak through. Consider a single
Repository interface with both single-item and bulk lookups:
interface Repository<T> {
T get(int id); // throws if not found
List<T> getBatch(List<Integer> ids);
}
class SqlRepository<T> implements Repository<T> {
public T get(int id) {
T item = db.find(id);
if (item == null) throw new NoSuchElementException("id " + id);
return item;
}
public List<T> getBatch(List<Integer> ids) {
List<T> out = new ArrayList<>();
for (int id : ids) out.add(this.get(id)); // BUG: delegates to get(), which THROWS
return out; // one missing id blows up the whole batch
}
}
The bug is a direct consequence of the fat interface: because getBatch was forced
onto every implementer regardless of whether bulk semantics were ever designed, the easiest
implementation is "loop and call the single-item method" — which silently imports the single-item
method's exception-on-missing contract into a context where the sane contract is
skip or report, don't blow up the batch. A caller iterating a 500-id batch gets an
uncaught NoSuchElementException on id #217 (or, in a Go rewrite, an
IndexOutOfBoundsException-shaped equivalent from a careless slice index) and loses
the other 499 results for free.
// Java — ISP fix: segregate, and give getBatch its OWN, consistent empty-handling contract
interface SingleLookup<T> { Optional<T> get(int id); } // never throws — Optional
interface BatchLookup<T> { Map<Integer, T> getBatch(List<Integer> ids); } // missing ids simply absent
class SqlRepository<T> implements SingleLookup<T>, BatchLookup<T> {
public Optional<T> get(int id) {
return Optional.ofNullable(db.find(id)); // consistent: absence is a value, not a throw
}
public Map<Integer, T> getBatch(List<Integer> ids) {
Map<Integer, T> out = new HashMap<>();
for (int id : ids) db.find(id, item -> out.put(id, item)); // missing ids just don't appear
return out; // caller checks out.size() vs ids.size()
}
}
// Go — the same segregation; Go's idiomatic "absence" is (value, bool), not an exception at all
type SingleLookup[T any] interface { Get(id int) (T, bool) }
type BatchLookup[T any] interface { GetBatch(ids []int) map[int]T }
func (r *SQLRepo[T]) Get(id int) (T, bool) {
v, ok := r.db.Find(id)
return v, ok // consistent: caller checks ok, no panic on missing
}
func (r *SQLRepo[T]) GetBatch(ids []int) map[int]T {
out := make(map[int]T)
for _, id := range ids {
if v, ok := r.db.Find(id); ok { out[id] = v } // missing ids simply omitted, no panic
}
return out
}
Segregating the interface did two things at once: callers that only ever need single lookups depend on a contract with no batch method to misuse, and the batch implementation is now free to define its own empty-handling contract (a map missing a key) instead of inheriting the single-lookup method's throw-on-missing behavior by accident.
7. SRP made measurable — LCOM on a traced class
"This class violates SRP" is a claim; LCOM (Lack of Cohesion of Methods) is the number that
backs it up. One common form (Chidamber & Kemerer's LCOM1): for every pair of methods in a
class, check whether they access at least one instance field in common. Following the original
C&K notation: P = the set of method pairs that share no instance field,
Q = the set of method pairs that share at least one; then
LCOM1 = max(|P| − |Q|, 0). A high number means the class is really several unrelated
clusters of behavior wearing one name.
class UserManager { // 4 fields, 4 methods — trace every pair
Pattern emailRegex;
String smtpHost;
String auditPath;
int retryCount;
boolean validateEmail(String s) { return emailRegex.matcher(s).matches(); } // uses {emailRegex}
void sendWelcomeEmail(String e){ smtpClient(smtpHost).send(e); } // uses {smtpHost}
void logAudit(String msg) { Files.write(Path.of(auditPath), msg.getBytes()); } // uses {auditPath}
void retryFailedSend(String e) { for (int i=0;i<retryCount;i++) smtpClient(smtpHost).send(e); } // uses {smtpHost, retryCount}
}
| Method pair | Shared field(s)? | Tally |
|---|---|---|
| validateEmail, sendWelcomeEmail | none | P=1 |
| validateEmail, logAudit | none | P=2 |
| validateEmail, retryFailedSend | none | P=3 |
| sendWelcomeEmail, logAudit | none | P=4 |
| sendWelcomeEmail, retryFailedSend | smtpHost | Q=1 |
| logAudit, retryFailedSend | none | P=5 |
LCOM1 = max(|P| − |Q|, 0) = max(5 − 1, 0) = 4. Four non-cohesive pairs against a single
cohesive one is a loud signal: UserManager is really three unrelated responsibilities
— email validation, email delivery (with its own retry concern), and audit
logging — held together only by living in the same file. The SRP fix follows directly from the
number, not from taste: split into EmailValidator (owns emailRegex),
EmailSender (owns smtpHost, retryCount — these two were the
one pair that actually shared a field, so they belong together), and AuditLogger (owns
auditPath). Recomputing LCOM1 on each resulting class gives 0 — every
remaining method pair in each class shares its class's field(s), because there's now only one
responsibility's worth of fields left to share.
Judgment layer
- Abstract class vs interface, restated as a decision: shared mutable state or a constructor-driven invariant across "kinds of the same thing" ⟹ abstract class (or, without classes at all, Go's embedding). A capability promised across otherwise-unrelated types, with no shared state ⟹ interface. If you find yourself calling a hierarchy "abstract class" purely to get multiple inheritance of behavior, that's the tell you actually wanted several small interfaces.
- When Rectangle/Square is a real LSP violation vs when it isn't: it's a violation exactly
when a base-type caller can observe a broken postcondition — independent-mutation code like the
test()trace in §5. It dissolves once mutation is removed (immutable value objects) or once the relationship is remodeled as composition instead of inheritance. The general form: LSP violations live at the mutable, stateful edges of a hierarchy; tightening those edges (immutability) or replacing is-a with has-a routinely removes the violation without removing the code. - ISP vs one convenient interface: segregate when implementers meaningfully differ in which methods they can support well (the batch/single split in §6); don't over-segregate down to one-method interfaces when every real implementer needs the whole set anyway — that just adds indirection with no substitution benefit.
- LCOM as a guide, not a gate: a single high-LCOM class is a strong prompt to look closer, but don't chase LCOM to zero mechanically — some classes (e.g. a small value object with a couple of independent computed properties) will always show a nonzero score without actually being a design problem; use it to find candidates, then apply the same "what is the one reason this class would change" question from SRP to confirm.
Pitfalls
- Reaching for an abstract class purely to get multiple inheritance of behavior — Java forbids it outright; the fix is composition (delegate to a helper object) or default methods on an interface, not fighting the single-inheritance rule.
- Assuming an override can narrow accepted parameter types — it can't be a true override at all if it does (Java treats it as a new overload, silently); the base method is still reachable through a base-typed reference and callers relying on the "override" never see it run.
- Confusing "the code compiles" with "LSP holds" — the Rectangle/Square example compiles cleanly and still breaks callers; LSP is a behavioral contract the type checker cannot enforce for you.
- Building one "God repository" interface and letting sloppy implementations leak an unrelated exception through a batch/bulk method — the fat-interface version of §6's bug; segregate the contract and give each piece its own explicit empty-handling rule.
- Treating SRP as a matter of opinion — a class doing "too much" is countable via LCOM; use the number to end the argument about whether a class needs splitting, not just a feeling that the file is long.
- Ignoring the diamond ambiguity by picking "whichever one the compiler happens to run" — in every language shown here, that "happens to run" answer either doesn't compile or is a compile error by design; there is no silent default to memorize because the language refuses to have one.
Takeaways
- Abstract class = shared state + partial implementation + single inheritance; interface = pure contract + multiple implementation. Go has neither classes nor abstract classes — it reaches the same "shared state + shared behavior" goal via struct embedding (composition, not inheritance).
- Overloading resolves at compile time by declared type (static binding); overriding resolves at runtime by actual type (dynamic binding) — the same variable can give a different-feeling answer for each, which is exactly the tripwire interviewers probe.
- LSP's testable form is contravariant parameters, covariant returns, and unweakened pre/postconditions — Rectangle/Square is a violation only when callers actually observe broken independent mutation; immutability or composition routinely dissolves it.
- ISP's payoff is forcing each implementer to define its own consistent contract (e.g. how absence is represented) instead of inheriting an unrelated method's exception-on-missing behavior through a fat interface; SRP's cohesion claim is not hand-wavy — LCOM counts the non-cohesive method pairs and gives you the number that tells you to split the class.
Related pages
- Introduction to the Liskov Substitution Principle — the prose statement of LSP that §5 turns into a testable, typed form.
- Using Composition to Follow Liskov Substitution Principle (LSP) — the has-a fix for the Rectangle/Square violation traced in §5.
- Introduction to the Interface Segregation Principle — the baseline ISP statement behind the fat-repository fix in §6.
- Introduction to the Single Responsibility Principle — the prose SRP claim that §7's LCOM calculation makes measurable.
- SRP vs Coupling Cohesion Separation of Concerns — the coupling/cohesion vocabulary introduced in §3, applied back to SRP.
🤖 Don't fully get this? Learn it with Claude
Stuck on OOD Foundations & SOLID — The Precise Forms & Interview Tripwires (Deep Dive)? Open Claude, copy a block below, and it'll teach you this exact concept — visually and interactively.
Build the mental picture, not memorization.
I just read a lesson on **OOD Foundations & SOLID — The Precise Forms & Interview Tripwires (Deep Dive)** (OO & Low-Level Design) and want to truly understand it. Explain OOD Foundations & SOLID — The Precise Forms & Interview Tripwires (Deep Dive) from first principles using ONE vivid real-world analogy and a visual mental model — draw it as ASCII art or a clear step-by-step diagram — with a concrete example using real numbers. Then ask me one question to check I got the mental picture, and wait for my reply. If you're unsure or a claim isn't standard, say so and reason from first principles instead of guessing.
Socratic — adapts to where you're stuck.
Teach me **OOD Foundations & SOLID — The Precise Forms & Interview Tripwires (Deep Dive)** interactively. Ask me ONE guiding question at a time, wait for my answer, and adapt to my confusion — build the idea with me step by step instead of explaining it all at once. If you're unsure or a claim isn't standard, say so and reason from first principles instead of guessing.
Active recall exposes what you missed.
Quiz me on **OOD Foundations & SOLID — The Precise Forms & Interview Tripwires (Deep Dive)** with 5 questions, easy to tricky, ONE at a time. Tell me if each answer is right; at the end, explain clearly what I got wrong and why. If you're unsure or a claim isn't standard, say so and reason from first principles instead of guessing.
Intuition + hook + flashcards for long-term memory.
Help me remember **OOD Foundations & SOLID — The Precise Forms & Interview Tripwires (Deep Dive)** for the long term: give the one-sentence intuition, a memorable hook/mnemonic, a tiny worked example, and 3 active-recall flashcards (Q -> A). If you're unsure or a claim isn't standard, say so and reason from first principles instead of guessing.