Welcome to another deep dive into software engineering fundamentals! Today, we're exploring the SOLID principles - five design principles that help us write more maintainable, scalable, and robust code. Whether you're working with C#, Python, TypeScript or any other object-oriented programming language, these principles are universally applicable.
What are SOLID Principles?
SOLID is an acronym representing five object-oriented design principles introduced by Robert C. Martin (aka Uncle Bob). These principles help developers create code that is:
- Maintainable: Easy to modify and extend
- Testable: Simple to unit test
- Reusable: Can be used in different contexts
- Understandable: Clear and readable
Let's break down each principle with practical examples.
S - Single Responsibility Principle (SRP)
"A class should have only one reason to change."
Each class should have a single, well-defined responsibility. If a class handles multiple concerns, it becomes harder to maintain and test.
❌ Violation Example
C#
public class UserManager
{
public void CreateUser(string username, string email)
{
// Validate user data
if (string.IsNullOrWhitespace(username) || string.IsNullOrWhitespace(email))
throw new ArgumentException("Username and email are required");
// Save to database
// ... database code ...
// Send welcome email
// ... email code ...
}
}
Python
class UserManager:
def create_user(self, username: str, email: str):
# Validate user data
if not username or not email:
raise ValueError("Username and email are required")
# Save to database
# ... database code ...
# Send welcome email
# ... email code ...
TypeScript
class UserManager {
createUser(username: string, email: string): void {
// Validate user data
if (!username || !email) {
throw new Error("Username and email are required");
}
// Save to database
// ... database code ...
// Send welcome email
// ... email code ...
}
}
✅ Correct Implementation
C#
// Separate responsibilities into different classes
public class UserValidator
{
public void Validate(string username, string email)
{
if (string.IsNullOrWhitespace(username) || string.IsNullOrWhitespace(email))
throw new ArgumentException("Username and email are required");
}
}
public class UserRepository
{
public void Save(User user)
{ /* Database operations*/ }
}
public class EmailService
{
public void SendWelcomeEmail(string email)
{ /* Email sending logic */ }
}
public class UserManager(
UserValidator validator,
UserRepository repository,
EmailService emailService)
{
public void CreateUser(string username, string email)
{
validator.Validate(username, email);
var user = new User { Username = username, Email = email };
repository.Save(user);
emailService.SendWelcomeEmail(email);
}
}
Python
# Separate responsibilities into different classes
class UserValidator:
def validate(self, username: str, email: str) -> None:
if not username or not email:
raise ValueError("Username and email are required")
class UserRepository:
def save(self, user: dict) -> None:
# Database operations
pass
class EmailService:
def send_welcome_email(self, email: str) -> None:
# Email sending logic
pass
class UserManager:
def __init__(self, validator: UserValidator, repository: UserRepository, email_service: EmailService):
self._validator = validator
self._repository = repository
self._email_service = email_service
def create_user(self, username: str, email: str) -> None:
self._validator.validate(username, email)
user = {"username": username, "email": email}
self._repository.save(user)
self._email_service.send_welcome_email(email)
TypeScript
// Separate responsibilities into different classes
class UserValidator {
validate(username: string, email: string): void {
if (!username || !email) {
throw new Error("Username and email are required");
}
}
}
class UserRepository {
save(user: User): void {
// Database operations
}
}
class EmailService {
sendWelcomeEmail(email: string): void {
// Email sending logic
}
}
class UserManager {
constructor(
private validator: UserValidator,
private repository: UserRepository,
private emailService: EmailService
) {}
createUser(username: string, email: string): void {
this.validator.validate(username, email);
const user = { username, email };
this.repository.save(user);
this.emailService.sendWelcomeEmail(email);
}
}
O - Open/Closed Principle (OCP)
"Software entities should be open for extension, but closed for modification."
You should be able to add new functionality without modifying existing code. Use abstractions (interfaces, abstract classes) to achieve this.
❌ Violation Example
C#
public class DiscountCalculator
{
public decimal CalculateDiscount(string customerType, decimal amount)
{
return customerType switch
{
"Regular" => amount * 0.1m, // 10% discount
"Premium" => amount * 0.2m, // 20% discount
"VIP" => amount * 0.3m, // 30% discount
_ => 0 // Adding a new customer type requires modifying this method
};
}
}
Python
class DiscountCalculator:
def calculate_discount(self, customer_type: str, amount: float) -> float:
if customer_type == "Regular":
return amount * 0.1 # 10% discount
elif customer_type == "Premium":
return amount * 0.2 # 20% discount
elif customer_type == "VIP":
return amount * 0.3 # 30% discount
# Adding a new customer type requires modifying this method
return 0
TypeScript
class DiscountCalculator {
calculateDiscount(customerType: string, amount: number): number {
if (customerType === "Regular") {
return amount * 0.1; // 10% discount
} else if (customerType === "Premium") {
return amount * 0.2; // 20% discount
} else if (customerType === "VIP") {
return amount * 0.3; // 30% discount
}
// Adding a new customer type requires modifying this method
return 0;
}
}
✅ Correct Implementation
C#
public interface IDiscountStrategy
{
decimal CalculateDiscount(decimal amount);
}
public class RegularCustomerDiscount : IDiscountStrategy
{
public decimal CalculateDiscount(decimal amount) => amount * 0.1m;
}
public class PremiumCustomerDiscount : IDiscountStrategy
{
public decimal CalculateDiscount(decimal amount) => amount * 0.2m;
}
public class VipCustomerDiscount : IDiscountStrategy
{
public decimal CalculateDiscount(decimal amount) => amount * 0.3m;
}
public class DiscountCalculator(IDiscountStrategy strategy)
{
public decimal CalculateDiscount(decimal amount) => strategy.CalculateDiscount(amount);
}
// Now you can add new discount types without modifying existing code:
public class GoldCustomerDiscount : IDiscountStrategy
{
public decimal CalculateDiscount(decimal amount) => amount * 0.25m;
}
Python
from abc import ABC, abstractmethod
class DiscountStrategy(ABC):
@abstractmethod
def calculate_discount(self, amount: float) -> float:
pass
class RegularCustomerDiscount(DiscountStrategy):
def calculate_discount(self, amount: float) -> float:
return amount * 0.1
class PremiumCustomerDiscount(DiscountStrategy):
def calculate_discount(self, amount: float) -> float:
return amount * 0.2
class VipCustomerDiscount(DiscountStrategy):
def calculate_discount(self, amount: float) -> float:
return amount * 0.3
class DiscountCalculator:
def __init__(self, strategy: DiscountStrategy):
self._strategy = strategy
def calculate_discount(self, amount: float) -> float:
return self._strategy.calculate_discount(amount)
# Now you can add new discount types without modifying existing code:
class GoldCustomerDiscount(DiscountStrategy):
def calculate_discount(self, amount: float) -> float:
return amount * 0.25
TypeScript
interface IDiscountStrategy {
calculateDiscount(amount: number): number;
}
class RegularCustomerDiscount implements IDiscountStrategy {
calculateDiscount(amount: number): number {
return amount * 0.1;
}
}
class PremiumCustomerDiscount implements IDiscountStrategy {
calculateDiscount(amount: number): number {
return amount * 0.2;
}
}
class VipCustomerDiscount implements IDiscountStrategy {
calculateDiscount(amount: number): number {
return amount * 0.3;
}
}
class DiscountCalculator {
constructor(private strategy: IDiscountStrategy) {}
calculateDiscount(amount: number): number {
return this.strategy.calculateDiscount(amount);
}
}
// Now you can add new discount types without modifying existing code:
class GoldCustomerDiscount implements IDiscountStrategy {
calculateDiscount(amount: number): number {
return amount * 0.25;
}
}
L - Liskov Substitution Principle (LSP)
"Objects of a superclass should be replaceable with objects of its subclasses without breaking the application."
Derived classes should be substitutable for their base classes without altering the correctness of the program.
❌ Violation Example
C#
public class Rectangle
{
public virtual int Width { get; set; }
public virtual int Height { get; set; }
public int GetArea() => Width * Height;
}
public class Square : Rectangle
{
public override int Width
{
get => base.Width;
set
{
base.Width = value;
base.Height = value; // Violates LSP: Square changes behavior
}
}
public override int Height
{
get => base.Height;
set
{
base.Height = value;
base.Width = value; // Violates LSP
}
}
}
// This breaks the substitution principle:
Rectangle rect = new Square();
rect.Width = 5;
rect.Height = 4;
// User expects area = 20, but gets 16 because Square forces width = height
Python
class Rectangle:
def __init__(self, width: int, height: int):
self.width = width
self.height = height
def get_area(self) -> int:
return self.width * self.height
class Square(Rectangle):
def __init__(self, side: int):
super().__init__(side, side)
@property
def width(self):
return self._width
@width.setter
def width(self, value: int):
self._width = value
self._height = value # Violates LSP: Square changes behavior
@property
def height(self):
return self._height
@height.setter
def height(self, value: int):
self._height = value
self._width = value # Violates LSP
# This breaks the substitution principle:
rect: Rectangle = Square(5)
rect.width = 5
rect.height = 4
# User expects area = 20, but gets 16 because Square forces width = height
TypeScript
class Rectangle {
protected _width: number = 0;
protected _height: number = 0;
setWidth(width: number): void {
this._width = width;
}
setHeight(height: number): void {
this._height = height;
}
getArea(): number {
return this._width * this._height;
}
}
class Square extends Rectangle {
setWidth(width: number): void {
this._width = width;
this._height = width; // Violates LSP: Square changes behavior
}
setHeight(height: number): void {
this._height = height;
this._width = height; // Violates LSP
}
}
// This breaks the substitution principle:
const rect: Rectangle = new Square();
rect.setWidth(5);
rect.setHeight(4);
// User expects area = 20, but gets 16 because Square forces width = height
✅ Correct Implementation
C#
public abstract class Shape
{
public abstract int GetArea();
}
public class Rectangle : Shape
{
public int Width { get; set; }
public int Height { get; set; }
public override int GetArea() => Width * Height;
}
public class Square : Shape
{
public int Side { get; set; }
public override int GetArea() => Side * Side;
}
// Now both can be used interchangeably where Shape is expected:
public class AreaCalculator
{
public int CalculateTotalArea(List<Shape> shapes) => shapes.Sum(s => s.GetArea());
}
Python
from abc import ABC, abstractmethod
class Shape(ABC):
@abstractmethod
def get_area(self) -> int:
pass
class Rectangle(Shape):
def __init__(self, width: int, height: int):
self.width = width
self.height = height
def get_area(self) -> int:
return self.width * self.height
class Square(Shape):
def __init__(self, side: int):
self.side = side
def get_area(self) -> int:
return self.side * self.side
# Now both can be used interchangeably where Shape is expected:
class AreaCalculator:
def calculate_total_area(self, shapes: list[Shape]) -> int:
return sum(shape.get_area() for shape in shapes)
TypeScript
abstract class Shape {
abstract getArea(): number;
}
class Rectangle extends Shape {
constructor(private width: number, private height: number) {
super();
}
getArea(): number {
return this.width * this.height;
}
}
class Square extends Shape {
constructor(private side: number) {
super();
}
getArea(): number {
return this.side * this.side;
}
}
// Now both can be used interchangeably where Shape is expected:
class AreaCalculator {
calculateTotalArea(shapes: Shape[]): number {
return shapes.reduce((total, shape) => total + shape.getArea(), 0);
}
}
I - Interface Segregation Principle (ISP)
"Clients should not be forced to depend on interfaces they do not use."
Instead of one large interface, create smaller, more specific interfaces. This prevents classes from implementing methods they don't need.
❌ Violation Example
C#
public interface IBird
{
void Fly();
void Swim();
void Walk();
}
public class Eagle : IBird
{
public void Fly() { /* Flying high */ }
public void Swim() { throw new NotImplementedException(); } // Eagle doesn't swim!
public void Walk() { /* Walking */ }
}
public class Penguin : IBird
{
public void Fly() { throw new NotImplementedException(); } // Penguin can't fly!
public void Swim() { /* Swimming underwater */ }
public void Walk() { /* Waddling */ }
}
public class Ostrich : IBird
{
public void Fly() { throw new NotImplementedException(); } // Ostrich can't fly!
public void Swim() { throw new NotImplementedException(); } // Ostrich doesn't swim!
public void Walk() { /* Running fast */ }
}
Python
from abc import ABC, abstractmethod
class IBird(ABC):
@abstractmethod
def fly(self) -> None:
pass
@abstractmethod
def swim(self) -> None:
pass
@abstractmethod
def walk(self) -> None:
pass
class Eagle(IBird):
def fly(self) -> None:
# Flying high
pass
def swim(self) -> None:
raise NotImplementedError("Eagle doesn't swim!") # Eagle doesn't swim!
def walk(self) -> None:
# Walking
pass
class Penguin(IBird):
def fly(self) -> None:
raise NotImplementedError("Penguin can't fly!") # Penguin can't fly!
def swim(self) -> None:
# Swimming underwater
pass
def walk(self) -> None:
# Waddling
pass
class Ostrich(IBird):
def fly(self) -> None:
raise NotImplementedError("Ostrich can't fly!") # Ostrich can't fly!
def swim(self) -> None:
raise NotImplementedError("Ostrich doesn't swim!") # Ostrich doesn't swim!
def walk(self) -> None:
# Running fast
pass
TypeScript
interface IBird {
fly(): void;
swim(): void;
walk(): void;
}
class Eagle implements IBird {
fly(): void {
// Flying high
}
swim(): void {
throw new Error("Eagle doesn't swim!"); // Eagle doesn't swim!
}
walk(): void {
// Walking
}
}
class Penguin implements IBird {
fly(): void {
throw new Error("Penguin can't fly!"); // Penguin can't fly!
}
swim(): void {
// Swimming underwater
}
walk(): void {
// Waddling
}
}
class Ostrich implements IBird {
fly(): void {
throw new Error("Ostrich can't fly!"); // Ostrich can't fly!
}
swim(): void {
throw new Error("Ostrich doesn't swim!"); // Ostrich doesn't swim!
}
walk(): void {
// Running fast
}
}
✅ Correct Implementation
C#
public interface IFlyable
{
void Fly();
}
public interface ISwimmable
{
void Swim();
}
public interface IWalkable
{
void Walk();
}
public class Eagle : IFlyable, IWalkable
{
public void Fly() { /* Flying high */ }
public void Walk() { /* Walking */ }
// No need to implement Swim()
}
public class Penguin : ISwimmable, IWalkable
{
public void Swim() { /* Swimming underwater */ }
public void Walk() { /* Waddling */ }
// No need to implement Fly()
}
public class Ostrich : IWalkable
{
public void Walk() { /* Running fast */ }
// No need to implement Fly() or Swim()
}
public class Duck : IFlyable, ISwimmable, IWalkable
{
public void Fly() { /* Flying */ }
public void Swim() { /* Swimming */ }
public void Walk() { /* Walking */ }
}
Python
from abc import ABC, abstractmethod
class IFlyable(ABC):
@abstractmethod
def fly(self) -> None:
pass
class ISwimmable(ABC):
@abstractmethod
def swim(self) -> None:
pass
class IWalkable(ABC):
@abstractmethod
def walk(self) -> None:
pass
class Eagle(IFlyable, IWalkable):
def fly(self) -> None:
# Flying high
pass
def walk(self) -> None:
# Walking
pass
# No need to implement swim()
class Penguin(ISwimmable, IWalkable):
def swim(self) -> None:
# Swimming underwater
pass
def walk(self) -> None:
# Waddling
pass
# No need to implement fly()
class Ostrich(IWalkable):
def walk(self) -> None:
# Running fast
pass
# No need to implement fly() or swim()
class Duck(IFlyable, ISwimmable, IWalkable):
def fly(self) -> None:
# Flying
pass
def swim(self) -> None:
# Swimming
pass
def walk(self) -> None:
# Walking
pass
TypeScript
interface IFlyable {
fly(): void;
}
interface ISwimmable {
swim(): void;
}
interface IWalkable {
walk(): void;
}
class Eagle implements IFlyable, IWalkable {
fly(): void {
// Flying high
}
walk(): void {
// Walking
}
// No need to implement swim()
}
class Penguin implements ISwimmable, IWalkable {
swim(): void {
// Swimming underwater
}
walk(): void {
// Waddling
}
// No need to implement fly()
}
class Ostrich implements IWalkable {
walk(): void {
// Running fast
}
// No need to implement fly() or swim()
}
class Duck implements IFlyable, ISwimmable, IWalkable {
fly(): void {
// Flying
}
swim(): void {
// Swimming
}
walk(): void {
// Walking
}
}
D - Dependency Inversion Principle (DIP)
"High-level modules should not depend on low-level modules. Both should depend on abstractions."
Depend on abstractions (interfaces) rather than concrete implementations. This makes your code more flexible and testable.
❌ Violation Example
C#
// High-level module depends on low-level module
public class OrderService
{
private readonly SqlServerDatabase _database; // Direct dependency
public OrderService()
{
_database = new SqlServerDatabase(); // Tightly coupled
}
public void SaveOrder(Order order)
{
_database.Save(order);
}
}
public class SqlServerDatabase
{
public void Save(object data) { /* SQL Server specific code */ }
}
Python
# High-level module depends on low-level module
class OrderService:
def __init__(self):
self._database = PostgreSQLDatabase() # Direct dependency, tightly coupled
def save_order(self, order: dict) -> None:
self._database.save(order)
class PostgreSQLDatabase:
def save(self, data: dict) -> None:
# PostgreSQL specific code
pass
TypeScript
// High-level module depends on low-level module
class OrderService {
private database: MySQLDatabase; // Direct dependency
constructor() {
this.database = new MySQLDatabase(); // Tightly coupled
}
saveOrder(order: Order): void {
this.database.save(order);
}
}
class MySQLDatabase {
save(data: any): void {
// MySQL specific code
}
}
✅ Correct Implementation
C#
// Both depend on abstraction
public interface IRepository
{
void Save<T>(T entity);
}
public class OrderService
{
private readonly IRepository _repository;
public OrderService(IRepository repository) // Dependency injection
{
_repository = repository;
}
public void SaveOrder(Order order) => _repository.Save(order);
}
// Low-level modules implement the abstraction
public class SqlServerRepository : IRepository
{
public void Save<T>(T entity) { /* SQL Server specific code */ }
}
public class PostgreSQLRepository : IRepository
{
public void Save<T>(T entity) { /* PostgreSQL specific code */ }
}
// Usage with dependency injection:
var orderService = new OrderService(new SqlServerRepository());
// Or easily switch to PostgreSQL:
// var orderService = new OrderService(new PostgreSQLRepository());
Python
from abc import ABC, abstractmethod
# Both depend on abstraction
class IRepository(ABC):
@abstractmethod
def save(self, entity: dict) -> None:
pass
class OrderService:
def __init__(self, repository: IRepository): # Dependency injection
self._repository = repository
def save_order(self, order: dict) -> None:
self._repository.save(order)
# Low-level modules implement the abstraction
class PostgreSQLRepository(IRepository):
def save(self, entity: dict) -> None:
# PostgreSQL specific code
pass
class MongoDBRepository(IRepository):
def save(self, entity: dict) -> None:
# MongoDB specific code
pass
# Usage with dependency injection:
order_service = OrderService(PostgreSQLRepository())
# Or easily switch to MongoDB:
# order_service = OrderService(MongoDBRepository())
TypeScript
// Both depend on abstraction
interface IRepository {
save<T>(entity: T): void;
}
class OrderService {
constructor(private repository: IRepository) {} // Dependency injection
saveOrder(order: Order): void {
this.repository.save(order);
}
}
// Low-level modules implement the abstraction
class MySQLRepository implements IRepository {
save<T>(entity: T): void {
// MySQL specific code
}
}
class MongoDBRepository implements IRepository {
save<T>(entity: T): void {
// MongoDB specific code
}
}
// Usage with dependency injection:
const orderService = new OrderService(new MySQLRepository());
// Or easily switch to MongoDB:
// const orderService = new OrderService(new MongoDBRepository());
Benefits of Following SOLID Principles
When you apply SOLID principles consistently, you'll notice:
- Easier Maintenance: Changes are localized and don't break unrelated features
- Better Testability: Smaller, focused classes with dependency injection are much easier to unit test
- Improved Flexibility: Easy to swap implementations and add new features without modifying existing code
- Reduced Coupling: Classes depend on abstractions, not concrete implementations
- Better Code Reusability: Components can be reused in different contexts
How SOLID Improves Testability
SOLID principles make unit testing significantly easier. Here's a practical comparison:
❌ Without SOLID - Hard to Test
public class OrderService
{
public void ProcessOrder(Order order)
{
// Direct database dependency - requires real database for testing
using (var connection = new SqlConnection("connection string"))
{
connection.Open();
// ... save order
}
// Direct email dependency - sends real emails during tests
var smtp = new SmtpClient();
smtp.Send("customer@email.com", "Order confirmed", "...");
}
}
✅ With SOLID - Easy to Test
public class OrderService
{
private readonly IRepository _repository;
private readonly IEmailService _emailService;
public OrderService(IRepository repository, IEmailService emailService)
{
_repository = repository;
_emailService = emailService;
}
public void ProcessOrder(Order order)
{
_repository.Save(order);
_emailService.SendConfirmation(order.CustomerEmail);
}
}
// Now you can test with mocks - fast, isolated, no external dependencies
[Test]
public void ProcessOrder_SavesOrderAndSendsEmail()
{
var mockRepo = new Mock<IRepository>();
var mockEmail = new Mock<IEmailService>();
var service = new OrderService(mockRepo.Object, mockEmail.Object);
service.ProcessOrder(new Order());
mockRepo.Verify(r => r.Save(It.IsAny<Order>()), Times.Once);
mockEmail.Verify(e => e.SendConfirmation(It.IsAny<string>()), Times.Once);
}
Benefits:
- ✅ Tests run in milliseconds (no database/network calls)
- ✅ Tests are isolated (no side effects)
- ✅ Easy to test edge cases and error scenarios
- ✅ No test environment setup required
Performance Considerations
While SOLID principles improve code quality, they can have minor performance implications:
Potential Trade-offs:
- Indirection Overhead: Interface calls have a tiny overhead compared to direct calls (negligible in most applications)
- Memory: More objects mean slightly more memory usage (usually insignificant)
- Initialization: Dependency injection containers add minimal startup time
When Performance Matters:
- High-frequency code paths (called millions of times per second): Consider direct dependencies
- Real-time systems: May need to balance SOLID with performance requirements
- Memory-constrained environments: Evaluate if the abstraction overhead is acceptable
Best Practice: Apply SOLID principles first, then optimize only if profiling shows actual performance issues. The maintainability benefits usually far outweigh the minimal performance cost.
Conclusion
SOLID principles are foundational to writing clean, maintainable code. They help us build software that can evolve and adapt to changing requirements. Remember: these are guidelines, not strict rules. Start simple, refactor when you see concrete problems, and find the right balance for your context.
Start applying these principles in your next project, and you'll see the difference in code quality and maintainability!
💡 Want to practice hands-on? You can find this exercise with starter code in multiple languages in the GitHub repository. Choose your preferred language (ava, Ruby, C#, Python, or TypeScript) and refactor the code following all SOLID principles!
Quick Reference
| Principle | One-Liner | When to Apply | Key Benefit |
|---|---|---|---|
| Single Responsibility | One class, one job | When a class handles multiple concerns | Easier maintenance and testing |
| Open/Closed | Extend, don't modify | When adding features requires changing existing code | Reduced risk of breaking changes |
| Liskov Substitution | Subclasses must be substitutable | When using inheritance | Predictable behavior |
| Interface Segregation | Many specific interfaces, not one large one | When classes implement methods they don't need | Reduced coupling |
| Dependency Inversion | Depend on abstractions, not concretions | When classes depend on concrete implementations | Better testability and flexibility |
Happy coding! 🚀
Top comments (0)