SOLID Principles, DRY, KISS, YAGNI & Clean Architecture

10 Jul 2026 15 min read
1
Principles such as SOLID, DRY, KISS, and YAGNI provide practical guidelines for organizing code, reducing coupling, improving cohesion, and making systems easier to maintain.

They are not strict rules that every class must follow. Instead, they help developers make better design decisions based on the problem they are solving.

SOLID Principles

The SOLID Principles are five object-oriented design principles introduced by Robert C. Martin (Uncle Bob). Their primary objective is to make software easier to understand, maintain, test, and extend as requirements evolve. The five principles are:

S β€” Single Responsibility Principle
O β€” Open-Closed Principle
L β€” Liskov Substitution Principle
I β€” Interface Segregation Principle
D β€” Dependency Inversion Principle

Single Responsibility Principle (SRP)

The Single Responsibility Principle (SRP) states that a class should have only one reason to change. A responsibility represents a single business concern or responsibility, not simply the number of methods inside the class.

A class can contain many methods and still follow SRP if all those methods contribute to the same responsibility. Conversely, even a small class can violate SRP if it performs unrelated tasks.

Bad Example

The following service performs multiple unrelated tasks.
public class UserService {

    public void register(User user) {
        validate(user);
        userRepository.save(user);
        sendWelcomeEmail(user);
        publishKafkaEvent(user);
        audit(user);
    }

    private void validate(User user) {
    }

    private void sendWelcomeEmail(User user) {
    }

    private void publishKafkaEvent(User user) {
    }

    private void audit(User user) {
    }
}
This class is responsible for validation, persistence, email notifications, event publishing, and auditing. A change in any one of these areas requires modifying the same class.

Better Design

Each responsibility is delegated to a dedicated component.

public class UserService {

    private final Validator validator;
    private final UserRepository repository;
    private final EmailService emailService;
    private final EventPublisher publisher;

    public void register(User user) {
        validator.validate(user);
        repository.save(user);
        emailService.sendWelcomeEmail(user);
        publisher.publish(user);
    }
}
Now each dependency has a clearly defined responsibility. The service coordinates the business workflow instead of implementing every concern itself.

Open-Closed Principle (OCP)

The Open-Closed Principle (OCP) states that software entities such as classes, modules, and functions should be open for extension but closed for modification. In other words, adding new functionality should not require changing existing, tested code.

This principle reduces the risk of introducing bugs into working code and makes applications easier to extend as business requirements evolve. OCP is commonly achieved using interfaces, abstraction, inheritance, and composition.

Bad Example

Every time a new payment method is introduced, the existing class must be modified.
public class PaymentService {

    public void processPayment(String type) {

        if ("CARD".equals(type)) {
            System.out.println("Processing Card Payment");

        } else if ("PAYPAL".equals(type)) {
            System.out.println("Processing PayPal Payment");

        } else if ("UPI".equals(type)) {
            System.out.println("Processing UPI Payment");
        }
    }
}
Whenever a new payment method such as Apple Pay or Google Pay is added, this class must be modified, violating the Open-Closed Principle.

Better Design

Define a common interface and let each payment method provide its own implementation.
public interface PaymentProcessor {
    void process();
}

public class CardPayment
        implements PaymentProcessor {

    @Override
    public void process() {
        System.out.println("Processing Card Payment");
    }
}

public class PayPalPayment
        implements PaymentProcessor {

    @Override
    public void process() {
        System.out.println("Processing PayPal Payment");
    }
}

public class PaymentService {

    public void processPayment(
            PaymentProcessor processor) {
        processor.process();
    }
}
Now new payment methods can be added by creating another implementation of PaymentProcessor without modifying the existing PaymentService class.

Liskov Substitution Principle (LSP)

The Liskov Substitution Principle (LSP) states that objects of a superclass should be replaceable with objects of their subclass without affecting the correctness of the program.

In simple terms, if class B extends class A, then B should be usable wherever A is expected without changing the application's behavior.

A subclass should extend the behavior of its parent, not break or restrict it. Violating this principle often indicates that inheritance is being used incorrectly and that composition or a different abstraction may be more appropriate.

Bad Example

A classic example is using inheritance where it does not logically fit.
class Bird {

    public void fly() {
        System.out.println("Flying...");
    }
}

class Penguin extends Bird {

    @Override
    public void fly() {
        throw new UnsupportedOperationException(
                "Penguins can't fly");
    }
}
Any code expecting a Bird can now fail at runtime if a Penguin is passed, violating the Liskov Substitution Principle.

Better Design

Extract only the common behavior into the parent class and move flying capability into a separate abstraction.
class Bird {
}

