SOLID Principles in C#: A Complete Developer Guide
Learn the five SOLID principles that every C# developer should master. This comprehensive guide covers Single Responsibility, Open/Closed, Liskov Substitution, Interface Segregation, and Dependency Inversion principles with practical C# examples, ASP.NET Core implementations, and real-world scenarios. Discover how these fundamental design principles can make your .NET applications more maintainable, testable, and scalable.
The SOLID principles have been the backbone of good software design for over two decades. As someone who's seen countless C# projects succeed and fail, I can tell you that understanding these principles is crucial for any .NET developer looking to write maintainable, scalable code.
Whether you're building modern microservices with ASP.NET Core or maintaining legacy enterprise applications, these principles will help you create code that stands the test of time.
What Makes SOLID Principles Essential?
In my experience working with C# applications—from small web APIs to large enterprise systems—I've consistently seen that codebases following SOLID principles are:
- Easier to debug and maintain - Issues are isolated to specific classes
- More testable - Dependencies can be easily mocked using frameworks like Moq
- Adaptable to change - New requirements don't require major refactoring
- Team-friendly - Clear responsibilities make collaboration smoother
The principles spell out SOLID:
- Single Responsibility Principle
- Open/Closed Principle
- Liskov Substitution Principle
- Interface Segregation Principle
- Dependency Inversion Principle
Let's explore each with practical C# examples.
Single Responsibility Principle (SRP)
"A class should have only one reason to change."
This principle keeps classes focused on a single job, making them easier to understand and maintain.
The Problem
1// Violates SRP - handles multiple responsibilities 2public class Customer 3{ 4 public string Name { get; set; } 5 public string Email { get; set; } 6 7 public void Save() 8 { 9 // Database logic mixed with domain logic 10 using var connection = new SqlConnection("..."); 11 // SQL operations 12 } 13 14 public void SendWelcomeEmail() 15 { 16 // Email logic mixed with domain logic 17 var smtpClient = new SmtpClient(); 18 // Send email 19 } 20 21 public string GenerateReport() 22 { 23 // Reporting logic mixed with domain logic 24 return $"Customer Report for {Name}"; 25 } 26}
The Solution
1// Domain model - single responsibility: represent customer data 2public class Customer 3{ 4 public string Name { get; set; } 5 public string Email { get; set; } 6 public DateTime CreatedAt { get; set; } 7} 8 9// Data access - single responsibility: persist customers 10public interface ICustomerRepository 11{ 12 Task SaveAsync(Customer customer); 13 Task<Customer> GetByEmailAsync(string email); 14} 15 16public class CustomerRepository : ICustomerRepository 17{ 18 private readonly IDbContext _context; 19 20 public CustomerRepository(IDbContext context) 21 { 22 _context = context; 23 } 24 25 public async Task SaveAsync(Customer customer) 26 { 27 _context.Customers.Add(customer); 28 await _context.SaveChangesAsync(); 29 } 30 31 public async Task<Customer> GetByEmailAsync(string email) 32 { 33 return await _context.Customers 34 .FirstOrDefaultAsync(c => c.Email == email); 35 } 36} 37 38// Email service - single responsibility: send emails 39public interface IEmailService 40{ 41 Task SendWelcomeEmailAsync(Customer customer); 42} 43 44public class EmailService : IEmailService 45{ 46 public async Task SendWelcomeEmailAsync(Customer customer) 47 { 48 // Email sending logic here 49 await Task.CompletedTask; // Placeholder 50 } 51} 52 53// Report generation - single responsibility: create reports 54public interface IReportService 55{ 56 string GenerateCustomerReport(Customer customer); 57} 58 59public class ReportService : IReportService 60{ 61 public string GenerateCustomerReport(Customer customer) 62 { 63 return $"Customer Report for {customer.Name} - Created: {customer.CreatedAt}"; 64 } 65}
Each class now has a clear, single purpose. This makes testing, debugging, and maintaining much simpler.
Open/Closed Principle (OCP)
"Software entities should be open for extension but closed for modification."
You should be able to add new functionality without changing existing code.
The Problem
1public class OrderProcessor 2{ 3 public decimal CalculateDiscount(Customer customer, decimal amount) 4 { 5 if (customer.Type == CustomerType.Regular) 6 return amount * 0.05m; 7 else if (customer.Type == CustomerType.Premium) 8 return amount * 0.10m; 9 else if (customer.Type == CustomerType.VIP) 10 return amount * 0.15m; 11 12 // Adding new customer types requires modifying this method 13 return 0; 14 } 15}
The Solution
1public abstract class Customer 2{ 3 public string Name { get; set; } 4 public string Email { get; set; } 5 6 public abstract decimal GetDiscountPercentage(); 7} 8 9public class RegularCustomer : Customer 10{ 11 public override decimal GetDiscountPercentage() => 0.05m; 12} 13 14public class PremiumCustomer : Customer 15{ 16 public override decimal GetDiscountPercentage() => 0.10m; 17} 18 19public class VIPCustomer : Customer 20{ 21 public override decimal GetDiscountPercentage() => 0.15m; 22} 23 24// Can add new customer types without modifying existing code 25public class CorporateCustomer : Customer 26{ 27 public override decimal GetDiscountPercentage() => 0.20m; 28} 29 30public class OrderProcessor 31{ 32 public decimal CalculateDiscount(Customer customer, decimal amount) 33 { 34 return amount * customer.GetDiscountPercentage(); 35 } 36}
Now you can add new customer types without touching the OrderProcessor
class.
Liskov Substitution Principle (LSP)
"Objects should be replaceable with instances of their subtypes without breaking functionality."
Subclasses should behave like their parent classes.
The Problem
1public class Rectangle 2{ 3 public virtual int Width { get; set; } 4 public virtual int Height { get; set; } 5 6 public virtual int GetArea() => Width * Height; 7} 8 9public class Square : Rectangle 10{ 11 public override int Width 12 { 13 get => base.Width; 14 set => base.Width = base.Height = value; // Violates LSP 15 } 16 17 public override int Height 18 { 19 get => base.Height; 20 set => base.Width = base.Height = value; // Violates LSP 21 } 22} 23 24// This breaks when using Square 25public void TestRectangle(Rectangle rect) 26{ 27 rect.Width = 5; 28 rect.Height = 4; 29 Assert.AreEqual(20, rect.GetArea()); // Fails for Square! 30}
The Solution
1public abstract class Shape 2{ 3 public abstract int GetArea(); 4} 5 6public class Rectangle : Shape 7{ 8 public int Width { get; set; } 9 public int Height { get; set; } 10 11 public override int GetArea() => Width * Height; 12} 13 14public class Square : Shape 15{ 16 public int Side { get; set; } 17 18 public override int GetArea() => Side * Side; 19} 20 21// Now both can be used interchangeably 22public void CalculateArea(Shape shape) 23{ 24 Console.WriteLine($"Area: {shape.GetArea()}"); 25} 26 27public void TestShapes() 28{ 29 var shapes = new List<Shape> 30 { 31 new Rectangle { Width = 5, Height = 4 }, 32 new Square { Side = 3 } 33 }; 34 35 foreach (var shape in shapes) 36 CalculateArea(shape); // Works correctly for all shapes 37}
Interface Segregation Principle (ISP)
"Clients should not be forced to depend on interfaces they don't use."
Create specific interfaces rather than one large interface.
The Problem
1public interface IWorker 2{ 3 void Work(); 4 void Eat(); 5 void Sleep(); 6} 7 8public class Robot : IWorker 9{ 10 public void Work() => Console.WriteLine("Robot working..."); 11 12 public void Eat() => throw new NotImplementedException(); // Robots don't eat! 13 public void Sleep() => throw new NotImplementedException(); // Robots don't sleep! 14}
The Solution
1public interface IWorkable 2{ 3 void Work(); 4} 5 6public interface IFeedable 7{ 8 void Eat(); 9} 10 11public interface ISleepable 12{ 13 void Sleep(); 14} 15 16public class Human : IWorkable, IFeedable, ISleepable 17{ 18 public void Work() => Console.WriteLine("Human working..."); 19 public void Eat() => Console.WriteLine("Human eating..."); 20 public void Sleep() => Console.WriteLine("Human sleeping..."); 21} 22 23public class Robot : IWorkable // Only implements what it needs 24{ 25 public void Work() => Console.WriteLine("Robot working..."); 26} 27 28public class WorkManager 29{ 30 public void ManageWork(IWorkable worker) 31 { 32 worker.Work(); 33 } 34 35 public void ManageLunch(IFeedable worker) 36 { 37 worker.Eat(); // Only works with workers that can eat 38 } 39}
Dependency Inversion Principle (DIP)
"High-level modules should not depend on low-level modules. Both should depend on abstractions."
This is particularly important in modern C# applications using dependency injection.
The Problem
1public class EmailService 2{ 3 public void SendEmail(string to, string subject, string body) 4 { 5 // Directly coupled to SMTP 6 var client = new SmtpClient("smtp.gmail.com"); 7 // Send email logic 8 } 9} 10 11public class UserService 12{ 13 private readonly EmailService _emailService; // Tightly coupled 14 15 public UserService() 16 { 17 _emailService = new EmailService(); // Hard to test 18 } 19 20 public async Task RegisterUserAsync(User user) 21 { 22 // Registration logic 23 _emailService.SendEmail(user.Email, "Welcome", "Welcome to our app!"); 24 } 25}
The Solution
1public interface IEmailService 2{ 3 Task SendEmailAsync(string to, string subject, string body); 4} 5 6public class SmtpEmailService : IEmailService 7{ 8 public async Task SendEmailAsync(string to, string subject, string body) 9 { 10 var client = new SmtpClient("smtp.gmail.com"); 11 // SMTP implementation 12 await Task.CompletedTask; 13 } 14} 15 16public class SendGridEmailService : IEmailService 17{ 18 public async Task SendEmailAsync(string to, string subject, string body) 19 { 20 // SendGrid implementation 21 await Task.CompletedTask; 22 } 23} 24 25public class UserService 26{ 27 private readonly IEmailService _emailService; 28 private readonly IUserRepository _userRepository; 29 30 public UserService(IEmailService emailService, IUserRepository userRepository) 31 { 32 _emailService = emailService; 33 _userRepository = userRepository; 34 } 35 36 public async Task RegisterUserAsync(User user) 37 { 38 await _userRepository.SaveAsync(user); 39 await _emailService.SendEmailAsync(user.Email, "Welcome", "Welcome to our app!"); 40 } 41} 42 43// In Program.cs or Startup.cs 44services.AddScoped<IEmailService, SmtpEmailService>(); 45services.AddScoped<IUserRepository, UserRepository>(); 46services.AddScoped<UserService>();
This approach makes the code much more testable and flexible.
SOLID in Modern C# Applications
ASP.NET Core Web API Example
Here's how SOLID principles apply in a typical ASP.NET Core application:
1// Domain model (SRP) 2public class Product 3{ 4 public int Id { get; set; } 5 public string Name { get; set; } 6 public decimal Price { get; set; } 7} 8 9// Repository interface (DIP, ISP) 10public interface IProductRepository 11{ 12 Task<Product> GetByIdAsync(int id); 13 Task<IEnumerable<Product>> GetAllAsync(); 14 Task SaveAsync(Product product); 15} 16 17// Service layer (SRP, DIP) 18public interface IProductService 19{ 20 Task<Product> GetProductAsync(int id); 21 Task<Product> CreateProductAsync(Product product); 22} 23 24public class ProductService : IProductService 25{ 26 private readonly IProductRepository _repository; 27 private readonly ILogger<ProductService> _logger; 28 29 public ProductService(IProductRepository repository, ILogger<ProductService> logger) 30 { 31 _repository = repository; 32 _logger = logger; 33 } 34 35 public async Task<Product> GetProductAsync(int id) 36 { 37 _logger.LogInformation("Fetching product with ID: {ProductId}", id); 38 return await _repository.GetByIdAsync(id); 39 } 40 41 public async Task<Product> CreateProductAsync(Product product) 42 { 43 _logger.LogInformation("Creating new product: {ProductName}", product.Name); 44 await _repository.SaveAsync(product); 45 return product; 46 } 47} 48 49// Controller (SRP - only handles HTTP concerns) 50[ApiController] 51[Route("api/[controller]")] 52public class ProductsController : ControllerBase 53{ 54 private readonly IProductService _productService; 55 56 public ProductsController(IProductService productService) 57 { 58 _productService = productService; 59 } 60 61 [HttpGet("{id}")] 62 public async Task<ActionResult<Product>> GetProduct(int id) 63 { 64 var product = await _productService.GetProductAsync(id); 65 return product == null ? NotFound() : Ok(product); 66 } 67 68 [HttpPost] 69 public async Task<ActionResult<Product>> CreateProduct(Product product) 70 { 71 var createdProduct = await _productService.CreateProductAsync(product); 72 return CreatedAtAction(nameof(GetProduct), 73 new { id = createdProduct.Id }, createdProduct); 74 } 75}
Testing with SOLID Principles
SOLID principles make unit testing much easier:
1[TestClass] 2public class ProductServiceTests 3{ 4 [TestMethod] 5 public async Task GetProductAsync_ValidId_ReturnsProduct() 6 { 7 // Arrange 8 var mockRepository = new Mock<IProductRepository>(); 9 var mockLogger = new Mock<ILogger<ProductService>>(); 10 var expectedProduct = new Product { Id = 1, Name = "Test Product" }; 11 12 mockRepository.Setup(r => r.GetByIdAsync(1)) 13 .ReturnsAsync(expectedProduct); 14 15 var service = new ProductService(mockRepository.Object, mockLogger.Object); 16 17 // Act 18 var result = await service.GetProductAsync(1); 19 20 // Assert 21 Assert.AreEqual(expectedProduct.Name, result.Name); 22 mockRepository.Verify(r => r.GetByIdAsync(1), Times.Once); 23 } 24}
Common Pitfalls and How to Avoid Them
Over-Engineering
Don't create abstractions for everything. Apply SOLID principles where they add value:
1// Don't do this for simple cases 2public interface IStringHelper 3{ 4 string ToUpperCase(string input); 5} 6 7// Just use the string method directly 8public class SomeService 9{ 10 public string ProcessName(string name) 11 { 12 return name.ToUpper(); // Simple operations don't need abstraction 13 } 14}
Premature Abstraction
Wait until you have a real need for flexibility:
1// Don't create this until you actually need multiple implementations 2public interface ICurrentTimeProvider 3{ 4 DateTime Now { get; } 5} 6 7// Start with DateTime.Now and refactor when needed
Ignoring Performance
While SOLID principles improve maintainability, be mindful of performance:
1// For high-performance scenarios, direct access might be better 2public class HighPerformanceCalculator 3{ 4 // Sometimes breaking DIP for performance is acceptable 5 private readonly SpecializedCache _cache = new SpecializedCache(); 6 7 public decimal Calculate(decimal input) 8 { 9 // Direct access for performance-critical paths 10 return _cache.GetOrCalculate(input); 11 } 12}
Key Takeaways
- Start with SRP - It's the foundation of good design
- Use dependency injection - Modern C# makes DIP easy to implement
- Don't over-abstract - Apply principles where they add real value
- Test-first thinking - SOLID principles make testing natural
- Gradual improvement - Refactor existing code incrementally
These principles aren't just academic concepts—they're practical tools that will make your C# code more maintainable, testable, and adaptable to change. Start applying them in your next project, and you'll see the difference they make.
Additional Resources
To further enhance your understanding of SOLID principles and modern C# architecture, I recommend exploring these valuable resources:
Microsoft Official Documentation
Practical Learning Resources
- SOLID Software Design Principles and How Fit in a Microservices Architecture Design
- What are the SOLID Principles in C#? Explained With Code Examples
- Mastering SOLID Principles in C#: A Practical Guide
These resources will help you dive deeper into architectural patterns, see real-world implementations, and understand how SOLID principles fit into modern C# development practices. I particularly recommend starting with Microsoft's official architecture guides and then exploring the community examples to see different implementation approaches.