Posted in

What is SOLID? Principles, how it works, and practical applications

SOLID: Principles of Sustainable Object-Oriented (OOP) Software Design

In the journey of software development, everyone hopes their product not only works well in the present but is also easy to upgrade and maintain in the future. However, without proper design orientation from the beginning, the system can easily become complex, overlapping, and difficult to manage.

To avoid that, developers often apply proven design principles. Among them, SOLID stands out as an essential foundation, helping to build software with a clear structure, easy scalability, and long-term stability.

1. What is SOLID?

SOLID is an acronym for five software design principles:

  • S – Single Responsibility Principle (Principle of Single Responsibility)
  • O – Open/Closed Principle (Open/Closed Principle)
  • L – Liskov Substitution Principle (Liskov Substitution Principle)
  • I – Interface Segregation Principle (Interface Segregation Principle)
  • D – Dependency Inversion Principle (Dependency Inversion Principle)

Introduced by Robert C. Martin (Uncle Bob) and popular in the Agile community, this set of principles was created to help software be maintainable, scalable, and testable, while reducing tight coupling between components.
Whether you are programming in Java, C#, Python, or any other OOP language, SOLID remains a crucial foundation for building systems with a clear structure that can “stay healthy” for the long term.

2. How SOLID Principles Work

Before diving into each principle, remember that SOLID is not a set of isolated rules, but a set of mutually supporting principles.
Each principle addresses a specific aspect of software design, but when combined, they form a solid foundation that makes the code both readable and easy to extend and maintain.

Now, let’s explore these five principles one by one – starting with S and ending with D – to see how they work and the practical benefits they bring.

5 SOLID Design Principles

2.1 Single Responsibility Principle (SRP) – Principle of Single Responsibility

A class should have only one reason to change.

Simply put: Each class should handle only one responsibility, and any changes to that class should come from only.

Example of SRP Violation:

class Invoice:
    def __init__(self, customer: str, amount: float):
        self.customer = customer
        self.amount = amount

    # Trách nhiệm: quản lý dữ liệu hóa đơn
    def get_amount(self) -> float:
        return self.amount

    # Trách nhiệm khác: tính thuế
    def calculate_tax(self) -> float:
        return self.amount * 0.1  # 10% VAT

    # Trách nhiệm khác nữa: in hóa đơn
    def print_invoice(self) -> None:
        print(f"Customer: {self.customer}")
        print(f"Amount: {self.amount}")
        print(f"Tax: {self.calculate_tax()}")


# Ví dụ sử dụng
invoice = Invoice("Alice", 1000.0)
invoice.print_invoice()

Problem:

  • The Invoice class manages data, calculates taxes, and prints invoices all at once.
  • If we change the way taxes are calculated or invoices are printed, we have to modify the Invoice class directly, which violates SRP.

Applying SRP

// Quản lý dữ liệu hóa đơn
public class Invoice {
    private String customer;
    private double amount;

    public Invoice(String customer, double amount) {
        this.customer = customer;
        this.amount = amount;
    }

    public String getCustomer() {
        return customer;
    }

    public double getAmount() {
        return amount;
    }
}

// Tính toán thuế
public class TaxCalculator {
    public double calculateTax(Invoice invoice) {
        return invoice.getAmount() * 0.1;
    }
}

// In hóa đơn
public class InvoicePrinter {
    public void print(Invoice invoice, double tax) {
        System.out.println("Customer: " + invoice.getCustomer());
        System.out.println("Amount: " + invoice.getAmount());
        System.out.println("Tax: " + tax);
    }
}

Advantages:

  • The Invoice class only manages data.
  • The TaxCalculator class only handles tax calculation.
  • The InvoicePrinter class only handles printing.
  • Any changes in logic only need to be made in the specific class, without affecting other parts.

2.2 Open/Closed Principle (OCP) – Open/Closed Principle

A module should be open for extension but closed for modification.

This means: When you need to change functionality, you should extend the class through inheritance or composition, without modifying existing code, to avoid affecting other parts of the system.

Example of OCP Violation:

Suppose we have a payment system that initially only supports credit cards. Later, when we want to add PayPal payment, we modify the existing code directly → violating OCP.

public class PaymentService {
    public void processPayment(String type) {
        if (type.equals("credit")) {
            System.out.println("Processing credit card payment...");
        } else if (type.equals("paypal")) {
            System.out.println("Processing PayPal payment...");
        }
        // Nếu thêm phương thức mới → lại phải sửa ở đây
    }
}

Problem:

  • Each time a new payment method is added, you have to open the old file and modify the logic.
  • There is a risk of breaking existing functionality.

Applying OCP

