Software Engineering

SOLID: Good OOP Principles

4 min read
OOPDesign PatternsSoftware EngineeringJavaBest Practices

SOLID principles kept coming up in interview prep and code reviews. At first, they seemed like abstract theory, but as I started applying them to real code, I saw how they actually make software more maintainable. These five principles have become my guide for writing better object-oriented code.

What Is SOLID?

SOLID is an acronym for five object-oriented programming design principles introduced by Robert C. Martin. These principles help you write code that's easier to maintain, test, and extend. I've found them especially useful when working on larger codebases.

Understanding OOP First

Before diving into SOLID, I needed to understand OOP basics. In object-oriented programming, we work with objects that have:

  • State: The data/attributes an object holds
  • Behavior: The methods/actions an object can perform

Here's a simple example I used to understand:

public class Person {
    // State
    private String name;
    private int age;

    // Constructor
    public Person(String name, int age) {
        this.name = name;
        this.age = age;
    }

    // Behavior
    public void sayHello() {
        System.out.println("Hello, my name is " + name);
    }
}

Now let me walk through each SOLID principle with examples from my own learning journey.

Single Responsibility Principle (SRP)

One class, one reason to change.

I learned this the hard way. I once wrote a class that handled user authentication, sent emails, and generated reports. When I needed to change the email format, I had to modify the same class that handled authentication. That's when I realized the problem.

A class should have only one responsibility. Here's a better approach:

public class UserAuthenticator {
    public boolean authenticate(String username, String password) {
        // Only handles authentication
    }
}

public class EmailService {
    public void sendEmail(String to, String subject, String body) {
        // Only handles email sending
    }
}

public class ReportGenerator {
    public void generateReport(Data data) {
        // Only handles report generation
    }
}

What I learned: When each class has one job, changes are isolated and the code is easier to understand.

Open-Closed Principle (OCP)

Open for extension, closed for modification.

I was working on a shape area calculator. My first version had if-else statements for each shape type:

public class AreaCalculator {
    public double calculateArea(Shape shape) {
        if (shape instanceof Rectangle) {
            // calculate rectangle area
        } else if (shape instanceof Circle) {
            // calculate circle area
        }
        // Adding a new shape means modifying this class!
    }
}

Then I learned about polymorphism and abstract classes:

public abstract class Shape {
    public abstract double calculateArea();
}

public class Rectangle extends Shape {
    private double length;
    private double width;

    public double calculateArea() {
        return length * width;
    }
}

public class Circle extends Shape {
    private double radius;

    public double calculateArea() {
        return Math.PI * radius * radius;
    }
}

public class AreaCalculator {
    public double calculateTotalArea(Shape[] shapes) {
        double total = 0;
        for (Shape shape : shapes) {
            total += shape.calculateArea(); // Works for any Shape!
        }
        return total;
    }
}

Now I can add new shapes without modifying existing code!

What I learned: Use abstraction and polymorphism to make code extensible without changing existing implementations.

Liskov Substitution Principle (LSP)

Subtypes must be substitutable for their base types.

I made a mistake once. I created a Square class that extended Rectangle, but Square violated the rectangle's behavior:

public class Rectangle {
    protected int width;
    protected int height;

    public void setWidth(int width) {
        this.width = width;
    }

    public void setHeight(int height) {
        this.height = height;
    }
}

public class Square extends Rectangle {
    // This violates LSP!
    public void setWidth(int width) {
        this.width = width;
        this.height = width; // Square forces width = height
    }

    public void setHeight(int height) {
        this.width = height;
        this.height = height; // This breaks rectangle behavior!
    }
}

The problem: code expecting a Rectangle might break with a Square because Square changes the expected behavior.

What I learned: Derived classes should maintain the contract of their base class. If they can't, they shouldn't inherit from it.

Interface Segregation Principle (ISP)

Clients shouldn't depend on interfaces they don't use.

I once created a huge interface that had methods for everything:

public interface BankAccount {
    void deposit(double amount);
    void withdraw(double amount);
    double getBalance();
    void transfer(BankAccount destination, double amount);
    void calculateInterest();
}

But not all account types need all these methods. A savings account might not need transfer(), and a checking account might not need calculateInterest().

Better approach:

public interface DepositAccount {
    void deposit(double amount);
    void withdraw(double amount);
    double getBalance();
}

public interface SavingsAccount extends DepositAccount {
    void calculateInterest();
}

public interface TransferableAccount extends DepositAccount {
    void transfer(BankAccount destination, double amount);
}

What I learned: Split large interfaces into smaller, focused ones. Classes only implement what they need.

Dependency Inversion Principle (DIP)

Depend on abstractions, not concretions.

I was writing a notification service and initially hardcoded the email implementation:

public class NotificationService {
    private EmailService emailService; // Depends on concrete class!

    public void sendNotification(String message) {
        emailService.sendEmail(message);
    }
}

This made it hard to add SMS notifications later. I learned to depend on an interface instead:

public interface Notification {
    void send(String message);
}

public class EmailNotification implements Notification {
    public void send(String message) {
        // Email implementation
    }
}

public class SMSNotification implements Notification {
    public void send(String message) {
        // SMS implementation
    }
}

public class NotificationService {
    private Notification notification; // Depends on abstraction!

    public NotificationService(Notification notification) {
        this.notification = notification;
    }

    public void sendNotification(String message) {
        notification.send(message); // Works with any Notification!
    }
}

Now I can easily swap implementations or add new ones without changing NotificationService.

What I learned: Depend on interfaces/abstractions, not concrete classes. This makes code more flexible and testable.

Putting It All Together

When I started applying SOLID principles, my code became:

  • Easier to test: Each class has one responsibility
  • Easier to extend: New features don't require changing existing code
  • Easier to understand: Clear responsibilities and relationships
  • More maintainable: Changes are isolated to specific classes

Key Takeaways

  • SRP: One class, one responsibility
  • OCP: Extend through inheritance/polymorphism, don't modify existing code
  • LSP: Subtypes must be truly substitutable
  • ISP: Keep interfaces focused and small
  • DIP: Depend on abstractions, not concrete implementations

Learning SOLID principles has made me a better programmer. They're not just theory. They're practical guidelines that help write code that stands the test of time. I hope this helps you understand and apply SOLID in your own projects!

Share:
Loading reactions...

Loading comments...