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.

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
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
andRectangle
both adhere to theShape
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 bothscan()
andfax()
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 onEmailSender
.- 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:
- 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. - 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. - 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. - 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. - 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
- 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. - 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. - 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.” - 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
- 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. - 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. - 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 Group | Main Objective | When to Use | Advantages | Disadvantages / Risks of Overuse | How to Combine with SOLID |
---|---|---|---|---|---|
S (Single Responsibility) – Each class has one reason to change | Reduces complexity, improves maintainability | When a class contains multiple unrelated logic | Code is easy to understand and test | Splitting too small can cause confusion | Combine 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 code | When you need to add functionality without touching existing code | Reduces bugs when extending | Overuse can lead to many complex classes/interfaces | Combine with Design Patterns (Strategy, Decorator) to implement |
L (Liskov Substitution) – Can be replaced by a subclass | When using inheritance | Ensures consistency | Reduces runtime bugs | Overusing inheritance makes maintenance difficult | Combine with YAGNI to avoid unnecessary inheritance |
I (Interface Segregation) – Small, focused interfaces | When an interface becomes “fat” | Clients depend only on what they use | Reduces coupling | Too many interfaces cause fragmentation | Combine with KISS to keep interfaces simple |
D (Dependency Inversion) – Depend on abstractions | When a high-level module depends on a low-level module | Reduces direct dependencies, making testing and mocking easier | Flexible code, easy to replace | Too many abstractions cause complexity | Combine with GRASP – Low Coupling to reduce overall dependencies |
KISS – Keep It Simple, Stupid | Keep everything as simple as possible | Every stage | Code is easy to understand | Too simple may lack extensibility | Combine with S and I to avoid over-engineering |
DRY – Don’t Repeat Yourself | Avoid repeating logic | When there is duplicate code | Reduces errors when making changes | Overdoing DRY can force inappropriate code sharing | Combine with S to split classes/modules appropriately |
YAGNI – You Aren’t Gonna Need It | Don’t code features before they are needed | Feature development phase | Saves time | May need refactoring if really necessary | Combine with O to extend later without modifying existing code |
GRASP – High Cohesion | Group related functions together | When assigning responsibilities | Code is focused and easy to understand | Overuse → 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
- Start Small with KISS + YAGNI → avoid over-engineering.
- Gradually Develop with SOLID as the system grows larger.
- Use DRY to optimize duplicate code, but ensure the S principle is not violated.
- Use GRASP to help decide who does what in the code.
- 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:
- Clean Architecture – Robert C. Martin
- Agile Software Development – Robert C. Martin
- SOLID Principles with Examples – dev.to, freeCodeCamp
Official documentation from Microsoft, JetBrains, ThoughtWorks