SOLID Principles Refresher

SOLID is a set of five simple rules that help you write cleaner, safer, and easier-to-maintain object-oriented code. Each principle helps you avoid common headaches as your codebase grows.

These principles are good engineering habits, born from real problems developers kept running into. I already knew the pain from experience, and when I came back to refresh these ideas, they clicked stronger than before. Therefore, I want to offer a summary with concise examples and straight-to-the-point explanations.

In this article, you will find a definition of each principle with a set of quick labs to showcase how to apply them. Finally, there’s a cheatsheet you can reference to reflect if you need to apply any of the principles in your day-to-day work.

I hope this will help you develop the intuition you need to apply them if you are a beginner — or serve as a refresher for more experienced developers.

Remember, understanding doesn't happen by memorizing principles — it happens when you connect them to real problems you're ready to solve. Let’s get started:

  • SSingle Responsibility Principle (SRP): "A class should have only one reason to change." Each class should do only one thing. Don't mix responsibilities. For example, A class that saves data shouldn't also send emails.

  • OOpen/Closed Principle (OCP)
    "Software entities should be open for extension but closed for modification." You should be able to add new behavior without changing existing code. For example, instead of editing existing classes, you extend them or create new ones that plug into your system.

  • LLiskov Substitution Principle (LSP)
    "Objects of a superclass should be replaceable with objects of its subclasses without breaking the program." If you have a class Animal, any Dog or Cat should behave like an Animal without problems. For example, subclasses should not throw errors when using methods they inherit from the parent class.

  • IInterface Segregation Principle (ISP)
    "Clients should not be forced to depend on interfaces they do not use." Prefer many small, specific interfaces over one big, general interface. For example, instead of one IMultifunctionDevice with print(), scan(), and fax(), create a separate IPrinter, IScanner, and IFax interfaces.

  • DDependency Inversion Principle (DIP): "Depend on abstractions, not on concrete implementations."
    Code should depend on interfaces or abstract classes, not on specific classes. For example, Instead of a OrderService depending directly on an SQLOrderRepository, a concrete implementation, it should depend on an OrderRepository interface.

SOLID principles often overlap, you may need to apply two or more together to achieve better maintainability and scalability.

Now that we cover the definition, let’s review a set of quick labs to refresh the principles and get you ready to recognize and define them during a code review or an interview.

QuickLab 1: Refactoring Toward SRP + OCP

❌ Before: Hard-to-Maintain Code

public class DiscountCalculator {
    public double calculateDiscount(String customerType, double amount) {
        if (customerType.equalsIgnoreCase("REGULAR")) {
            return amount * 0.1;
        } else if (customerType.equalsIgnoreCase("VIP")) {
            return amount * 0.2;
        } else {
            return amount * 0.0;
        }
    }
}

🔴 What’s wrong with the above code?

At first, it looks simple enough:
just an if-else to calculate a discount based on customer type.

But the real problem is that every time you need to support a new discount, You have to go back and modify this method.

  • Even if you intend to only add something, you might accidentally break existing logic.

  • What starts as a few else ifs can quickly turn into messy, fragile code — especially when real business rules get more complex.

  • In practice, discount calculations often involve multiple conditions, exceptions, or special cases, and maintaining a big chain of if-else becomes a nightmare.

The risk isn't visible when the code is small. It becomes painful when it grows. If you play the devil’s advocate, like I often do, you may think but what’s the problem with breaking things, shouldn’t your unit test just spot it and you will fix it the right way? Unit tests help catch mistakes, but good design prevents many mistakes from happening at all. When code grows, every edit you avoid is one less risk — and one less reason to break the system. Mic drop!

Now let’s refactor this code to make more compatible wit the SRP and the OCP:

✅ After: A Safer, Cleaner Approach

Step 1: Define Discount Strategies

interface DiscountStrategy {
    double calculate(double amount);
}

class RegularDiscount implements DiscountStrategy {
    public double calculate(double amount) { return amount * 0.1; }
}

class VIPDiscount implements DiscountStrategy {
    public double calculate(double amount) { return amount * 0.2; }
}

Step 2: Select Strategy Dynamically

import java.util.Map;

class DiscountFactory {
    private static final Map<String, DiscountStrategy> strategies = Map.of(
        "REGULAR", new RegularDiscount(),
        "VIP", new VIPDiscount()
    );

    static DiscountStrategy get(String type) {
        return strategies.getOrDefault(type.toUpperCase(), a -> 0.0);
    }
}

Step 3: Use It

public class Main {
    public static void main(String[] args) {
        DiscountStrategy strategy = DiscountFactory.get("VIP");
        System.out.println(strategy.calculate(100.0)); // prints 20.0
    }
}

Why is this better?

  • Adding new discount types means creating a new small class, not touching the old ones.

  • No risk of breaking existing discount logic when expanding.

  • Keeps changes isolated and safe, even as the system grows.

  • Short code today, complicated business rules tomorrow — this structure protects you early.

QuickLab 2: Refactoring Toward LSP + ISP

❌ Before: Subtypes That Don’t Behave Properly

class Bird {
    void fly() {
        System.out.println("Flying high!");
    }
}

class Penguin extends Bird {
    @Override
    void fly() {
        throw new UnsupportedOperationException("Penguins can't fly!");
    }
}

