SOLID principles are a set of fundamental guidelines for designing object-oriented software. They help developers create code that is flexible, maintainable, and testable. These principles were introduced by Robert C. Martin and are widely adopted in object-oriented programming.
Single Responsibility Principle (SRP):
A class should have only one reason to change. In other words, a class should have a single responsibility. Example Consider a Customer
class. It should handle customer-related operations like adding, updating, and deleting customers. However, it shouldn’t also be responsible for sending email notifications. To adhere to SRP, we can create a separate EmailNotifier
class that handles email notifications.
Imagine a library system that needs to manage its collection of books. The library has the following requirements:
- Add New Books: The system should allow librarians to add new books to the catalog. This involves recording details such as the book’s title, author, genre, and publication year.
- Delete Old Books: Librarians should be able to remove books from the catalog when they are no longer part of the library’s collection.
- Display Book Details: The system should display information about a specific book, including its title, author, and availability status.
Now, let’s see how we can apply the Single Responsibility Principle to design a cleaner solution:
Violation of SRP (Without SRP):
using System;
using System.Collections.Generic;
namespace LibraryDemo
{
public class BookCatalog
{
private List<Book> books = new List<Book>();
public void AddBook(Book book)
{
// Logic to add book to the catalog
books.Add(book);
}
public void DeleteBook(Book book)
{
// Logic to remove book from the catalog
books.Remove(book);
}
public void DisplayBookDetails(Book book)
{
// Logic to display book details
Console.WriteLine($"Title: {book.Title}");
Console.WriteLine($"Author: {book.Author}");
// More details...
}
}
public class Book
{
public string Title { get; set; }
public string Author { get; set; }
// Other book properties...
}
class Program
{
static void Main()
{
// Usage example
var catalog = new BookCatalog();
var bookToAdd = new Book { Title = "The Catcher in the Rye", Author = "J.D. Salinger" };
catalog.AddBook(bookToAdd);
catalog.DisplayBookDetails(bookToAdd);
}
}
}
In the above code, the BookCatalog
class handles multiple responsibilities: adding books, deleting books, and displaying book details. This violates the SRP because it combines unrelated functionalities.
Following SRP (With SRP):
To adhere to SRP, we can separate the responsibilities:
BookCatalog
class: Responsible for managing the catalog (adding and deleting books).BookDetailsPrinter
class: Handles the responsibility of displaying book details.
using System;
namespace LibraryDemo
{
public class BookCatalog
{
// Logic to add and delete books
// ...
}
public class BookDetailsPrinter
{
public void DisplayBookDetails(Book book)
{
Console.WriteLine($"Title: {book.Title}");
Console.WriteLine($"Author: {book.Author}");
// More details...
}
}
public class Book
{
public string Title { get; set; }
public string Author { get; set; }
// Other book properties...
}
class Program
{
static void Main()
{
// Usage example
var catalog = new BookCatalog();
var printer = new BookDetailsPrinter();
var bookToAdd = new Book { Title = "The Catcher in the Rye", Author = "J.D. Salinger" };
catalog.AddBook(bookToAdd);
printer.DisplayBookDetails(bookToAdd);
}
}
}
By separating concerns, we achieve a cleaner design where each class has a single responsibility. The BookCatalog
class manages the catalog, and the BookDetailsPrinter
class handles displaying book details. This adheres to the Single Responsibility Principle, making our code more maintainable and extensible.
Open-Closed Principle (OCP):
Software entities (classes, modules, functions) should be open for extension but closed for modification. Example: Suppose we have a PaymentProcessor
class with different payment methods (credit card, PayPal, etc.). Instead of modifying the existing class when adding a new payment method, we can create a new class (e.g., PayPalPaymentProcessor
) that extends the base PaymentProcessor
class.
Imagine you’re building an e-commerce system that provides discounts to different types of customers. The system initially handles two customer categories: regular customers and premium customers. Each category receives a different discount percentage on their purchases.
Without OCP (Violation of OCP):
using System;
namespace ECommerceDemo
{
public class Customer
{
public string Name { get; set; }
public decimal TotalPurchaseAmount { get; set; }
}
public class DiscountCalculator
{
public decimal CalculateDiscount(Customer customer)
{
// Logic to calculate discount based on customer type
if (customer.TotalPurchaseAmount > 1000)
return customer.TotalPurchaseAmount * 0.1m; // 10% discount for premium customers
else
return customer.TotalPurchaseAmount * 0.05m; // 5% discount for regular customers
}
}
class Program
{
static void Main()
{
var regularCustomer = new Customer { Name = "Alice", TotalPurchaseAmount = 800 };
var premiumCustomer = new Customer { Name = "Bob", TotalPurchaseAmount = 1500 };
var discountCalculator = new DiscountCalculator();
Console.WriteLine($"Regular Customer Discount: ${discountCalculator.CalculateDiscount(regularCustomer)}");
Console.WriteLine($"Premium Customer Discount: ${discountCalculator.CalculateDiscount(premiumCustomer)}");
}
}
}
In the above code, the DiscountCalculator
class violates the OCP. If we need to add a new customer category (e.g., VIP customers), we would have to modify the existing class, which is not ideal.
Following OCP (With OCP):
To adhere to the OCP, we can create an abstract base class or interface for discount calculation and then derive specific discount calculators for each customer type:
using System;
namespace ECommerceDemo
{
public abstract class DiscountCalculatorBase
{
public abstract decimal CalculateDiscount(Customer customer);
}
public class RegularCustomerDiscountCalculator : DiscountCalculatorBase
{
public override decimal CalculateDiscount(Customer customer)
{
return customer.TotalPurchaseAmount * 0.05m; // 5% discount for regular customers
}
}
public class PremiumCustomerDiscountCalculator : DiscountCalculatorBase
{
public override decimal CalculateDiscount(Customer customer)
{
return customer.TotalPurchaseAmount * 0.1m; // 10% discount for premium customers
}
}
class Program
{
static void Main()
{
var regularCustomer = new Customer { Name = "Alice", TotalPurchaseAmount = 800 };
var premiumCustomer = new Customer { Name = "Bob", TotalPurchaseAmount = 1500 };
var regularDiscountCalculator = new RegularCustomerDiscountCalculator();
var premiumDiscountCalculator = new PremiumCustomerDiscountCalculator();
Console.WriteLine($"Regular Customer Discount: ${regularDiscountCalculator.CalculateDiscount(regularCustomer)}");
Console.WriteLine($"Premium Customer Discount: ${premiumDiscountCalculator.CalculateDiscount(premiumCustomer)}");
}
}
}
Now, we can easily add new customer types by creating additional discount calculator classes without modifying the existing ones. This adheres to the Open-Closed Principle, allowing extension without altering the source code.
By following the OCP, our code becomes more maintainable and flexible as the e-commerce system evolves.
Liskov Substitution Principle (LSP):
Objects of a derived class should be able to replace objects of the base class without affecting the correctness of the program. Example: Imagine a Rectangle
class with properties Width
and Height
. If we create a Square
class that inherits from Rectangle
, we must ensure that changing the dimensions of a Square
doesn’t violate the expected behavior of a Rectangle
.
Consider a scenario where we’re building a banking application. We have a base class called BankAccount
, which represents a generic bank account. It has common properties like AccountNumber
, Balance
, and methods like Deposit
and Withdraw
.
Now, we want to create specialized account types: SavingsAccount
and CheckingAccount
. These derived classes should inherit from the base BankAccount
class. Let’s see how we can apply the Liskov Substitution Principle:
class Program
{
static void Main()
{
// Create a Savings Account
BankAccount savingsAccount = new SavingsAccount
{
AccountNumber = "SA123",
Balance = 1000,
InterestRate = 0.02 // 2% annual interest
};
// Create a Checking Account
BankAccount checkingAccount = new CheckingAccount
{
AccountNumber = "CA456",
Balance = 2000,
OverdraftLimit = 500 // Overdraft limit of $500
};
// Perform operations
savingsAccount.Deposit(500); // Deposit $500
checkingAccount.Withdraw(2500); // Withdraw $2500 (within overdraft limit)
// Print account details
Console.WriteLine($"Savings Account Balance: ${savingsAccount.Balance}");
Console.WriteLine($"Checking Account Balance: ${checkingAccount.Balance}");
}
}
class BankAccount
{
public string AccountNumber { get; set; }
public decimal Balance { get; set; }
public virtual void Deposit(decimal amount)
{
Balance += amount;
}
public virtual void Withdraw(decimal amount)
{
Balance -= amount;
}
}
class SavingsAccount : BankAccount
{
public decimal InterestRate { get; set; }
public override void Withdraw(decimal amount)
{
// Enforce minimum balance rules for savings accounts
if (Balance - amount >= 100)
base.Withdraw(amount);
}
}
class CheckingAccount : BankAccount
{
public decimal OverdraftLimit { get; set; }
public override void Withdraw(decimal amount)
{
// Allow overdrafts within the specified limit
if (Balance - amount >= -OverdraftLimit)
base.Withdraw(amount);
}
}
In this example, both SavingsAccount
and CheckingAccount
are substitutable for the base BankAccount
class. They adhere to the contract defined by the base class while providing specialized behavior. Applying the LSP ensures that we can interchangeably use these account types without unexpected side effects.
Interface Segregation Principle (ISP):
Clients should not be forced to depend on interfaces they don’t use. Keep interfaces small and focused. Example: Suppose we have an Employee
interface with methods like CalculateSalary
, Promote
, and Terminate
. If a class only needs salary calculation, we can create a separate ISalaryCalculator
interface containing just the CalculateSalary
method.
In a library management system, various users interact with the system, including members, librarians, and guests. Each user type has different permissions and behaviors:
- Members:
- Can borrow and return books.
- Can search the catalog.
- Librarians:
- Can manage inventory (add or remove books).
- Can also perform tasks that members can.
- Guests:
- Can only search the catalog.
Now, let’s design the interfaces adhering to the Interface Segregation Principle:
// Interface for common book-related actions
public interface IBookActions
{
void BorrowBook(Book book);
void ReturnBook(Book book);
}
// Interface for managing inventory (specific to librarians)
public interface IInventoryManagement
{
void AddBook(Book book);
void RemoveBook(Book book);
}
// Interface for searching the catalog (common for all users)
public interface ICatalogSearch
{
void SearchCatalog(string query);
}
// Concrete class representing a book
public class Book
{
public string Title { get; set; }
public string Author { get; set; }
// Other book properties...
}
// Member class implementing relevant interfaces
public class Member : IBookActions, ICatalogSearch
{
public void BorrowBook(Book book)
{
// Logic to borrow a book
}
public void ReturnBook(Book book)
{
// Logic to return a book
}
public void SearchCatalog(string query)
{
// Logic to search the catalog
}
}
// Librarian class implementing all relevant interfaces
public class Librarian : IBookActions, IInventoryManagement, ICatalogSearch
{
public void BorrowBook(Book book)
{
// Logic to borrow a book
}
public void ReturnBook(Book book)
{
// Logic to return a book
}
public void AddBook(Book book)
{
// Logic to add a book to inventory
}
public void RemoveBook(Book book)
{
// Logic to remove a book from inventory
}
public void SearchCatalog(string query)
{
// Logic to search the catalog
}
}
// Guest class implementing catalog search interface only
public class Guest : ICatalogSearch
{
public void SearchCatalog(string query)
{
// Logic to search the catalog
}
}
class Program
{
static void Main()
{
// Example usage
var member = new Member();
var librarian = new Librarian();
var guest = new Guest();
member.BorrowBook(new Book());
librarian.AddBook(new Book());
guest.SearchCatalog("fiction");
}
}
By creating specific interfaces for each user type, we avoid unnecessary coupling and ensure that classes implement only the methods they need. This promotes a more organized and maintainable codebase, aligning with the Interface Segregation Principle.
Dependency Inversion Principle (DIP):
High-level modules should not depend on low-level modules. Both should depend on abstractions. Abstractions should not depend on details; details should depend on abstractions. Example: Consider a PaymentService
that interacts with a PaymentGateway
. Instead of directly coupling them, we can introduce an IPaymentGateway
interface. The PaymentService
depends on the interface, and specific payment gateways implement it.
Imagine we’re building a payment processing system that handles transactions for an e-commerce platform. Here’s how we can apply DIP to enhance flexibility and maintainability:
Identify High-Level and Low-Level Modules:
- High-Level Module: Our payment processing system, responsible for handling payment requests, validations, and interactions with external payment gateways.
- Low-Level Module: The actual payment gateway implementations (e.g., PayPal, Stripe, or a custom gateway).
Define Abstractions:
- Create an interface or abstract class (abstraction) that defines the behavior our high-level module (payment processing system) expects from payment gateways. For example:
public interface IPaymentGateway
{
bool ProcessPayment(decimal amount);
}
Implement Abstractions:
- Implement the
IPaymentGateway
interface in low-level modules (payment gateway providers) with concrete details. Each provider (e.g., PayPal, Stripe) will have its implementation of payment processing logic.
Invert Dependencies:
- Instead of the payment processing system directly depending on specific payment gateways, it depends on the abstraction (
IPaymentGateway
). This reduces coupling. - Our payment processing system now relies on the contract defined by
IPaymentGateway
.
Use Dependency Injection:
- Inject the appropriate payment gateway implementation (e.g., PayPal, Stripe) into the payment processing system during runtime.
- We can easily switch between different payment gateways without modifying the core payment processing logic.
Configure Dependencies:
- Configure the application to inject the desired payment gateway implementation based on configuration settings or user preferences.
- For instance:
var paymentGateway = new PayPalGateway(); // Or any other gateway
var paymentProcessor = new PaymentProcessor(paymentGateway);
Isolate Unit Testing:
- During unit testing, replace the actual payment gateway implementations with mock implementations to isolate the payment processing system from external dependencies.
By adhering to the Dependency Inversion Principle, we achieve a more modular and extensible payment processing system. We can seamlessly integrate new payment gateways or switch providers without disrupting the core functionality.