As developers, writing clean, maintainable, and scalable code is always a top priority. One of the best ways to achieve this is by following the SOLID principles. SOLID is an acronym for five design principles that help us write better object-oriented code. Let's break them down one by one with simple examples in C#.
1. S – Single Responsibility Principle (SRP)
The Single Responsibility Principle (SRP) states that a class should have only one reason to change. In simple words, each class should focus on one specific job and do it well. For example, if a class handles both database operations and report generation, any change in either functionality will force you to modify the same class. This makes the code harder to maintain and more prone to errors. By splitting responsibilities into separate classes, your code becomes cleaner, easier to test, and more flexible for future changes.
Example
// Poor Practice
class Employee
{
public void CalculateSalary() { /* ... */ }
public void SaveToDatabase() { /* ... */ }
}
// Good Example
class Employee
{
public void CalculateSalary()
{ /* ... */ }
}
class EmployeeRepository
{
public void SaveToDatabase(Employee employee)
{ /* ... */ }
}
The poor practice example mixes salary calculation and database saving in one class, making it harder to maintain.
The good practice example separates responsibilities: Employee handles salary, EmployeeRepository handles database operations.
Each class now has one clear reason to change, making the code cleaner, safer, and easier to maintain.
2. O – Open/Closed Principle (OCP)
The Open/Closed Principle means your code should be open for extension but closed for modification. In other words, you should be able to add new features without changing the existing code that already works. Keeping existing code "closed" prevents breaking anything, while being "open" allows your software to grow and adapt safely. Using interfaces, inheritance, or polymorphism is a practical way to achieve this in C#.
// Poor Practice
class Discount
{
public double Calculate(double price, string type)
{
if(type == "Gold") return price * 0.9;
if(type == "Silver") return price * 0.95;
return price;
}
}
// Good Practice
interface IDiscount
{
double Apply(double price);
}
class GoldDiscount : IDiscount
{
public double Apply(double price) => price * 0.9;
}
class SilverDiscount : IDiscount
{
public double Apply(double price) => price * 0.95;
}
As poor practiceDiscount class handles all discount types in one method using if conditions.
Every time a new discount type is added, you must modify the existing Calculate method, which risks breaking existing discounts.
In the good Practice, each discount type has its own class implementing the IDiscount interface. This allows adding new discount types by creating new classes without changing existing code.
This approach follows the Open/Closed Principle, making the code easier to maintain and extend safely.
3. L – Liskov Substitution Principle (LSP)
The Liskov Substitution Principle (LSP) says that derived classes should be able to replace their base classes without breaking the program. In other words, if your code works with a base class, it should also work correctly with any subclass. This ensures that adding new types through inheritance doesn't cause unexpected errors, keeping your software reliable, predictable, and easy to extend.
Example
// Poor Practice
class Bird
{
public virtual void Fly()
{
/* ... */
}
}
class Ostrich : Bird
{
public override void Fly()
{
throw new Exception("Ostriches can't fly!");
}
}
// Good Practice : Interface Declearation
interface IFlyable
{
void Fly();
}
class Sparrow : IFlyable
{
public void Fly()
{
/* Flying logic */
}
}
class Ostrich
{
// No Fly method needed
}
IFlyable is an interface that represents the ability to fly. Only birds that can actually fly (like Sparrow) implement this interface.
Birds that cannot fly (like Ostrich) do not implement IFlyable, because it wouldn't make sense. This ensures that any code using IFlyable only works with flying birds, avoiding errors.
Common bird behaviors (like walking or eating) are still shared through the base Bird class, so all birds can be handled generally where needed.
4. I – Interface Segregation Principle (ISP)
The Interface Segregation Principle (ISP) means that clients (classes) should only be required to use the methods they actually need. In other words, a class shouldn't be forced to implement methods it will never use. By creating smaller, more focused interfaces, you keep your code clean, easier to maintain, and less confusing.
Example
// Poor Practice
interface IMachine
{
void Print();
void Scan();
void Fax();
}
class OldPrinter : IMachine
{
public void Print()
{
/*...*/
}
public void Scan()
{
throw new NotImplementedException();
}
public void Fax()
{
throw new NotImplementedException();
}
}
// Good Practice : using Interface
interface IPrinter
{
void Print();
}
interface IScanner
{
void Scan();
}
class ModernPrinter : IPrinter, IScanner
{
public void Print()
{
/ * Logic * /
}
public void Scan()
{
/ * Logic * /
}
}
Each machine (or class) only implements the features it actually supports.
A scanner-printer implements both scanning and printing, while a basic printer only implements printing.
This avoids forcing classes to include methods they don't need, keeping the code clean and easy to maintain.
💡 Note: In C#, a class cannot inherit from multiple classes, but it can implement multiple interfaces. This allows each class to pick only the features it needs, which is exactly what the Interface Segregation Principle promotes.
5. D – Dependency Inversion Principle (DIP)
The Dependency Inversion Principle (DIP) means that high-level code should not directly depend on low-level details. Instead, both should rely on abstractions, like interfaces. This way, your high-level modules (like business logic) don't break if you change low-level modules (like databases or services). By depending on abstractions, your code becomes more flexible, easier to maintain, and simpler to test.
Example
// Poor Practice
class MySQLDatabase
{
public void Connect()
{
/* DB Connection Logic */
}
}
class UserService
{
private MySQLDatabase _db = new MySQLDatabase();
}
// Good Practice
interface IDatabase
{
void Connect();
}
class MySQLDatabase : IDatabase
{
public void Connect()
{
/* DB Connection Logic */
}
}
class UserService
{
private readonly IDatabase _db;
public UserService(IDatabase db)
{
_db = db;
}
}
Poor Practice: UserService directly depends on MySQLDatabase. Switching to another database requires changing UserService.
Good Practice: UserService depends on the interface IDatabase. Any class implementing IDatabase can be used, making the system flexible.
Conclusion
Applying the SOLID principles makes writing C# code easier, cleaner, and more maintainable. By keeping each class focused on one task, relying on abstractions instead of concrete implementations, and designing simple, clear interfaces, your code becomes flexible, safe to change, and less error-prone. Using these principles regularly will not only improve your code but also make you a stronger, more confident C# developer.
Coming Up Next: Ready to take your C# skills further? In the next article, we'll explore Dependency Injection (DI) with clear examples, showing how to make your code cleaner, more flexible, and easier to maintain. Don't miss it!