What's wrong here?

The code assumes all Birds can fly — but Penguin can't. Instead of flying, it crashes at runtime.
Now you can't safely treat all Birds the same way, and every place that uses a Bird has to worry about special cases. What looked simple becomes fragile and full of workarounds.

✅ After: A Safer, Cleaner Design

Step 1: Redefine the Abstraction

interface Bird {
    void eat();
}

interface FlyingBird extends Bird {
    void fly();
}

Step 2: Implement Only What Makes Sense

class Sparrow implements FlyingBird {
    public void eat() {
        System.out.println("Sparrow eating.");
    }

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

class Penguin implements Bird {
    public void eat() {
        System.out.println("Penguin eating.");
    }
}

Step 3: Use Safely

public class Main {
    public static void main(String[] args) {
        Bird penguin = new Penguin();
        penguin.eat();  // OK

        FlyingBird sparrow = new Sparrow();
        sparrow.eat();
        sparrow.fly();  // OK

        // No surprises, no runtime crashes!
    }
}

QuickLab 3: Refactoring Toward DIP

❌ Before: High Coupling, Hard to Change

class MySQLRepository {
    void save(String data) {
        System.out.println("Saving '" + data + "' to MySQL");
    }
}

class ReportService {
    private MySQLRepository repository = new MySQLRepository();

    void generateReport(String data) {
        repository.save(data);
    }
}

What's wrong here?

ReportService is tightly locked to MySQLRepository.
If tomorrow you want to save reports somewhere else (e.g., InMemoryRepository, PostgreSQLRepository, FileRepository),
you have to edit ReportService — and risk breaking it.

You're gluing your business logic to one specific storage choice, and it kills flexibility.

✅ After: Depending on Abstractions

Step 1: Introduce a Repository Interface

interface Repository {
    void save(String data);
}

Step 2: Implement Different Repositories

class MySQLRepository implements Repository {
    public void save(String data) {
        System.out.println("Saving '" + data + "' to MySQL");
    }
}

class InMemoryRepository implements Repository {
    public void save(String data) {
        System.out.println("Saving '" + data + "' to In-Memory storage");
    }
}

Step 3: Make Service Depend on Abstraction

class ReportService {
    private final Repository repository;

    ReportService(Repository repository) {
        this.repository = repository;
    }

    void generateReport(String data) {
        repository.save(data);
    }
}

Step 4: Use It Flexibly

public class Main {
    public static void main(String[] args) {
        Repository repository = new MySQLRepository();
        ReportService service = new ReportService(repository);

        service.generateReport("Monthly Report");
    }
}

🧠 Notes Worth Mentioning:

  • ReportService no longer knows or cares about how or where the data is saved.

  • You can easily swap MySQLRepository for InMemoryRepository, PostgresRepository, etc. without changing the service.

  • Flexibility is built in right from the start.

🎯 Quick Guide: How to Spot Problems and Fix Them (Prescriptive SOLID Cheatsheet)

Over time, as you refactor/write more and more code, you start to build intuition.
You get faster at spotting when something feels wrong — big classes, messy inheritance, hard-to-extend code — and you instinctively reach for the right design improvement.

But when you're starting (or even when you're experienced but moving fast), it helps to have a quick checklist.

Here's a small cheatsheet you can refer to anytime you need a quick reminder:

SituationProblemSOLID PrincipleQuick Action
Big class or methodToo many responsibilitiesSRP (Single Responsibility Principle)Break into smaller classes or methods, each doing one thing.
Need to change old code to add newCode not extendableOCP (Open/Closed Principle)Add behavior via new classes instead of editing existing ones.
Subclasses have methods that don't make senseBad inheritance structureLSP (Liskov Substitution Principle)Redesign abstractions so subclasses fit behavior naturally.
Interface has too many unrelated methodsInterface too fatISP (Interface Segregation Principle)Split interfaces by role — avoid forcing classes to implement unused methods.
Hardcoded dependenciesTight couplingDIP (Dependency Inversion Principle)Program to abstractions (interfaces) and inject dependencies from outside.

Conclusion

SOLID is a toolbox, not a checklist. Good design means knowing when to use these principles — and when to keep things simple. Flexibility is valuable when change is expected, but unnecessary abstractions only add complexity. The real skill is balancing simplicity with foresight.

Keep in mind that clean code matters, but so does collaboration. Every solution has trade-offs, and not every situation will let you apply the "perfect" design. Favor simplicity, respect team decisions, and focus on continuous improvement.

I hope this article refreshed the SOLID principles for you and encouraged a mindset of thoughtful, intentional design.

This article is 🚀 part of the "Clean Code" series! If you enjoyed it, stay tuned for more!

0
Subscribe to my newsletter

Read articles from Mirna De Jesus Cambero directly inside your inbox. Subscribe to the newsletter, and don't miss out.

Written by

Mirna De Jesus Cambero
Mirna De Jesus Cambero

I’m a backend software engineer with over a decade of experience primarily in Java. I started this blog to share what I’ve learned in a simplified, approachable way — and to add value for fellow developers. Though I’m an introvert, I’ve chosen to put myself out there to encourage more women to explore and thrive in tech. I believe that by sharing what we know, we learn twice as much — that’s precisely why I’m here.