interface Flyable {
    void fly();
}

class Sparrow extends Bird implements Flyable {
    @Override
    public void fly() {
        System.out.println("Flying...");
    }
}

class Penguin extends Bird {
}
Now every class correctly represents its behavior. A Penguin is still a Bird, but it is no longer forced to implement functionality that does not apply to it.

Interface Segregation Principle (ISP)

The Interface Segregation Principle (ISP) states that clients should not be forced to depend on interfaces they do not use.

Instead of creating one large interface with many unrelated methods, split it into smaller, more focused interfaces so that implementing classes only provide the functionality they actually need.

Large interfaces often lead to empty implementations, unsupported operations, and unnecessary coupling. Smaller, well-defined interfaces improve flexibility, readability, and maintainability.

Bad Example

The interface forces every implementation to provide methods that may not be applicable.
interface Worker {
    void work();
    void eat();
    void sleep();
}

class Robot implements Worker {

    @Override
    public void work() {
        System.out.println("Working...");
    }

    @Override
    public void eat() {
        throw new UnsupportedOperationException();
    }

    @Override
    public void sleep() {
        throw new UnsupportedOperationException();
    }
}
A robot can work, but it does not eat or sleep. Being forced to implement these methods violates the Interface Segregation Principle.

Better Design

Split the interface into smaller, more specific interfaces.
interface Workable {
    void work();
}

interface Eatable {
    void eat();
}

interface Sleepable {
    void sleep();
}

class Human implements Workable, Eatable, Sleepable {

    @Override
    public void work() {
        System.out.println("Working...");
    }

    @Override
    public void eat() {
        System.out.println("Eating...");
    }

    @Override
    public void sleep() {
        System.out.println("Sleeping...");
    }
}

class Robot implements Workable {

    @Override
    public void work() {
        System.out.println("Working...");
    }
}
Each class now implements only the interfaces it actually requires, resulting in cleaner and more maintainable code.

Dependency Inversion Principle (DIP)

The Dependency Inversion Principle (DIP) states that high-level modules should not depend on low-level modules. Both should depend on abstractions.

Additionally, abstractions should not depend on details; details should depend on abstractions.

Instead of creating and tightly coupling concrete implementations inside a class, dependencies should be injected through interfaces. This makes the code more flexible, easier to test, and allows implementations to be changed without modifying the business logic.

Bad Example

The service directly depends on a concrete implementation.
class MySqlDatabase {

    public void save(String data) {
        System.out.println("Saving to MySQL");
    }
}

class UserService {

    private final MySqlDatabase database =
            new MySqlDatabase();

    public void register(String user) {
        database.save(user);
    }
}
If the application needs to switch from MySQL to PostgreSQL, MongoDB, or another database, the UserService must be modified.

Better Design

Depend on an abstraction instead of a concrete class.
interface Database {
    void save(String data);
}

class MySqlDatabase implements Database {

    @Override
    public void save(String data) {
        System.out.println("Saving to MySQL");
    }
}

class PostgreSqlDatabase implements Database {

    @Override
    public void save(String data) {
        System.out.println("Saving to PostgreSQL");
    }
}

class UserService {

    private final Database database;

    public UserService(Database database) {
        this.database = database;
    }

    public void register(String user) {
        database.save(user);
    }
}
Now UserService depends only on the Database interface. Switching databases simply requires providing a different implementation without changing the service itself.