We separate the payment methods into an interface and individual implementing classes.
When adding a new method, we only need to create a new class without touching the existing code.

// Interface cho các phương thức thanh toán
public interface PaymentMethod {
    void pay();
}

// Triển khai thanh toán qua thẻ tín dụng
public class CreditCardPayment implements PaymentMethod {
    @Override
    public void pay() {
        System.out.println("Processing credit card payment...");
    }
}

// Triển khai thanh toán qua PayPal
public class PayPalPayment implements PaymentMethod {
    @Override
    public void pay() {
        System.out.println("Processing PayPal payment...");
    }
}

// Service xử lý thanh toán
public class PaymentService {
    public void processPayment(PaymentMethod method) {
        method.pay();
    }
}
public class Main {
    public static void main(String[] args) {
        PaymentService service = new PaymentService();

        service.processPayment(new CreditCardPayment());
        service.processPayment(new PayPalPayment());

        // Nếu muốn thêm phương thức mới → chỉ cần tạo class mới implement PaymentMethod
    }
}

Advantages:

  • The old code does not need to be modified when adding new features → closed for modification.
  • It can be extended by adding new classes → open for extension.
  • Reduces the risk of breaking existing functionality.

2.3 Liskov Substitution Principle (LSP) – Liskov Substitution Principle

A subclass should be able to replace its parent class without breaking the program’s logic.

In other words, any subclass must fully preserve the behavior of the parent class and must not alter the user’s expectations.

Example of LSP Violation:

Suppose we have a Rectangle class and want to create a Square class that inherits from Rectangle.
It sounds reasonable, but in practice, setting the width/height of Square changes the behavior compared to Rectangle, leading to bugs.

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 int getArea() {
        return width * height;
    }
}

class Square extends Rectangle {
    @Override
    public void setWidth(int width) {
        this.width = width;
        this.height = width; // Ép chiều cao bằng chiều rộng
    }

    @Override
    public void setHeight(int height) {
        this.height = height;
        this.width = height; // Ép chiều rộng bằng chiều cao
    }
}

Proper Application of LSP

Solution: Do not force inheritance if the “is-a” relationship is not truly appropriate.
Here, Square and Rectangle should both implement a common interface like Shape, instead of forcing Square to inherit from Rectangle.

interface Shape {
    int getArea();
}

class Rectangle implements Shape {
    private int width;
    private int height;

    public Rectangle(int width, int height) {
        this.width = width;
        this.height = height;
    }

    @Override
    public int getArea() {
        return width * height;
    }
}

class Square implements Shape {
    private int side;

    public Square(int side) {
        this.side = side;
    }

    @Override
    public int getArea() {
        return side * side;
    }
}
public class Main {
    public static void main(String[] args) {
        Shape rect = new Rectangle(5, 10);
        Shape square = new Square(5);

        System.out.println(rect.getArea());   // 50
        System.out.println(square.getArea()); // 25
    }
}

Advantages:

  • Square and Rectangle both adhere to the Shape contract.
  • The expected behavior of the parent class is not altered.
  • The code is easy to understand and free of hidden bugs.

2.4 Interface Segregation Principle (ISP) – Interface Segregation Principle

Classes should not be forced to depend on methods they do not use.

Instead of creating a large interface, break it down into multiple smaller, specialized interfaces. This helps reduce unnecessary dependencies and increases flexibility.

Example of ISP Violation:

Suppose we have a printer management system and define an overly large interface, forcing all printers to implement every method, including those they do not need.

// Interface quá khổng lồ
interface Machine {
    void print();
    void scan();
    void fax();
}

// Máy in cơ bản
class BasicPrinter implements Machine {
    @Override
    public void print() {
        System.out.println("Printing document...");
    }

    @Override
    public void scan() {
        // Không hỗ trợ scan → phải để trống hoặc throw exception
        throw new UnsupportedOperationException("Scan not supported");
    }

    @Override
    public void fax() {
        // Không hỗ trợ fax
        throw new UnsupportedOperationException("Fax not supported");
    }
}

Problem:

  • BasicPrinter has to implement both scan() and fax() even though it does not use them.
  • This creates unnecessary dependencies and messy code, making maintenance prone to errors.

Applying ISP

Instead of a large interface, split it into multiple specialized interfaces.

interface Printer {
    void print();
}

interface Scanner {
    void scan();
}

interface Fax {
    void fax();
}

// Máy in cơ bản chỉ cần in
class BasicPrinter implements Printer {
    @Override
    public void print() {
        System.out.println("Printing document...");
    }
}

// Máy in đa năng
class MultiFunctionPrinter implements Printer, Scanner, Fax {
    @Override
    public void print() {
        System.out.println("Printing document...");
    }

