.NET SOLID Principles
Introduction
SOLID is an acronym for five design principles that help developers create more maintainable, flexible, and scalable software. These principles were introduced by Robert C. Martin (also known as Uncle Bob) and have become fundamental guidelines for object-oriented programming and software design.
In this tutorial, we'll explore each SOLID principle and see how to implement them in .NET applications using C#. By the end, you'll understand how these principles can lead to better code organization, easier maintenance, and more robust software.
Why SOLID Principles Matter
Before diving into each principle, let's understand why these principles are important:
- They help create cleaner code that is easier to read and maintain
- They make your code more flexible and adaptable to changes
- They improve testability by promoting loose coupling
- They create better abstractions in your code
- They promote reusability of components
Let's explore each principle one by one.
Single Responsibility Principle (SRP)
The Principle
A class should have only one reason to change.
This means a class should have only one responsibility or job. If a class has multiple responsibilities, it becomes coupled in ways that make it harder to maintain.
Bad Example
Here's a class that violates SRP:
public class User
{
public int Id { get; set; }
public string Name { get; set; }
public string Email { get; set; }
// This class handles too many responsibilities
public bool ValidateEmail()
{
return Email.Contains("@");
}
public void SaveToDatabase()
{
// Code to save user to database
Console.WriteLine($"Saving user {Name} to database");
}
public void SendWelcomeEmail()
{
// Code to send welcome email
Console.WriteLine($"Sending welcome email to {Email}");
}
}
Good Example
Let's refactor this into classes with single responsibilities:
// User class only manages user data
public class User
{
public int Id { get; set; }
public string Name { get; set; }
public string Email { get; set; }
}
// EmailValidator handles email validation
public class EmailValidator
{
public bool IsValid(string email)
{
return !string.IsNullOrEmpty(email) && email.Contains("@");
}
}
// UserRepository handles database operations
public class UserRepository
{
public void Save(User user)
{
// Code to save user to database
Console.WriteLine($"Saving user {user.Name} to database");
}
}
// EmailService handles email communications
public class EmailService
{
public void SendWelcomeEmail(User user)
{
// Code to send welcome email
Console.WriteLine($"Sending welcome email to {user.Email}");
}
}
Real-world Application
In real-world applications, SRP helps create more maintainable services. For example, in an e-commerce application, you might have separate services for:
- Product information management
- Inventory tracking
- Order processing
- Payment handling
- Customer notifications
Each service focuses on a single aspect of the business domain, making the system easier to understand and maintain.
Open/Closed Principle (OCP)
The Principle
Software entities should be open for extension but closed for modification.
This means you should be able to add new functionality without changing existing code.
Bad Example
Here's a class that violates OCP:
public class PaymentProcessor
{
public void ProcessPayment(string paymentType, double amount)
{
if (paymentType == "CreditCard")
{
// Process credit card payment
Console.WriteLine($"Processing credit card payment of ${amount}");
}
else if (paymentType == "PayPal")
{
// Process PayPal payment
Console.WriteLine($"Processing PayPal payment of ${amount}");
}
// When we need to add another payment type, we must modify this class!
}
}
Good Example
Using OCP, we create a design that allows adding new payment types without modifying existing code:
public interface IPaymentMethod
{
void ProcessPayment(double amount);
}
public class CreditCardPayment : IPaymentMethod
{
public void ProcessPayment(double amount)
{
// Process credit card payment
Console.WriteLine($"Processing credit card payment of ${amount}");
}
}
public class PayPalPayment : IPaymentMethod
{
public void ProcessPayment(double amount)
{
// Process PayPal payment
Console.WriteLine($"Processing PayPal payment of ${amount}");
}
}
// This class is now closed for modification but open for extension
public class PaymentProcessor
{
public void ProcessPayment(IPaymentMethod paymentMethod, double amount)
{
paymentMethod.ProcessPayment(amount);
}
}
// To add a new payment method, we just create a new class:
public class BitcoinPayment : IPaymentMethod
{
public void ProcessPayment(double amount)
{
Console.WriteLine($"Processing Bitcoin payment of ${amount}");
}
}
Using the Payment Processor
var processor = new PaymentProcessor();
// Process a credit card payment
processor.ProcessPayment(new CreditCardPayment(), 100.00);
// Process a PayPal payment
processor.ProcessPayment(new PayPalPayment(), 50.00);
// Process a Bitcoin payment
processor.ProcessPayment(new BitcoinPayment(), 75.00);
Output:
Processing credit card payment of $100
Processing PayPal payment of $50
Processing Bitcoin payment of $75
Liskov Substitution Principle (LSP)
The Principle
Objects of a superclass should be replaceable with objects of its subclasses without affecting the correctness of the program.
In other words, derived classes must be substitutable for their base classes without altering the behavior that clients expect.
Bad Example
Here's an example that violates LSP:
public class Rectangle
{
public virtual int Width { get; set; }
public virtual int Height { get; set; }
public Rectangle(int width, int height)
{
Width = width;
Height = height;
}
public virtual int CalculateArea()
{
return Width * Height;
}
}
public class Square : Rectangle
{
public Square(int size) : base(size, size) { }
public override int Width
{
get => base.Width;
set
{
base.Width = value;
base.Height = value;
}
}
public override int Height
{
get => base.Height;
set
{
base.Width = value;
base.Height = value;
}
}
}
The problem occurs when someone tries to use a Square where a Rectangle is expected:
void ModifyRectangle(Rectangle rectangle)
{
rectangle.Width = 5;
rectangle.Height = 10;
// If rectangle is actually a Square, Width would be 10 too!
// This assertion would fail if rectangle is a Square
Console.WriteLine($"Expected area: 50, Actual area: {rectangle.CalculateArea()}");
}
Good Example
A better approach is to redesign the hierarchy:
public abstract class Shape
{
public abstract int CalculateArea();
}
public class Rectangle : Shape
{
public int Width { get; set; }
public int Height { get; set; }
public Rectangle(int width, int height)
{
Width = width;
Height = height;
}
public override int CalculateArea()
{
return Width * Height;
}
}
public class Square : Shape
{
public int Size { get; set; }
public Square(int size)
{
Size = size;
}
public override int CalculateArea()
{
return Size * Size;
}
}
Now there's no inheritance relationship between Square and Rectangle, and each implements the Shape abstraction correctly.
Using the Shapes
void PrintShapeArea(Shape shape)
{
Console.WriteLine($"Area: {shape.CalculateArea()}");
}
var rectangle = new Rectangle(5, 10);
var square = new Square(5);
PrintShapeArea(rectangle); // Area: 50
PrintShapeArea(square); // Area: 25
Interface Segregation Principle (ISP)
The Principle
Clients should not be forced to depend upon interfaces they do not use.
This principle encourages creating smaller, specific interfaces rather than large, general-purpose ones.
Bad Example
Here's a monolithic interface that violates ISP:
public interface IWorker
{
void Work();
void TakeBreak();
void GetPaid();
}
// A problem arises with robot workers
public class HumanWorker : IWorker
{
public void Work() => Console.WriteLine("Human is working");
public void TakeBreak() => Console.WriteLine("Human is taking a break");
public void GetPaid() => Console.WriteLine("Human is getting paid");
}
public class RobotWorker : IWorker
{
public void Work() => Console.WriteLine("Robot is working");
// Robots don't need breaks!
public void TakeBreak() => throw new NotImplementedException("Robots don't take breaks");
// Robots don't get paid!
public void GetPaid() => throw new NotImplementedException("Robots don't get paid");
}
Good Example
We can split the interface into smaller, more focused ones:
public interface IWorkable
{
void Work();
}
public interface IBreakable
{
void TakeBreak();
}
public interface IPayable
{
void GetPaid();
}
public class HumanWorker : IWorkable, IBreakable, IPayable
{
public void Work() => Console.WriteLine("Human is working");
public void TakeBreak() => Console.WriteLine("Human is taking a break");
public void GetPaid() => Console.WriteLine("Human is getting paid");
}
public class RobotWorker : IWorkable
{
public void Work() => Console.WriteLine("Robot is working");
// No need to implement methods that don't apply to robots
}
Using the Workers
void MakeWork(IWorkable worker)
{
worker.Work();
}
void GiveBreak(IBreakable worker)
{
worker.TakeBreak();
}
var human = new HumanWorker();
var robot = new RobotWorker();
MakeWork(human); // Human is working
MakeWork(robot); // Robot is working
GiveBreak(human); // Human is taking a break
// GiveBreak(robot); // This won't compile - robots don't implement IBreakable
Dependency Inversion Principle (DIP)
The Principle
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.
The key is to depend on abstractions rather than concrete implementations.
Bad Example
Here's code that violates DIP by having a high-level module (NotificationService) directly depend on low-level modules:
public class EmailSender
{
public void SendEmail(string to, string subject, string body)
{
// Code to send email
Console.WriteLine($"Email sent to {to} with subject: {subject}");
}
}
public class NotificationService
{
private readonly EmailSender _emailSender;
public NotificationService()
{
_emailSender = new EmailSender(); // Direct dependency on concrete implementation
}
public void SendNotification(string user, string message)
{
_emailSender.SendEmail(user, "Notification", message);
}
}
Good Example
Let's refactor this to depend on abstractions:
public interface IMessageSender
{
void SendMessage(string to, string subject, string body);
}
public class EmailSender : IMessageSender
{
public void SendMessage(string to, string subject, string body)
{
// Code to send email
Console.WriteLine($"Email sent to {to} with subject: {subject}");
}
}
public class SMSSender : IMessageSender
{
public void SendMessage(string to, string subject, string body)
{
// Code to send SMS
Console.WriteLine($"SMS sent to {to}: {body}");
}
}
public class NotificationService
{
private readonly IMessageSender _messageSender;
// Dependency injection
public NotificationService(IMessageSender messageSender)
{
_messageSender = messageSender;
}
public void SendNotification(string user, string message)
{
_messageSender.SendMessage(user, "Notification", message);
}
}
Using Dependency Injection
// Using email sender
var emailService = new NotificationService(new EmailSender());
emailService.SendNotification("[email protected]", "Hello from the application!");
// Using SMS sender
var smsService = new NotificationService(new SMSSender());
smsService.SendNotification("+1234567890", "Hello from the application!");
Output:
Email sent to [email protected] with subject: Notification
SMS sent to +1234567890: Hello from the application!
Real-world Application of SOLID Principles
Let's look at a more complete example combining all SOLID principles in a simple order processing system:
// Single Responsibility Principle: Each class has a single responsibility
public class Order
{
public int Id { get; set; }
public List<OrderItem> Items { get; set; } = new List<OrderItem>();
public decimal TotalAmount => Items.Sum(i => i.Price * i.Quantity);
}
public class OrderItem
{
public string ProductName { get; set; }
public int Quantity { get; set; }
public decimal Price { get; set; }
}
// Open/Closed Principle: Payment system is open for extension
public interface IPaymentProcessor
{
bool ProcessPayment(Order order, decimal amount);
}
public class CreditCardProcessor : IPaymentProcessor
{
public bool ProcessPayment(Order order, decimal amount)
{
// Credit card processing logic
Console.WriteLine($"Processing credit card payment of ${amount} for order {order.Id}");
return true;
}
}
public class PayPalProcessor : IPaymentProcessor
{
public bool ProcessPayment(Order order, decimal amount)
{
// PayPal processing logic
Console.WriteLine($"Processing PayPal payment of ${amount} for order {order.Id}");
return true;
}
}
// Liskov Substitution Principle: All notification services can be used interchangeably
public interface IOrderNotification
{
void NotifyCustomer(Order order);
}
public class EmailNotification : IOrderNotification
{
public void NotifyCustomer(Order order)
{
Console.WriteLine($"Sending email notification for order {order.Id}");
}
}
public class SMSNotification : IOrderNotification
{
public void NotifyCustomer(Order order)
{
Console.WriteLine($"Sending SMS notification for order {order.Id}");
}
}
// Interface Segregation Principle: Separate interfaces for different responsibilities
public interface IOrderRepository
{
void Save(Order order);
Order GetById(int id);
}
public interface IOrderValidator
{
bool ValidateOrder(Order order);
}
// Dependency Inversion Principle: High-level modules depend on abstractions
public class OrderProcessor
{
private readonly IPaymentProcessor _paymentProcessor;
private readonly IOrderRepository _orderRepository;
private readonly IOrderValidator _orderValidator;
private readonly IOrderNotification _orderNotification;
public OrderProcessor(
IPaymentProcessor paymentProcessor,
IOrderRepository orderRepository,
IOrderValidator orderValidator,
IOrderNotification orderNotification)
{
_paymentProcessor = paymentProcessor;
_orderRepository = orderRepository;
_orderValidator = orderValidator;
_orderNotification = orderNotification;
}
public bool ProcessOrder(Order order)
{
// Validate order
if (!_orderValidator.ValidateOrder(order))
{
Console.WriteLine("Order validation failed");
return false;
}
// Save order
_orderRepository.Save(order);
// Process payment
if (!_paymentProcessor.ProcessPayment(order, order.TotalAmount))
{
Console.WriteLine("Payment processing failed");
return false;
}
// Notify customer
_orderNotification.NotifyCustomer(order);
Console.WriteLine($"Order {order.Id} processed successfully");
return true;
}
}
Usage Example
// Implementation of repositories and validators
public class SqlOrderRepository : IOrderRepository
{
public Order GetById(int id)
{
Console.WriteLine($"Getting order {id} from SQL database");
return new Order { Id = id };
}
public void Save(Order order)
{
Console.WriteLine($"Saving order {order.Id} to SQL database");
}
}
public class BasicOrderValidator : IOrderValidator
{
public bool ValidateOrder(Order order)
{
if (order == null || order.Items.Count == 0)
{
return false;
}
return true;
}
}
// Using the order processor
var order = new Order
{
Id = 12345,
Items = new List<OrderItem>
{
new OrderItem { ProductName = "Laptop", Quantity = 1, Price = 1200 },
new OrderItem { ProductName = "Mouse", Quantity = 2, Price = 25 }
}
};
var processor = new OrderProcessor(
new CreditCardProcessor(),
new SqlOrderRepository(),
new BasicOrderValidator(),
new EmailNotification()
);
processor.ProcessOrder(order);
Output:
Saving order 12345 to SQL database
Processing credit card payment of $1250 for order 12345
Sending email notification for order 12345
Order 12345 processed successfully
Benefits of Applying SOLID Principles
By applying SOLID principles to your .NET applications, you gain several benefits:
- Easier maintenance: Small, focused classes are easier to understand and modify
- Improved testability: Decoupled components are easier to test in isolation
- Better scalability: The system can grow without becoming overly complex
- Reduced bugs: Clear responsibilities lead to fewer unexpected interactions
- Increased reusability: Well-defined components can be reused in other parts of the application
- Future-proofing: Code designed with SOLID principles adapts more easily to changing requirements
Summary
The SOLID principles provide powerful guidelines for creating maintainable and scalable software in .NET:
- Single Responsibility Principle (SRP): Each class should have only one reason to change
- Open/Closed Principle (OCP): Classes should be open for extension but closed for modification
- Liskov Substitution Principle (LSP): Derived classes must be substitutable for their base classes
- Interface Segregation Principle (ISP): Many client-specific interfaces are better than one general-purpose interface
- Dependency Inversion Principle (DIP): Depend on abstractions, not on concrete implementations
By following these principles, you'll write cleaner, more maintainable code that can adapt to changing requirements and scale with your application's needs.
Additional Resources
- Microsoft Docs: .NET Architectural Principles
- Clean Code by Robert C. Martin
- Agile Principles, Patterns, and Practices in C#
Practice Exercises
- Take an existing class in your project that has multiple responsibilities and refactor it according to SRP.
- Identify a place where you're using if/else statements to handle different types of objects, and refactor it using OCP.
- Review your class hierarchies to ensure they follow LSP.
- Look for interfaces in your code that could be split into smaller, more focused interfaces according to ISP.
- Identify dependencies in your code that could be inverted using DIP and dependency injection.
If you spot any mistakes on this website, please let me know at [email protected]. I’d greatly appreciate your feedback! :)