DRY (Don't Repeat Yourself)

The DRY (Don't Repeat Yourself) principle states that every piece of knowledge or logic should have a single, authoritative representation within a system. Instead of duplicating the same code or business logic across multiple classes, it should be extracted into a reusable component.

Duplicated code increases maintenance effort because every change must be applied in multiple places. If one copy is updated while another is forgotten, the application can become inconsistent and introduce bugs.

Bad Example

The same discount calculation logic is duplicated in multiple services.
class OrderService {

    public double calculatePrice(double amount) {
        if (amount > 1000) {
            return amount * 0.9;
        }
        return amount;
    }
}

class InvoiceService {

    public double calculatePrice(double amount) {
        if (amount > 1000) {
            return amount * 0.9;
        }
        return amount;
    }
}
If the discount changes from 10% to 15%, both classes must be updated. Missing one location can lead to inconsistent business behavior.

Better Design

Extract the shared logic into a reusable component.
class DiscountCalculator {

    public double applyDiscount(double amount) {
        if (amount > 1000) {
            return amount * 0.9;
        }
        return amount;
    }
}

class OrderService {
    private final DiscountCalculator calculator;

    public double calculatePrice(double amount) {
        return calculator.applyDiscount(amount);
    }
}

class InvoiceService {
    private final DiscountCalculator calculator;

    public double calculatePrice(double amount) {
        return calculator.applyDiscount(amount);
    }
}
Now the discount logic exists in only one place. Any future change needs to be made only once.

DRY should not be applied by combining two pieces of code that only appear similar today but may evolve independently. Premature abstraction often creates overly generic components that are harder to understand than a small amount of duplication.

A little duplication is usually better than the wrong abstraction.

KISS (Keep It Simple, Stupid)

The KISS (Keep It Simple, Stupid) principle states that software should be as simple as possible while still solving the problem correctly. Simple code is easier to understand, maintain, test, and debug. Complexity should only be introduced when it provides a clear benefit.

Many developers try to anticipate future requirements by introducing unnecessary abstractions, design patterns, or generic solutions. This often makes the code harder to understand without solving any real problem.

Bad Example

A simple calculation is hidden behind multiple layers of abstraction.
interface Calculator {
    int calculate(int a, int b);
}

class AdditionCalculator implements Calculator {

    @Override
    public int calculate(int a, int b) {
        return a + b;
    }
}

class CalculatorFactory {

    public static Calculator getCalculator() {
        return new AdditionCalculator();
    }
}
For a simple addition operation, introducing interfaces and factories adds unnecessary complexity without improving flexibility.

Better Design

Keep the solution simple until additional complexity is actually required.
public class Calculator {
    public int add(int a, int b) {
        return a + b;
    }
}
This implementation is easier to read, test, and maintain. If future requirements demand multiple calculation strategies, the design can be refactored later.

KISS does not mean writing simplistic code or ignoring good design practices. If a problem genuinely requires abstraction, extensibility, or multiple implementations, introducing additional structure is justified.

YAGNI (You Aren't Gonna Need It)

The YAGNI (You Aren't Gonna Need It) principle states that you should implement functionality only when it is actually needed, not when you think it might be needed in the future.

It encourages developers to focus on current business requirements instead of building speculative features that may never be used.

Prematurely adding abstractions, configurations, or extensibility points increases complexity, makes the code harder to understand, and often results in unused code that must still be maintained.

Bad Example

The application currently supports only email notifications, but multiple implementations and a factory have been introduced in anticipation of future requirements.
interface NotificationService {

    void send(String message);
}

class EmailNotificationService
        implements NotificationService {

    @Override
    public void send(String message) {
        System.out.println("Sending Email");
    }
}

class SmsNotificationService
        implements NotificationService {

    @Override
    public void send(String message) {

    }
}

class PushNotificationService
        implements NotificationService {

    @Override
    public void send(String message) {

    }
}

class NotificationFactory {

    public NotificationService get(String type) {
        // Future implementation
        return null;
    }
}
Only email notifications are required today, yet the design includes unused implementations and infrastructure for features that do not exist.

Better Design

Implement only the functionality required by the current business need.
public class EmailNotificationService {

    public void send(String message) {
        System.out.println("Sending Email");
    }
}
If the business later requires SMS or Push notifications, the design can be refactored to introduce interfaces and multiple implementations. Until then, the simpler solution is easier to understand and maintain.

Clean Architecture

Clean Architecture, proposed by Robert C. Martin (Uncle Bob), is an architectural approach that organizes an application into independent layers with clear responsibilities. Its primary goal is to ensure that business logic remains independent of frameworks, databases, user interfaces, and external systems.

In traditional applications, business logic often becomes tightly coupled to technologies such as Spring Boot, Hibernate, or a specific database. As a result, changing the database, replacing a messaging system, or exposing a new API requires modifications throughout the application.

Clean Architecture solves this problem by making the business rules the center of the application, while all external technologies become replaceable implementation details.

Dependency Rule

The most important rule of Clean Architecture is the Dependency Rule:
Source code dependencies must always point inward, toward the business logic.
Outer layers can depend on inner layers, but inner layers should never depend on outer layers.

This ensures that business logic remains independent of frameworks and external technologies.

Architecture Layers

A typical Clean Architecture consists of four layers.

1. Domain Layer: The Domain layer contains the core business entities and business rules. It should not depend on Spring Boot, JPA, Hibernate, or any external library.
public class User {

    private Long id;
    private String name;
    // Business rules
}
2. Application Layer: The Application layer contains use cases that coordinate business operations.
public class RegisterUserUseCase {

    private final UserRepository repository;

    public RegisterUserUseCase(UserRepository repository) {
        this.repository = repository;
    }

    public void execute(User user) {
        repository.save(user);
    }
}
Notice that it depends on the UserRepository interface, not a database implementation.

3. Infrastructure Layer: This layer contains implementation details such as databases, messaging systems, Redis, Kafka, REST clients, and file systems.
@Repository
public class JpaUserRepository
        implements UserRepository {

    @Override
    public void save(User user) {
        // JPA Implementation
    }
}
The business logic does not know or care that JPA is being used.

4. Presentation Layer: The Presentation layer exposes the application through REST APIs, GraphQL, messaging, or other interfaces.
@RestController
@RequestMapping("/users")
public class UserController {

    private final RegisterUserUseCase useCase;

    @PostMapping
    public void register(
            @RequestBody User user) {

        useCase.execute(user);
    }
}
The controller only receives HTTP requests and delegates the work to the use case. This separation makes each layer responsible for a specific concern and minimizes coupling between different parts of the application.

Typical Project Structure

A typical Clean Architecture project organizes code into separate packages based on their responsibilities rather than technical implementation details. This separation keeps the business logic independent of frameworks and external systems, making the application easier to maintain, test, and extend.

Because the business logic is isolated from external technologies, applications become easier to test, maintain, and evolve. Replacing MySQL with PostgreSQL, introducing Kafka, or exposing GraphQL instead of REST typically affects only the Infrastructure or Presentation layers, while the core business rules remain unchanged.

High Cohesion & Low Coupling

One of the primary goals of good software design is to achieve high cohesion and low coupling. Almost every design principle discussed so far ultimately aims to improve one or both of these characteristics.

A well-designed application consists of components that have a single, well-defined responsibility (high cohesion) while interacting with other components through minimal dependencies (low coupling).

High Cohesion

Cohesion measures how closely related the responsibilities of a class or module are. A highly cohesive class focuses on solving a single business problem, with all of its methods working toward that purpose.

Classes with high cohesion are generally smaller, easier to read, and easier to test because their responsibilities are clearly defined.

Bad Example

The following class mixes multiple unrelated responsibilities.
public class UserManager {

    public void register(User user) {

    }

    public void sendEmail(User user) {

    }

    public void generateReport() {

    }

    public void backupDatabase() {

    }
}
This class manages users, sends emails, generates reports, and performs database backups. These responsibilities are unrelated, making the class difficult to maintain.

Better Design

Separate each responsibility into its own class.
public class UserService {

    public void register(User user) {

    }
}

public class EmailService {

    public void send(User user) {

    }
}

public class ReportService {

    public void generate() {

    }
}
Each class now has a clear purpose, making the code easier to understand and modify.

Low Coupling

Coupling measures how dependent one class is on another. Low coupling means that components communicate through abstractions instead of concrete implementations, allowing one component to change without affecting others.

Reducing coupling improves flexibility, testability, and maintainability because components can evolve independently.

Bad Example

The service directly creates its dependency.
public class UserService {

    private final EmailService emailService =
            new EmailService();

    public void register(User user) {
        emailService.send(user);
    }
}
The UserService is tightly coupled to a specific implementation of EmailService. Replacing it with another notification mechanism requires modifying the class.

Better Design

Depend on an abstraction and inject the implementation.
public interface NotificationService {

    void send(User user);
}

public class EmailService
        implements NotificationService {

    @Override
    public void send(User user) {

    }
}

public class UserService {

    private final NotificationService notificationService;

    public UserService(
            NotificationService notificationService) {

        this.notificationService = notificationService;
    }

    public void register(User user) {
        notificationService.send(user);
    }
}
The UserService no longer depends on a specific implementation. Any notification mechanism can be introduced without modifying the business logic.

Conclusion

Writing maintainable software is not about following every design principle blindly, but about making thoughtful design decisions based on the problem at hand.

Principles such as SOLID, DRY, KISS, and YAGNI, together with concepts like Clean Architecture, High Cohesion, and Low Coupling, help build applications that are easier to understand, test, extend, and maintain.

As applications grow, these principles become increasingly valuable by reducing technical debt, minimizing the impact of change, and improving code quality.
Nagesh Chauhan

Nagesh Chauhan

Principal Software Engineer β€’ Java β€’ Python β€’ Distributed Systems β€’ AI/ML

Principal Software Engineer with 14+ years of experience designing and delivering large-scale distributed systems, cloud-native applications, and AI-powered platforms.

Passionate about solving complex engineering problems using strong data structures and algorithms, along with expertise in Java, Spring Boot, Python, System Design, Microservices, Cloud, Kafka, Elasticsearch, and Generative AI.

Share this Article

πŸ’¬ Comments

Join the Discussion