    @Override
    public void scan() {
        System.out.println("Scanning document...");
    }

    @Override
    public void fax() {
        System.out.println("Sending fax...");
    }
}

Advantages:

  • Each class only implements the interfaces it needs.
  • Reduces unnecessary dependencies.
  • The code is concise and easy to extend.

2.5 Dependency Inversion Principle (DIP) – Dependency Inversion Principle

High-level modules should not depend on low-level modules; both should depend on abstractions.

Meaning: Do not let high-level classes “know too much” about low-level classes. Communicate through interfaces or abstractions, making the system more flexible for changes or testing.

Ví dụ vi phạm DIP:

Suppose we have a notification application.
The NotificationService class (high-level) directly calls EmailSender (low-level).

// Module cấp thấp
class EmailSender {
    public void sendEmail(String message) {
        System.out.println("Sending Email: " + message);
    }
}

// Module cấp cao
class NotificationService {
    private EmailSender emailSender = new EmailSender();

    public void send(String message) {
        emailSender.sendEmail(message);
    }
}

Problem:

  • NotificationService directly depends on EmailSender.
  • If we want to send via SMS or Push Notification, we have to modify the NotificationService code → violating the principle.

Applying DIP

We create an abstraction (interface) so that both high-level and low-level modules depend on it.
When we want to change the way notifications are sent, we only need to create a new class that implements this interface.

// Abstraction
interface MessageSender {
    void sendMessage(String message);
}

// Module cấp thấp: Email
class EmailSender implements MessageSender {
    @Override
    public void sendMessage(String message) {
        System.out.println("Sending Email: " + message);
    }
}

// Module cấp thấp: SMS
class SmsSender implements MessageSender {
    @Override
    public void sendMessage(String message) {
        System.out.println("Sending SMS: " + message);
    }
}

// Module cấp cao
class NotificationService {
    private MessageSender sender;

    // Tiêm dependency qua constructor
    public NotificationService(MessageSender sender) {
        this.sender = sender;
    }

    public void send(String message) {
        sender.sendMessage(message);
    }
}
public class Main {
    public static void main(String[] args) {
        NotificationService emailService = new NotificationService(new EmailSender());
        emailService.send("Hello via Email!");

        NotificationService smsService = new NotificationService(new SmsSender());
        smsService.send("Hello via SMS!");
    }
}

Advantages:

  • NotificationService does not care about the sending method.
  • To add a new sending channel, simply create a new class that implements MessageSender.
  • Easy to test (you can mock MessageSender during unit testing).

3. Why Apply SOLID?

Applying SOLID is not just because Uncle Bob said so, but because it helps you thrive in the ever-changing world of code:

  1. Reduced Maintenance Cost
    When code is well-organized, adding features or fixing bugs only requires changes in the right place, without “breaking the flow” elsewhere. This is especially important for large projects, where a single wrong line of code can cause the whole team to work overtime.
  2. Increased Reusability
    Small, clear, independent modules are like “Lego pieces” – they can be assembled in different places. No need to rewrite from scratch, saving time and effort.
  3. Improved Testability
    When components are separated, writing unit tests becomes as easy as “taking an ID photo” – quick, simple, and without interference from unrelated parts.
  4. Better Team Communication
    Clear, principle-driven code is immediately understandable → the team doesn’t have to “decode secret messages” when reading each other’s code. This helps onboard new members faster and reduces misunderstandings when collaborating.
  5. Ready for Change
    Change requests are inevitable (customers can be unpredictable). SOLID helps the system stay flexible and adaptable, preventing each change from turning into a full-scale “overhaul.”

4. When to Apply and When Not to Apply SOLID?

4.1 When to Apply SOLID

  1. Medium or large projects, especially with large teams

    When multiple developers work on the same codebase, there’s a high risk of overlapping code, conflicts, or even “breaking” each other’s work. SOLID acts like traffic rules – everyone stays in their own lane, reducing the chances of collisions along the way.
  2. Systems with a long lifecycle, requiring frequent maintenance and expansion

    A long-living system will go through many upgrades and changes. Without applying SOLID, each modification is like removing a block from a Jenga tower – it can easily cause the entire system to collapse.
  3. Microservice architecture or clearly modularized systems

    In environments where components need to communicate flexibly yet remain independent, SOLID helps modules “talk” to each other through interfaces, avoiding tight coupling. This is how you keep a microservice from turning into a “micro-mess.”
  4. Projects suffering from spaghetti code

    If your code is tangled like a plate of spaghetti, and adding new features feels like a nightmare, then SOLID is the “surgical tool” to untangle, break down, and clean up the architecture.

