SOLID Principles Explained with C# Examples
In enterprise software engineering, writing code that simply works is only half the battle. As applications scale, requirements evolve, and codebases grow from thousands to millions of lines, poorly structured code quickly devolves into rigid, fragile, and untestable spaghetti code. To prevent this, professional developers rely on the **SOLID principles**—a set of five object-oriented design guidelines introduced by Robert C. Martin (Uncle Bob).
These principles provide a blueprint for building maintainable, decoupled, and easily testable applications. This comprehensive guide explores each SOLID principle in detail, accompanied by production-grade **C#** examples.
1. Single Responsibility Principle (SRP)
The **Single Responsibility Principle** states that a class should have one, and only one, reason to change. In other words, a class should encapsulate only one specific business responsibility or domain concern.
// Bad Practice: Violating SRP by mixing business logic, persistence, and notifications
public class UserAccount
{
public string Username { get; set; }
public string Email { get; set; }
public void Register()
{
// Save user to database
Console.WriteLine($"Saving {Username} to SQL database...");
// Send welcome email
Console.WriteLine($"Sending welcome email to {Email}...");
}
}
// Good Practice: Separating concerns into dedicated classes
public class User
{
public string Username { get; set; }
public string Email { get; set; }
}
public class UserRepository
{
public void Save(User user) => Console.WriteLine($"Saving {user.Username} to database...");
}
public class EmailService
{
public void SendWelcomeEmail(User user) => Console.WriteLine($"Sending email to {user.Email}...");
}
2. Open/Closed Principle (OCP)
The **Open/Closed Principle** states that software entities (classes, modules, functions) should be **open for extension, but closed for modification**. You should be able to add new functionality without altering existing, tested code.
// Good Practice: Using polymorphism to remain open for extension
public interface IDiscountStrategy
{
decimal CalculateDiscount(decimal amount);
}
public class RegularCustomerDiscount : IDiscountStrategy
{
public decimal CalculateDiscount(decimal amount) => amount * 0.05m;
}
public class VIPCustomerDiscount : IDiscountStrategy
{
public decimal CalculateDiscount(decimal amount) => amount * 0.20m;
}
public class DiscountCalculator
{
public decimal ApplyDiscount(IDiscountStrategy strategy, decimal amount)
{
return strategy.CalculateDiscount(amount);
}
}
3. Liskov Substitution Principle (LSP)
The **Liskov Substitution Principle** states that objects of a superclass should be replaceable with objects of its subclasses without breaking the application's correctness or expected behavior.
// Good Practice: Ensuring subtype substitutability
public interface IFlyingBird
{
void Fly();
}
public class Sparrow : IFlyingBird
{
public void Fly() => Console.WriteLine("Sparrow is flying.");
}
// Penguins don't fly; forcing them to inherit an invalid Fly method violates LSP.
public interface IBird
{
void Eat();
}
public class Penguin : IBird
{
public void Eat() => Console.WriteLine("Penguin is eating fish.");
}
4. Interface Segregation Principle (ISP)
The **Interface Segregation Principle** states that clients should not be forced to depend upon interfaces they do not use. Instead of one massive fat interface, create smaller, highly specific role interfaces.
// Good Practice: Segregated granular interfaces
public interface IPrinter
{
void Print(string document);
}
public interface IScanner
{
void Scan(string document);
}
public class MultiFunctionPrinter : IPrinter, IScanner
{
public void Print(string document) => Console.WriteLine("Printing document...");
public void Scan(string document) => Console.WriteLine("Scanning document...");
}
public class BasicPrinter : IPrinter
{
public void Print(string document) => Console.WriteLine("Printing document...");
}
5. Dependency Inversion Principle (DIP)
The **Dependency Inversion Principle** states that high-level modules should not depend on low-level modules; both should depend on abstractions. Furthermore, abstractions should not depend on details; details should depend on abstractions.
// Good Practice: Depending on abstractions injected via constructors
public interface IMessageSender
{
void SendMessage(string message);
}
public class SMTPEmailSender : IMessageSender
{
public void SendMessage(string message) => Console.WriteLine($"Sending email: {message}");
}
public class NotificationService
{
private readonly IMessageSender _messageSender;
public NotificationService(IMessageSender messageSender)
{
_messageSender = messageSender;
}
public void Notify(string message)
{
_messageSender.SendMessage(message);
}
}
Conclusion
Mastering the SOLID principles in C#—from maintaining single-responsibility classes and open-for-extension architectures to enforcing liskov substitutability, segregated interfaces, and dependency injection—allows developers to build robust, extensible, and enterprise-grade applications.