Knowledge Guide
HomeOO & Low-Level DesignOOD Foundations

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 classInterface
Shared state (fields)yesno (constants only)
Shared implementationyes — concrete methodspartial, via default
Inheritance countsingle (one parent)multiple (implement many)
Constructoryes (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 runtimedynamic 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.

Two panels contrasted. Left: overloading resolved by static binding — the compiler picks feed(Animal x) purely from the variable's declared type Animal, even though the object is really a Dog. Right: overriding resolved by dynamic binding — the JVM looks up sound() in the actual runtime object's vtable and calls Dog.sound(), regardless of the Animal-typed variable holding it.
Two panels contrasted. Left: overloading resolved by static binding — the compiler picks feed(Animal x) purely from the variable's declared type Animal, even though the object is really a Dog. Right: overriding resolved by dynamic binding — the JVM looks up sound() in the actual runtime object's vtable and calls Dog.sound(), regardless of the Animal-typed variable holding it.

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?
    }
}
CallResolution mechanismDecided byOutput
v.feed(a)overload resolution — compile timea's declared type, Animalgeneric feed
a.sound()override dispatch — runtimea's actual type, DogWoof

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.

Diamond diagram: interface Greeter has a default greet(), and two sub-interfaces FormalGreeter and CasualGreeter each override it with a different string. A class Person implements both. Java gives a compile error because Person inherits two conflicting defaults and must override greet() itself, e.g. by calling FormalGreeter.super.greet(). A note explains Go has no default-method diamond since interfaces are pure contracts, but struct embedding hits the same ambiguity: embedding both interfaces makes any call to the promoted method a compile error, ambiguous selector.
Diamond diagram: interface Greeter has a default greet(), and two sub-interfaces FormalGreeter and CasualGreeter each override it with a different string. A class Person implements both. Java gives a compile error because Person inherits two conflicting defaults and must override greet() itself, e.g. by calling FormalGreeter.super.greet(). A note explains Go has no default-method diamond since interfaces are pure contracts, but struct embedding hits the same ambiguity: embedding both interfaces makes any call to the promoted method a compile error, ambiguous selector.

The resolution rule, precisely:

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:

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 pairShared field(s)?Tally
validateEmail, sendWelcomeEmailnoneP=1
validateEmail, logAuditnoneP=2
validateEmail, retryFailedSendnoneP=3
sendWelcomeEmail, logAuditnoneP=4
sendWelcomeEmail, retryFailedSendsmtpHostQ=1
logAudit, retryFailedSendnoneP=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

Pitfalls

Takeaways

Related pages

🤖 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.

🎨 Explain it visually

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.
🤔 Walk me through it (interactive)

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.
🧪 Quiz me & fix my gaps

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.
🧠 Make it stick

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.

📝 My notes