4.2 When You Don’t Need to Fully Apply SOLID

  1. Small projects or MVPs (Minimum Viable Products)

    At this stage, the goal is to launch the product as quickly as possible to test the idea with users or investors. Over-optimizing the architecture too early can slow down progress and waste effort if the product gets pivoted or dropped.
  2. Limited resources

    With a small team, tight deadlines, and a limited budget, the top priority is to get a working product. A clean architecture can be saved for later stages, once the project has proven its value.
  3. Experimental phase with unstable architecture

    If you’re still exploring different approaches and the architecture is likely to change entirely, strictly applying SOLID from the start may just lead to wasted time rewriting code. Keep things as simple and flexible as possible.

5. Comparison Between SOLID and Other Principles

I will divide the comparison into 4 main groups:

  • SOLID (object-oriented design principles)
  • KISS / DRY / YAGNI (general coding principles)
  • GRASP (responsibility assignment principles)
  • Design Patterns (design templates)
Principles GroupMain ObjectiveWhen to UseAdvantagesDisadvantages / Risks of OveruseHow to Combine with SOLID
S (Single Responsibility) – Each class has one reason to change
Reduces complexity, improves maintainabilityWhen a class contains multiple unrelated logicCode is easy to understand and testSplitting too small can cause confusionCombine with DRY to avoid code duplication, and GRASP – High Cohesion to group related functions
O (Open/Closed) – Extend without modifying
Allows adding features without breaking existing codeWhen you need to add functionality without touching existing codeReduces bugs when extendingOveruse can lead to many complex classes/interfacesCombine with Design Patterns (Strategy, Decorator) to implement
L (Liskov Substitution) – Can be replaced by a subclass
When using inheritanceEnsures consistencyReduces runtime bugsOverusing inheritance makes maintenance difficultCombine with YAGNI to avoid unnecessary inheritance
I (Interface Segregation) – Small, focused interfaces
When an interface becomes “fat”Clients depend only on what they useReduces couplingToo many interfaces cause fragmentationCombine with KISS to keep interfaces simple
D (Dependency Inversion) – Depend on abstractions
When a high-level module depends on a low-level moduleReduces direct dependencies, making testing and mocking easierFlexible code, easy to replaceToo many abstractions cause complexityCombine with GRASP – Low Coupling to reduce overall dependencies
KISS – Keep It Simple, Stupid
Keep everything as simple as possibleEvery stageCode is easy to understandToo simple may lack extensibilityCombine with S and I to avoid over-engineering
DRY – Don’t Repeat Yourself
Avoid repeating logicWhen there is duplicate codeReduces errors when making changesOverdoing DRY can force inappropriate code sharingCombine with S to split classes/modules appropriately
YAGNI – You Aren’t Gonna Need It
Don’t code features before they are neededFeature development phaseSaves timeMay need refactoring if really necessaryCombine with O to extend later without modifying existing code
GRASP – High Cohesion
Group related functions togetherWhen assigning responsibilitiesCode is focused and easy to understandOveruse → class becomes too large

Supplement for the S principle


GRASP – Low Coupling

Reduce the dependencies between modules

When designing a module

Easy to maintain and replace

Requires more design effort

Goes hand in hand with the D in SOLID


Design Patterns

Reusable solutions to common problems

When an appropriate pattern is identified

Saves time and ensures clean code

Overuse leads to confusion

Many patterns are created to implement O, D, and L


When to use which one?

  • Starting a Project: KISS, YAGNI, DRY → keep everything simple, avoid doing unnecessary work.
  • OOP System Design: Apply SOLID principles to structure classes/modules.
  • Responsibility Assignment: Use GRASP alongside SOLID.
  • Solving Specific Problems: Choose the appropriate Design Pattern.

Reasonable combination approach

  1. Start Small with KISS + YAGNI → avoid over-engineering.
  2. Gradually Develop with SOLID as the system grows larger.
  3. Use DRY to optimize duplicate code, but ensure the S principle is not violated.
  4. Use GRASP to help decide who does what in the code.
  5. Apply Design Patterns when implementing the O or D principles.

6. Conclusion

SOLID is not just a set of dry principles. It is the foundation for developing high-quality software that is scalable, less error-prone, and easy to maintain. Whether you write Java, Python, C#, or any OOP language – SOLID remains the right guide to build software professionally.

Learn to apply SOLID step by step. You don’t need to be perfect from the start – but you must clearly understand the essence and apply it flexibly. As your coding skills develop, adhering to SOLID will become a natural instinct.


7. References:


Leave a Reply

Your email address will not be published. Required fields are marked *