Design Principles | SOLID | DRY | KISS | YAGNI
Software development is inherently complex and requires deliberate low-level design (LLD) planning to produce scalable, maintainable, and readable codebases. Software design principles—specifically SOLID, DRY, KISS, and YAGNI—serve as foundational guidelines that prevent tight coupling, reduce bug regressions, simplify automated testing, and streamline long-term software maintenance across engineering teams.
1. Foundation of Clean Code: Why Design Principles Matter
Building production-grade software demands long-term sustainability. Without clear low-level design guidelines, codebases suffer from tight coupling, where a minor modification in one module unexpectedly breaks unrelated components across the application.
Core Business & Technical Motivations
- Scalability: Systems must handle expanding business logic and increasing traffic without architectural rewrites.
- Maintainability: In enterprise environments, team compositions change continuously. Code written today must be readable and safely modifiable by engineers joining tomorrow.
- Testability: Decoupled components allow focused unit testing and mocking. Tightly coupled classes require complex integration setups just to verify basic logic.
- Readability & Separation of Concerns: Isolating specific responsibilities into dedicated modules minimizes cognitive load during code reviews and debugging sessions.
- Resource & Time Savings: Re-running build pipelines, regression suites, and integration tests after touching legacy code can take hours or even days. Adhering to established design principles minimizes code breakage risks upfront.
2. Single Responsibility Principle (SRP)
Definition: A class should have one, and only one, reason to change. Every class must be assigned a single, well-defined responsibility or job.
Real-Life Analogy
Real-Life Analogy: The Bakery Workflow
Imagine a professional baker whose primary expertise is baking high-quality bread. If the bakery also assigns the baker to manage financial inventory, order raw ingredients from suppliers, serve retail customers, and sweep the floors, the baker's core efficiency and product quality decline significantly.
To optimize output, the bakery assigns distinct roles to specific people: a Baker bakes, an Inventory Manager tracks stock, a Supplier Manager orders inventory, a Customer Service Agent serves clients, and a Cleaner maintains hygiene.
Naive Implementation (Violating SRP)
In the bad approach, the BreadBaker class aggregates multiple distinct business domains into a single entity.
// VIOLATION: Single Responsibility Principle
public class BreadBaker {
public void bakeBread() {
System.out.println("Baking high-quality sourdough bread...");
}
public void manageInventory() {
System.out.println("Updating flour and yeast stock count...");
}
public void orderSupplies() {
System.out.println("Ordering bulk wheat from vendor...");
}
public void serveCustomer() {
System.out.println("Collecting payment and handing over bread...");
}
public void cleanBakery() {
System.out.println("Sanitizing baking station...");
}
}
Why This Fails in Production
- Low Readability & High Cognitive Load: An engineer looking to fix an inventory tracking bug must wade through unrelated baking and cleaning logic.
- Fragile Maintenance: Modifying how supplies are ordered requires altering
BreadBaker.java, introducing regression risks to core baking logic. - Complex Debugging: Debugging standard execution flows becomes difficult as single classes balloon to thousands of lines of unrelated code.
Refactored Implementation (Adhering to SRP)
We decouple each distinct concern into its own dedicated class.
// Dedicated class for core baking operations
public class BreadBaker {
public void bakeBread() {
System.out.println("Baking high-quality sourdough bread...");
}
}
// Dedicated class for managing stock and supplies
public class InventoryManager {
public void manageInventory() {
System.out.println("Updating stock counts...");
}
}
// Dedicated class for vendor ordering
public class SupplyOrderManager {
public void orderSupplies() {
System.out.println("Ordering supplies from vendors...");
}
}
// Dedicated class for customer interactions
public class CustomerService {
public void serveCustomer() {
System.out.println("Serving customer at the counter...");
}
}
// Dedicated class for sanitation
public class BakerCleaner {
public void cleanBakery() {
System.out.println("Cleaning and sanitizing bakery area...");
}
}
3. Open/Closed Principle (OCP)
Definition: Software entities (classes, modules, functions) should be open for extension, but closed for modification.
You should be able to add new capabilities to a system by writing new code rather than mutating existing, tested code.
Naive Implementation (Violating OCP)
The following AreaCalculator class relies on standard control structures (if-else / conditional branches) based on shape types.
// VIOLATION: Open/Closed Principle
public class Shape {
public String type;
public double radius;
public double width;
public double height;
}
public class AreaCalculator {
public double calculateArea(Shape shape) {
if ("Circle".equalsIgnoreCase(shape.type)) {
return Math.PI * shape.radius * shape.radius;
} else if ("Rectangle".equalsIgnoreCase(shape.type)) {
return shape.width * shape.height;
}
// Adding a 'Triangle' requires modifying this method directly!
return 0.0;
}
}
Production Consequences of Code Modification
- Regression Risks: Editing existing core classes introduces potential bugs into features that were previously stable and tested.
- CI/CD Overhead: Modifying legacy code forces long re-compilation cycles, full regression test execution, and extensive code review scrutiny.
Refactored Implementation (Adhering to OCP)
Define an interface abstraction for Shape and extend functionality by creating new classes.
public interface Shape {
double calculateArea();
}
public class Circle implements Shape {
private final double radius;
public Circle(double radius) {
this.radius = radius;
}
@Override
public double calculateArea() {
return Math.PI * radius * radius;
}
}
public class Rectangle implements Shape {
private final double width;
private final double height;
public Rectangle(double width, double height) {
this.width = width;
this.height = height;
}
@Override
public double calculateArea() {
return width * height;
}
}
// Extension: Adding a Triangle does NOT require touching existing Circle/Rectangle code
public class Triangle implements Shape {
private final double base;
private final double height;
public Triangle(double base, double height) {
this.base = base;
this.height = height;
}
@Override
public double calculateArea() {
return 0.5 * base * height;
}
}
Note: For complex computation variations, OCP can be combined with design patterns like the Strategy Pattern to dynamically inject algorithmic behaviors without altering calling code.
4. Liskov Substitution Principle (LSP)
Definition: Subtypes must be substitutable for their base types without altering the correctness of the program.
Formulated by Barbara Liskov in 1987, LSP dictates that a subclass should fulfill the complete behavioral contract defined by its parent class without throwing unexpected runtime exceptions or altering base class semantics.
Naive Implementation (Violating LSP)
In this flawed hierarchy, Bicycle inherits from Vehicle, but forced implementation of startEngine() throws a runtime exception because bicycles do not possess engines.
// VIOLATION: Liskov Substitution Principle
public class Vehicle {
public void startEngine() {
System.out.println("Engine started.");
}
}
public class Car extends Vehicle {
@Override
public void startEngine() {
System.out.println("Vroom! Car engine running.");
}
}
public class Bicycle extends Vehicle {
@Override
public void startEngine() {
// Violates LSP! Fails behavioral contract of the parent class
throw new UnsupportedOperationException("Bicycles do not have engines!");
}
}
Why Polymorphic Substitution Breaks Here
Vehicle vehicle = new Bicycle();
vehicle.startEngine(); // Throws Unexpected Runtime Exception!
Client code written to accept a generic Vehicle object will unexpectedly crash at runtime when passed a Bicycle. Workarounds require defensive if (vehicle instanceof Bicycle) checks, ruining polymorphic abstraction.
Refactored Implementation (Adhering to LSP)
Restructure the class hierarchy to reflect accurate domain capabilities.
public abstract class Vehicle {
public abstract void move();
}
public abstract class EngineVehicle extends Vehicle {
public abstract void startEngine();
}
public abstract class NonEngineVehicle extends Vehicle {
// Shared behavior specific to non-engine vehicles can reside here
}
public class Car extends EngineVehicle {
@Override
public void startEngine() {
System.out.println("Car engine started.");
}
@Override
public void move() {
System.out.println("Car driving on road.");
}
}
public class Bicycle extends NonEngineVehicle {
@Override
public void move() {
System.out.println("Bicycle pedaling forward.");
}
}
Hierarchy Comparison
5. Interface Segregation Principle (ISP)
Definition: Clients should not be forced to depend upon interfaces that they do not use.
Avoid bloated, monolithic "fat" interfaces. Instead, segregate large interfaces into smaller, specialized, and role-specific interfaces.
Naive Implementation (Violating ISP)
A single Machine interface forces simple, standard hardware to implement irrelevant operations.
// VIOLATION: Interface Segregation Principle
public interface Machine {
void print();
void scan();
void fax();
}
public class BasicPrinter implements Machine {
@Override
public void print() {
System.out.println("Printing document...");
}
@Override
public void scan() {
// Forced dummy implementation or runtime exception
throw new UnsupportedOperationException("Basic printer cannot scan!");
}
@Override
public void fax() {
// Forced dummy implementation or runtime exception
throw new UnsupportedOperationException("Basic printer cannot fax!");
}
}
Refactored Implementation (Adhering to ISP)
Break the monolithic contract into highly focused, single-purpose interfaces.
public interface Printer {
void print();
}
public interface Scanner {
void scan();
}
public interface Fax {
void fax();
}
// Basic printer implements ONLY what it supports
public class BasicPrinter implements Printer {
@Override
public void print() {
System.out.println("Printing standard document...");
}
}
// Advanced multi-function printer implements all necessary specialized contracts
public class AllInOnePrinter implements Printer, Scanner, Fax {
@Override
public void print() {
System.out.println("All-in-One: Printing...");
}
@Override
public void scan() {
System.out.println("All-in-One: Scanning...");
}
@Override
public void fax() {
System.out.println("All-in-One: Faxing...");
}
}
6. Dependency Inversion Principle (DIP)
Definition:
- High-level modules should not depend on low-level modules. Both should depend on abstractions.
- Abstractions should not depend on details. Details should depend on abstractions.
High-level business logic must be completely isolated from concrete low-level implementation details (e.g., direct database connections, third-party notification APIs, specific logging framework instances).
Naive Implementation (Violating DIP)
In this approach, OrderService directly instantiates a concrete EmailNotifier inside its own constructor.
// VIOLATION: Dependency Inversion Principle
public class EmailNotifier {
public void sendEmail(String message) {
System.out.println("Sending Email via SMTP: " + message);
}
}
public class OrderService {
private EmailNotifier emailNotifier;
public OrderService() {
// Tightly coupled hardcoded instantiation!
this.emailNotifier = new EmailNotifier();
}
public void processOrder(String orderId) {
System.out.println("Order processed: " + orderId);
emailNotifier.sendEmail("Order " + orderId + " was successful.");
}
}
Why This Impairs Architecture
- Zero Swapability: Switching from email to SMS or Push notifications requires modifying internal lines of code inside
OrderService. - Blocked Unit Testing: You cannot mock
EmailNotifiercleanly during unit testing ofOrderServicewithout resorting to heavy bytecode manipulation frameworks (e.g., PowerMock).
Refactored Implementation (Adhering to DIP)
Introduce an abstract interface and use Dependency Injection to pass low-level instances into the high-level module.
// Abstraction contract
public interface NotificationService {
void sendNotification(String message);
}
// Concrete implementation 1: Email
public class EmailNotifier implements NotificationService {
@Override
public void sendNotification(String message) {
System.out.println("SMTP Email: " + message);
}
}
// Concrete implementation 2: SMS
public class SMSNotifier implements NotificationService {
@Override
public void sendNotification(String message) {
System.out.println("SMS Gateway: " + message);
}
}
// High-level module depends strictly on the abstraction contract
public class OrderService {
private final NotificationService notificationService;
// Dependency Injection via Constructor
public OrderService(NotificationService notificationService) {
this.notificationService = notificationService;
}
public void processOrder(String orderId) {
System.out.println("Order processed: " + orderId);
notificationService.sendNotification("Order " + orderId + " completed.");
}
}
7. Don't Repeat Yourself (DRY) Principle
Definition: Every piece of knowledge or logic must have a single, unambiguous, authoritative representation within a system.
The DRY principle focuses on eliminating duplication across functions, algorithms, class implementations, and hardcoded constants.
Strategies for Applying DRY
- Helper Functions & Utility Methods: Extract repeated business computations or inline mutations (such as array swaps or formatting strings) into isolated helper methods.
- OOP Abstraction & Inheritance: Place shared rendering, validation, or state-management logic inside abstract parent classes.
- Centralized Configuration Constants: Extract magic numbers and hardcoded string values (e.g., API endpoints, default connection timeouts) into single source-of-truth configuration files or static constant classes.
Code Evolution Example: Eliminating Duplicated Logic
Naive Code (Violating DRY)
public class CancelButton {
public void onClick() {
// Redundant graphic & animation execution logic
System.out.println("Rendering UI animation frame at 60fps...");
System.out.println("Cancelling operation...");
}
}
public class SubmitButton {
public void onClick() {
// Redundant graphic & animation execution logic duplicated
System.out.println("Rendering UI animation frame at 60fps...");
System.out.println("Submitting form data...");
}
}
Refactored DRY-Compliant Code
public abstract class BaseButton {
// Shared centralized logic
protected void renderAnimation() {
System.out.println("Rendering UI animation frame at 60fps...");
}
public abstract void onClick();
}
public class CancelButton extends BaseButton {
@Override
public void onClick() {
renderAnimation();
System.out.println("Cancelling operation...");
}
}
public class SubmitButton extends BaseButton {
@Override
public void onClick() {
renderAnimation();
System.out.println("Submitting form data...");
}
}
DRY Pitfall: Over-Abstraction Warning
Note: Avoid premature optimization and over-abstraction. Attempting to unify completely unrelated components just because they happen to share two lines of similar-looking code can tightly couple fundamentally different domains.
Unwinding flawed over-abstractions in legacy enterprise codebases often requires massive refactoring efforts—sometimes taking engineers over a year to resolve safely.
8. Keep It Simple, Stupid (KISS) Principle
Definition: Systems work best if they are kept simple rather than made complicated. Avoid unnecessary cleverness, obfuscated logic, and over-engineering.
High-Impact Metric: "Simple for Others, Not Just Simple for You"
Writing a dynamic 1-line mathematical expression or complex bitwise nested operation may feel intuitive to the author, but if it increases cognitive load for peer reviewers and maintenance developers, it violates the KISS principle.
Naive Implementation (Over-Complicated Implementation Logic)
// VIOLATION: KISS Principle (Cryptic, convoluted factorial implementation)
public class FactorialCalculator {
public int calculate(int n) {
// Cryptic expression written to look clever or avoid standard loops
return (n <= 1) ? 1 : java.util.stream.IntStream.rangeClosed(1
, n).reduce(1, (a, b) -> a * b);
}
}
Refactored Implementation (Clear, Clean, and Concise)
public class FactorialCalculator {
public int calculateFactorial(int number) {
if (number < 0) {
throw new IllegalArgumentException("Number must be non-negative.");
}
int factorial = 1;
for (int i = 1; i <= number; i++) {
factorial *= i;
}
return factorial;
}
}
Core Guidelines for Maintaining KISS
- Deconstruct Problems: Break complex engineering tasks into discrete, easily digestible sub-problems.
- Self-Documenting Naming: Use explicit variable names (e.g.,
subtotalPrice,taxRatePercent) instead of single-letter tokens (x,t1). - Targeted Abstraction: Avoid creating extra abstract layers unless multi-type runtime polymorphism is explicitly required by current business parameters.
9. You Aren't Gonna Need It (YAGNI) Principle
Definition: Always implement features when you actually need them, never when you merely foresee that you might need them.
YAGNI is an extreme-programming (XP) principle derived to prevent speculative feature development. Implementing unused capabilities bloats testing requirements, pollutes APIs, and burns engineering hours.
Example: Payment Gateway Requirement
Current Business Requirement
"Build a payment processor that handles Debit Card and Credit Card transactions."
Speculative Over-Engineering (Violating YAGNI)
An engineer builds an extensive framework supporting Debit Cards, Credit Cards, PayPal, Crypto Wallet Connect, and Automated Clearing House (ACH) transfers under the assumption: "We will probably accept Crypto payments next year."
// VIOLATION: YAGNI Principle (Speculative implementation of unrequested crypto processing)
public class PaymentProcessor {
public void processDebit(double amount) {
System.out.println("Processing debit card: $" + amount);
}
public void processCredit(double amount) {
System.out.println("Processing credit card: $" + amount);
}
// Speculative, unused logic polluting current release codebase
public void processCryptoWeb3(String walletAddress, double gasFee) {
throw new UnsupportedOperationException("Unused feature built speculatively!");
}
}
Production Costs of Speculative Development
- Wasted Engineering Capital: Writing, testing, and debugging unrequested features consumes precious sprint bandwidth.
- Maintenance Overhead: Unused code paths still require maintenance whenever underlying base framework classes are refactored.
- Interview Strategy Trap: In Low-Level Design interviews, speculatively writing code for unasked features wastes time, often preventing candidates from completing core requested logic.
10. SOLID, DRY, KISS, and YAGNI Comparison Matrix
The matrix below synthesizes how these fundamental principles apply to low-level design tasks, interview discussions, and production application refactoring.
Summary and Key Takeaways
- Decoupling Drives Scalability: Software design principles exist to transform fragile, tightly coupled systems into modular, testable, and maintainable applications.
- SOLID Core Foundation:
- SRP: Restrict every class to a single operational responsibility.
- OCP: Add new features by adding new code (extensions), not by editing existing, verified code (modifications).
- LSP: Design subclasses that fulfill their parent contracts without throwing runtime exceptions during polymorphic substitution.
- ISP: Split fat interfaces into fine-grained, role-specific contracts.
- DIP: Invert direct dependency directions by injecting abstract interface contracts instead of hardcoding concrete implementations.
- Maintain single truth sources with DRY: Unify duplicate computations, algorithms, and magical strings into helper functions, parent abstract classes, or static constants. Avoid over-abstraction.
- Reduce cognitive load using KISS: Prioritize readability and simplicity over cleverness. Ensure code is simple for reviewers and future maintainers to digest.
- Eliminate speculative code with YAGNI: Implement features that satisfy explicit current requirements. Verbally explain architectural extensibility during design discussions, but avoid writing code for unrequested capabilities.
- Synergistic Execution: Applying these principles together establishes clean architectures, prevents long debugging sessions, and accelerates engineering delivery.