SOLID, KISS, DRY, YAGNI: The Design Principles Behind Every Pattern
Design patterns get all the attention, but principles are what actually decide whether your code ages well. Here’s the deep dive, interview-style.
Design patterns are solutions to recurring problems. Principles are the reasoning that tells you whether a pattern is the right solution at all — and they come up in interviews just as often, usually as the harder, more open-ended half of a design conversation (“walk me through what’s wrong with this class”).
This article covers the principles that actually get asked about in interviews: the SOLID five, plus DRY, KISS, YAGNI, and Law of Demeter. For each, the same structure as before:
- What it actually means (not the acronym-expansion version)
- A concrete before/after in C++
- The interview question that gets asked, with a strong answer
- The follow-up trap that tests whether you understand the principle’s limits, not just its definition
Part 1: SOLID
1. Single Responsibility Principle (SRP)
What it actually means: Often misquoted as “a class should do one thing.” The more precise formulation (Robert Martin’s later phrasing) is: a class should have only one reason to change — one axis of responsibility, tied to one actor or stakeholder who could request a change to it.
// Violates SRP — two reasons to change: business rules AND persistence format
class Invoice {
public:
double calculateTotal() { /* business logic */ return 0.0; }
void saveToDatabase() { /* SQL, connection handling */ }
std::string renderAsPdf() { /* PDF formatting */ }
};
// Fixed - each class has exactly one reason to change
class Invoice {
public:
double calculateTotal() { /* business logic only */ return 0.0; }
};
class InvoiceRepository {
public:
void save(const Invoice& inv) { /* persistence only */ }
};
class InvoicePdfRenderer {
public:
std::string render(const Invoice& inv) { /* formatting only */ return ""; }
};Interview question: “Isn’t ‘one reason to change’ subjective? How do you actually apply this when reviewing a PR?”
Strong answer: It is somewhat subjective at the margins, which is why the “one reason to change” framing is more useful than “does one thing” — ask who would request a change to this class. If the answer spans multiple stakeholders (a DBA wants to change the storage schema, a finance person wants to change tax calculation, a designer wants to change PDF layout), that’s your signal the class is doing too much, regardless of line count. In review, a practical heuristic: if you can’t summarize a class’s purpose in one sentence without using “and,” it’s a candidate for splitting.
Follow-up trap: “Can you over-apply SRP? What does that look like?”
Good answer: Yes — taken too far, SRP produces a swarm of tiny classes each wrapping a single method, which increases indirection without increasing clarity, and makes the actual flow of a use case hard to trace across a dozen files. SRP is about cohesion, not about minimizing class size. A class with five tightly related methods that all serve one responsibility and always change together is more SRP-compliant than five classes that must always be modified in lockstep.
2. Open/Closed Principle (OCP)
What it actually means: Software entities should be open for extension, but closed for modification — you should be able to add new behavior without editing existing, tested code.
// Violates OCP — adding a new shape means editing this function
double area(Shape shape) {
if (shape.type == ShapeType::Circle) return 3.14159 * shape.radius * shape.radius;
if (shape.type == ShapeType::Square) return shape.side * shape.side;
// every new shape = another branch here, risking regressions in existing branches
}
// Fixed - new shapes extend, never modify, existing code
class Shape {
public:
virtual double area() const = 0;
virtual ~Shape() = default;
};
class Circle : public Shape {
public:
double area() const override { return 3.14159 * r_ * r_; }
private:
double r_;
};
// Adding Triangle later: new class, zero changes to Shape or existing shapes.Interview question: “OCP sounds great in theory. What’s the actual cost of designing for it upfront, and when is that cost not worth paying?”
Strong answer: Designing for extension upfront means introducing abstraction — interfaces, virtual dispatch, indirection — before you know the actual shape of future extension. If that guess is wrong (you build for extension along axis A, but the real future need is axis B), you’ve paid abstraction cost for nothing and still have to modify code anyway. This is why OCP is best applied retroactively, the first time you actually need a second variant — not speculatively for a single, currently-only implementation. Uncle Bob himself has walked back the “design for every possible future change” reading in favor of “let the pattern of actual changes guide where you add extension points.”
Follow-up trap: “Give a concrete example where following OCP made a codebase worse, not better.”
Good answer: A team builds a PaymentProcessor with a strategy interface anticipating "we might support many payment providers" — but the product only ever ships with one (Stripe) for its entire life. The interface, factory, and DI wiring add three files and a layer of indirection for a "family" of exactly one implementation, making the code harder to navigate for zero realized benefit. This is a textbook case of premature OCP — the fix would have been a concrete StripePaymentProcessor class, refactored into an interface only when a second provider was actually being added.
3. Liskov Substitution Principle (LSP)
What it actually means: Subtypes must be substitutable for their base types without altering the correctness of the program — a subclass shouldn’t just have the same method signatures, it must honor the same behavioral contract (preconditions, postconditions, invariants).
// The classic violation: Square "is-a" Rectangle mathematically,
// but not behaviorally, once you allow independent width/height mutation.
class Rectangle {
public:
virtual void setWidth(int w) { width_ = w; }
virtual void setHeight(int h) { height_ = h; }
int area() const { return width_ * height_; }
protected:
int width_ = 0, height_ = 0;
};
class Square : public Rectangle {
public:
void setWidth(int w) override { width_ = height_ = w; } // breaks caller expectations
void setHeight(int h) override { width_ = height_ = h; }
};
void resize(Rectangle& r) {
r.setWidth(5);
r.setHeight(10);
assert(r.area() == 50); // FAILS for Square - LSP violated
}Interview question: “Square-is-a-Rectangle is the textbook example. Give me a realistic LSP violation you’d actually catch in a code review — not the geometry one.”
Strong answer: A very common real-world case: ReadOnlyCollection inheriting from a mutable Collection base and throwing UnsupportedOperationException (or its equivalent) from add()/remove(). Any code that's written against the base Collection type and calls .add() — perfectly valid per the base contract — now crashes at runtime for a subtype that's supposed to be substitutable. This is exactly LSP violated: the subtype narrows the postconditions the base type promised (in this case, "adding an element succeeds"). The fix is usually to invert the hierarchy — have a narrow ReadableCollection interface that Collection extends, rather than making read-only a "restricted" subtype of mutable.
Follow-up trap: “How does LSP relate to preconditions and postconditions specifically — what’s the rule for each?”
Good answer: A subclass may weaken preconditions (accept a broader range of valid inputs than the base class required — that’s still compatible, since anything valid for the base is still valid) but must not strengthen preconditions (rejecting inputs the base class accepted breaks callers). Conversely, a subclass may strengthen postconditions (guarantee more than the base promised) but must not weaken postconditions (promise less than callers were told to expect, like Square::setWidth silently also changing height, which is an undocumented side effect the base contract never mentioned).
4. Interface Segregation Principle (ISP)
What it actually means: Clients shouldn’t be forced to depend on methods they don’t use — many small, focused interfaces beat one large, general-purpose one.
// Violates ISP — a Robot doesn't eat, but is forced to implement eat()
class Worker {
public:
virtual void work() = 0;
virtual void eat() = 0;
virtual ~Worker() = default;
};
class RobotWorker : public Worker {
public:
void work() override { /* ... */ }
void eat() override { throw std::logic_error("Robots don't eat"); } // red flag
};
// Fixed - segregated interfaces, implement only what applies
class Workable { public: virtual void work() = 0; virtual ~Workable() = default; };
class Eatable { public: virtual void eat() = 0; virtual ~Eatable() = default; };
class HumanWorker : public Workable, public Eatable { /* implements both, correctly */ };
class RobotWorker : public Workable { /* implements only work() */ };Interview question: “Where’s the line between good interface segregation and interface explosion — 15 tiny single-method interfaces that are a pain to wire up?”
Strong answer: Segregate along client usage patterns, not along “one method per interface” as a rule of thumb. If two methods are always needed together by every client that needs either one, keeping them on the same interface is fine and doesn’t violate ISP — the principle is about not forcing unrelated capabilities on a client, not about atomizing every interface to a single method. In practice: look at your actual call sites. If every implementer either needs the full interface or none of it, ISP isn’t being violated even if the interface has 5 methods. If implementers regularly need 2 of 8 methods and throw/stub the rest, that’s the smell ISP is meant to catch.
Follow-up trap: “You’ve got a RobotWorker that stubs eat() by throwing. Besides splitting the interface, what's a way to detect this smell automatically, in code review or static analysis?"
Get Ashutosh Jha’s stories in your inbox
Join Medium for free to get updates from this writer.
Good answer: A grep-able smell: search the codebase for method bodies that just throw, assert(false), return null, or have a // TODO: not applicable comment — these are almost always ISP violations in disguise (or LSP violations, since a throwing override also breaks substitutability, as discussed above). Some teams add a lint rule flagging any override whose entire body is a single throw statement, specifically to surface this pattern for review.
5. Dependency Inversion Principle (DIP)
What it actually means: High-level modules shouldn’t depend on low-level modules — both should depend on abstractions. And abstractions shouldn’t depend on details; details should depend on abstractions. This is the principle that makes dependency injection possible, not the same thing as DI itself.
// Violates DIP — high-level PaymentService directly depends on the concrete, low-level MySQLDatabase
class MySQLDatabase {
public:
void save(const std::string& record) { /* mysql-specific code */ }
};
class PaymentService {
public:
void processPayment() {
db_.save("payment record"); // tightly coupled to MySQL specifically
}
private:
MySQLDatabase db_;
};
// Fixed - both depend on an abstraction
class Database {
public:
virtual void save(const std::string& record) = 0;
virtual ~Database() = default;
};
class MySQLDatabase : public Database {
public:
void save(const std::string& record) override { /* mysql-specific code */ }
};
class PaymentService {
public:
explicit PaymentService(Database& db) : db_(db) {} // depends on the interface
void processPayment() { db_.save("payment record"); }
private:
Database& db_;
};Interview question: “Is DIP the same thing as Dependency Injection? People use the terms interchangeably.”
Strong answer: No, and this distinction is a good signal of depth in an interview. DIP is the design principle: high-level and low-level modules should both depend on abstractions, inverting the “naive” dependency direction where high-level code depends directly on low-level details. Dependency Injection is one technique for satisfying DIP — passing dependencies in via constructor/setter rather than having a class construct its own dependencies. You can follow DIP without a DI framework (manual constructor injection, as shown above, is enough), and you can use DI mechanically without actually achieving DIP (e.g., injecting a concrete MySQLDatabase instead of an abstraction — that's DI without the inversion).
Follow-up trap: “Where does the abstraction — the Database interface — actually live? Who owns it, PaymentService's module or MySQLDatabase's module?"
Good answer: This is the crux of DIP that’s often missed. The abstraction should be owned by the high-level module (or a neutral layer above both), not by the low-level module. Database should live alongside PaymentService, defined in terms of what the high-level policy needs — not alongside MySQLDatabase, defined in terms of what MySQL happens to offer. This is what actually "inverts" the dependency: in a naive design, low-level details dictate the interface; under true DIP, high-level policy dictates the interface, and low-level details must conform to it. Get this backwards and you've built DI plumbing without DIP's actual benefit — the interface still leaks low-level assumptions.
Part 2: The Other Classics
6. DRY — Don’t Repeat Yourself
What it actually means: “Every piece of knowledge must have a single, unambiguous, authoritative representation within a system.” Critically — this is about knowledge duplication, not code that merely looks similar. This distinction is the single most common way DRY is misapplied.
Interview question: “Two functions have identical-looking code but represent different business rules that happen to coincide right now. Do you DRY them up into one shared function?”
Strong answer: No — and this is the sharpest test of whether someone actually understands DRY versus pattern-matching on “duplicate code = bad.” If calculateShippingDiscount() and calculateLoyaltyDiscount() both currently compute price * 0.1, that's coincidental duplication, not knowledge duplication — they represent two independent business rules that happen to have the same value today. Merging them into a shared calculateDiscount() creates a false coupling: when Product asks to change the loyalty discount to 15%, you either break shipping discount too, or you're back to splitting the "shared" function apart under time pressure. DRY targets duplicated concepts, not duplicated text. A useful test: "if I had to change this business rule, would I want the other one to change too?" If no, don't merge them, no matter how identical the code looks today.
Follow-up trap: “What’s the practical failure mode of over-applying DRY, and what do people call the resulting anti-pattern?”
Good answer: Sometimes called “premature abstraction” or, more pointedly, DRY-driven coupling — collapsing superficially similar code into one shared function/class that now has multiple unrelated callers, each needing slightly different behavior. Over time this function accumulates conditional flags (isShipping ? ... : ...) to serve all callers, becoming harder to understand than the original "duplicated" version ever was. Sandi Metz's framing is useful here: prefer the "wrong abstraction" pain of some duplication over the "wrong abstraction" pain of a shared function that's grown a maze of special cases — duplication is far cheaper to fix later than an incorrect abstraction that many callers now depend on.
7. KISS — Keep It Simple, Stupid
What it actually means: Systems work best when kept simple; unnecessary complexity should be avoided — but “simple” needs a precise definition, or the principle becomes an excuse to under-engineer.
Interview question: “‘Simple’ is doing a lot of work in KISS. How do you define simple in a way that’s actually actionable, not just a vibe?”
Strong answer: Rich Hickey’s distinction between “simple” and “easy” is the most rigorous framing here: simple means not compound — few interleaved concerns, low essential complexity, easy to reason about in isolation. Easy means familiar or close at hand — using an ORM because you already know it, even if it hides significant complexity underneath. These aren’t the same, and conflating them is exactly how KISS gets misapplied: people choose the “easy” (familiar) tool and call it “simple,” when it may have quietly imported a large surface area of hidden behavior. A genuinely simple design has few moving parts that interact in few ways — regardless of whether it’s the path of least resistance to write today.
Follow-up trap: “When does pursuing KISS conflict with SOLID — for instance, OCP or DIP — and how do you resolve that tension?”
Good answer: Directly and often. Adding an interface + DI for a single, currently-only implementation is more moving parts (an abstraction, a factory or injection point, an implementation) than just calling the concrete class directly — SOLID’s OCP/DIP push toward more indirection in anticipation of future change, while KISS pushes toward the minimum structure that solves today’s problem. The resolution most experienced engineers land on: apply SOLID’s abstractions when a second concrete case actually shows up, not speculatively — this keeps you KISS-compliant right up until the moment OCP/DIP’s benefit becomes real rather than hypothetical. This is the same “extract on second occurrence” instinct that resolves the DRY-vs-premature-abstraction tension above — it’s a recurring theme, not a coincidence.
8. YAGNI — You Aren’t Gonna Need It
What it actually means: Don’t implement functionality on the speculation that you might need it later — build for the requirements you actually have.
Interview question: “How is YAGNI different from just being lazy or under-engineering? Where’s the line?”
Strong answer: YAGNI isn’t “don’t think ahead” — it’s “don’t build ahead.” The distinction is between design decisions that keep future options open cheaply (using an interface where a concrete class would do, if it costs almost nothing — arguably not even a YAGNI violation) versus building actual unused functionality (a full plugin system, configurable strategies for three algorithms when you use one, a generic EventBus for events that don't exist yet). The former is inexpensive foresight; the latter is speculative work that (a) may guess wrong about the actual future shape of the requirement, and (b) adds real maintenance and cognitive cost today, for a "later" that may never arrive. The test: "am I writing code to solve a problem I have, or a problem I'm imagining I might have?"
Follow-up trap: “Product tells you ‘we’ll definitely need multi-tenancy in 6 months, but not for this release.’ Does YAGNI mean you ignore that entirely?”
Good answer: No — this is the nuance that separates a good answer from a rote “just don’t build it” answer. YAGNI applies to implementing speculative functionality now, not to ignoring known future requirements when they affect present decisions that are expensive to reverse. If a known-but-not-yet-needed requirement (multi-tenancy) would be catastrophically expensive to retrofit later — e.g., it changes your primary key/data model shape — it’s reasonable to make cheap, low-cost accommodations now (like including a tenant_id column you don't populate meaningfully yet) without building the actual multi-tenant behavior. YAGNI says don't build the feature; it doesn't say be reckless about decisions with high reversal cost, when you have credible, concrete information about what's coming.
9. Law of Demeter (Principle of Least Knowledge)
What it actually means: A method should only talk to its immediate friends — its own fields, its parameters, objects it creates, and its direct component objects — not to objects obtained through those objects. Informally: “don’t talk to strangers,” or “one dot” (with caveats for fluent APIs).
// Violates Law of Demeter — reaching through Customer -> Wallet -> Money
void chargeCustomer(Customer& customer, double amount) {
if (customer.getWallet().getMoney().getBalance() >= amount) { // train wreck
customer.getWallet().getMoney().subtract(amount);
}
}
// Fixed - Customer exposes behavior, not internal structure
class Wallet {
public:
bool canAfford(double amount) const { return balance_ >= amount; }
void charge(double amount) { balance_ -= amount; }
private:
double balance_;
};
class Customer {
public:
bool canAfford(double amount) const { return wallet_.canAfford(amount); }
void charge(double amount) { wallet_.charge(amount); }
private:
Wallet wallet_;
};
void chargeCustomer(Customer& customer, double amount) {
if (customer.canAfford(amount)) customer.charge(amount); // one dot, talks to a friend only
}Interview question: “Doesn’t strict Law of Demeter just mean you write a ton of pass-through wrapper methods (Customer::canAfford just calling Wallet::canAfford)? Isn't that its own kind of bloat?"
Strong answer: Yes, taken rigidly, this is exactly the criticism the Law of Demeter draws, and it’s a fair one. The pattern above only pays off if Customer::canAfford represents genuine behavior delegation — Customer is exposing a capability, not just mechanically forwarding a getter chain. If every single method on an inner object needs a matching wrapper on the outer object "just in case," you get a bloated, low-value facade. The principle is best applied selectively, at genuine module/layer boundaries (don't let calling code reach through your domain object into a third-party library's internals, for example) rather than mechanically at every object composition in the codebase. Martin Fowler himself has noted the "one dot" heuristic is a simplification and shouldn't be applied as a hard style-checker rule.
Follow-up trap: “Does calling .getWallet().getBalance() really matter if Wallet is a stable, simple value-object with no real internal logic? Is LoD worth applying there?"
Good answer: This is the key nuance — Law of Demeter is really about coupling to structure that might change, not dots-on-a-line as a syntactic rule. If Wallet is a stable, near-immutable value type (like reaching into a std::pair or a simple DTO) unlikely to ever change its internal shape, chaining through it costs little and doesn't meaningfully increase fragility. LoD earns its value protecting against reaching through objects whose internal structure is likely to change — domain objects with evolving relationships — where a chain of getters means every caller breaks the moment that internal structure is refactored. Applying it uniformly to every object graph, including stable value types, is over-application of the same kind KISS and DRY warn against above.
Closing thoughts
Notice the thread running through nearly every “follow-up trap” question above: every principle has a counter-force that keeps it honest. OCP’s cost is DIP’s abstraction tax. DRY’s cost is coupling unrelated concepts. KISS pulls against SOLID’s future-proofing. YAGNI pulls against principled foresight. Even Law of Demeter, applied without judgment, produces its own bloat.
This is the actual signal senior interviewers are listening for — not whether you can define SOLID, but whether you instinctively reach for the opposing principle when one is being over-applied. Junior answers state the principle. Senior answers state the principle, then immediately name its limit.
Part of an ongoing series on software design fundamentals — see also the companion piece on the [Gang of Four design patterns and their interview questions].









