The SOLID principles are five design principles that make software designs more understandable, flexible, and maintainable. When building .NET Core APIs, following these principles helps create robust, scalable applications that are easy to test and modify. Let's explore each principle with practical examples.
What are the SOLID Principles?
SOLID is an acronym that stands for:
- S - Single Responsibility Principle (SRP)
- O - Open/Closed Principle (OCP)
- L - Liskov Substitution Principle (LSP)
- I - Interface Segregation Principle (ISP)
- D - Dependency Inversion Principle (DIP)
1. Single Responsibility Principle (SRP)
"A class should have only one reason to change."
Each class should have only one job or responsibility. This makes code more maintainable and reduces the impact of changes.
❌ Bad Example (Violating SRP)
public class UserController : ControllerBase { private readonly string _connectionString; public UserController(string connectionString) { _connectionString = connectionString; } [HttpPost] public async Task<IActionResult> CreateUser(CreateUserRequest request) { // Validation logic if (string.IsNullOrEmpty(request.Email) || !request.Email.Contains("@")) return BadRequest("Invalid email"); // Business logic var user = new User { Name = request.Name, Email = request.Email, CreatedAt = DateTime.UtcNow }; // Data access logic using var connection = new SqlConnection(_connectionString); await connection.OpenAsync(); var command = new SqlCommand( "INSERT INTO Users (Name, Email, CreatedAt) VALUES (@name, @email, @createdAt)", connection); command.Parameters.AddWithValue("@name", user.Name); command.Parameters.AddWithValue("@email", user.Email); command.Parameters.AddWithValue("@createdAt", user.CreatedAt); await command.ExecuteNonQueryAsync(); // Email notification logic var emailService = new SmtpClient(); await emailService.SendMailAsync(new MailMessage { To = { user.Email }, Subject = "Welcome!", Body = $"Welcome {user.Name}!" }); return Ok(user); } }
✅ Good Example (Following SRP)
// Controller - handles HTTP requests/responses only [ApiController] [Route("api/[controller]")] public class UsersController : ControllerBase { private readonly IUserService _userService; public UsersController(IUserService userService) { _userService = userService; } [HttpPost] public async Task<IActionResult> CreateUser(CreateUserRequest request) { var result = await _userService.CreateUserAsync(request); if (!result.IsSuccess) return BadRequest(result.ErrorMessage); return Ok(result.User); } } // Service - handles business logic public interface IUserService { Task<UserCreationResult> CreateUserAsync(CreateUserRequest request); } public class UserService : IUserService { private readonly IUserRepository _userRepository; private readonly IEmailService _emailService; private readonly IUserValidator _userValidator; public UserService( IUserRepository userRepository, IEmailService emailService, IUserValidator userValidator) { _userRepository = userRepository; _emailService = emailService; _userValidator = userValidator; } public async Task<UserCreationResult> CreateUserAsync(CreateUserRequest request) { // Validate var validationResult = await _userValidator.ValidateAsync(request); if (!validationResult.IsValid) return UserCreationResult.Failure(validationResult.ErrorMessage); // Create user var user = new User { Name = request.Name, Email = request.Email, CreatedAt = DateTime.UtcNow }; // Save to database await _userRepository.CreateAsync(user); // Send welcome email await _emailService.SendWelcomeEmailAsync(user); return UserCreationResult.Success(user); } } // Repository - handles data access public interface IUserRepository { Task CreateAsync(User user); } // Validator - handles validation logic public interface IUserValidator { Task<ValidationResult> ValidateAsync(CreateUserRequest request); } // Email Service - handles email notifications public interface IEmailService { Task SendWelcomeEmailAsync(User user); }
2. Open/Closed Principle (OCP)
"Software entities should be open for extension but closed for modification."
You should be able to extend a class's behavior without modifying its existing code.
❌ Bad Example (Violating OCP)
public class PaymentProcessor { public async Task ProcessPayment(PaymentRequest request) { if (request.PaymentType == "CreditCard") { // Process credit card payment await ProcessCreditCardPayment(request); } else if (request.PaymentType == "PayPal") { // Process PayPal payment await ProcessPayPalPayment(request); } else if (request.PaymentType == "Bitcoin") { // Process Bitcoin payment await ProcessBitcoinPayment(request); } // Adding new payment method requires modifying this class } private async Task ProcessCreditCardPayment(PaymentRequest request) { /* ... */ } private async Task ProcessPayPalPayment(PaymentRequest request) { /* ... */ } private async Task ProcessBitcoinPayment(PaymentRequest request) { /* ... */ } }
✅ Good Example (Following OCP)
// Abstract base for all payment processors public abstract class PaymentProcessor { public abstract Task<PaymentResult> ProcessAsync(PaymentRequest request); public abstract bool CanProcess(string paymentType); } // Concrete implementations public class CreditCardProcessor : PaymentProcessor { public override bool CanProcess(string paymentType) => paymentType == "CreditCard"; public override async Task<PaymentResult> ProcessAsync(PaymentRequest request) { // Credit card specific logic await Task.Delay(100); // Simulate API call return new PaymentResult { Success = true, TransactionId = Guid.NewGuid().ToString() }; } } public class PayPalProcessor : PaymentProcessor { public override bool CanProcess(string paymentType) => paymentType == "PayPal"; public override async Task<PaymentResult> ProcessAsync(PaymentRequest request) { // PayPal specific logic await Task.Delay(100); // Simulate API call return new PaymentResult { Success = true, TransactionId = Guid.NewGuid().ToString() }; } } // New payment method - no existing code modification needed public class BitcoinProcessor : PaymentProcessor { public override bool CanProcess(string paymentType) => paymentType == "Bitcoin"; public override async Task<PaymentResult> ProcessAsync(PaymentRequest request) { // Bitcoin specific logic await Task.Delay(200); // Simulate blockchain confirmation return new PaymentResult { Success = true, TransactionId = Guid.NewGuid().ToString() }; } } // Payment service that orchestrates payment processing public class PaymentService { private readonly IEnumerable<PaymentProcessor> _processors; public PaymentService(IEnumerable<PaymentProcessor> processors) { _processors = processors; } public async Task<PaymentResult> ProcessPaymentAsync(PaymentRequest request) { var processor = _processors.FirstOrDefault(p => p.CanProcess(request.PaymentType)); if (processor == null) throw new NotSupportedException($"Payment type {request.PaymentType} not supported"); return await processor.ProcessAsync(request); } }
3. Liskov Substitution Principle (LSP)
"Objects of a superclass should be replaceable with objects of its subclasses without breaking the application."
Derived classes must be substitutable for their base classes without altering the correctness of the program.
❌ Bad Example (Violating LSP)
public abstract class Bird { public abstract void Fly(); public abstract void Eat(); } public class Sparrow : Bird { public override void Fly() { Console.WriteLine("Sparrow flying"); } public override void Eat() { Console.WriteLine("Sparrow eating seeds"); } } public class Penguin : Bird { public override void Fly() { // Penguins can't fly! throw new NotSupportedException("Penguins cannot fly"); } public override void Eat() { Console.WriteLine("Penguin eating fish"); } } // This violates LSP because Penguin cannot substitute Bird in all contexts public class BirdService { public void MakeBirdFly(Bird bird) { bird.Fly(); // This will throw exception for Penguin } }
✅ Good Example (Following LSP)
// Base interface with common behavior public interface IAnimal { void Eat(); void Move(); } // Specific interface for flying animals public interface IFlyingAnimal : IAnimal { void Fly(); } // Specific interface for swimming animals public interface ISwimmingAnimal : IAnimal { void Swim(); } public class Sparrow : IFlyingAnimal { public void Eat() => Console.WriteLine("Sparrow eating seeds"); public void Move() => Fly(); public void Fly() => Console.WriteLine("Sparrow flying"); } public class Penguin : ISwimmingAnimal { public void Eat() => Console.WriteLine("Penguin eating fish"); public void Move() => Swim(); public void Swim() => Console.WriteLine("Penguin swimming"); } public class AnimalService { public void FeedAnimal(IAnimal animal) { animal.Eat(); // Works for all animals } public void MakeAnimalMove(IAnimal animal) { animal.Move(); // Each animal moves in its own way } public void MakeFlyingAnimalFly(IFlyingAnimal flyingAnimal) { flyingAnimal.Fly(); // Only works with flying animals } }
4. Interface Segregation Principle (ISP)
"Clients should not be forced to depend on interfaces they don't use."
Create specific interfaces rather than one general-purpose interface.
❌ Bad Example (Violating ISP)
// Fat interface that forces implementations to implement methods they don't need public interface IUserOperations { Task<User> GetByIdAsync(int id); Task<IEnumerable<User>> GetAllAsync(); Task CreateAsync(User user); Task UpdateAsync(User user); Task DeleteAsync(int id); Task<byte[]> ExportToPdfAsync(); Task<byte[]> ExportToExcelAsync(); Task SendEmailAsync(int userId, string subject, string body); Task SendSmsAsync(int userId, string message); } // ReadOnlyUserService is forced to implement methods it doesn't need public class ReadOnlyUserService : IUserOperations { public async Task<User> GetByIdAsync(int id) { /* Implementation */ return null; } public async Task<IEnumerable<User>> GetAllAsync() { /* Implementation */ return null; } // These methods don't make sense for a read-only service public Task CreateAsync(User user) => throw new NotSupportedException(); public Task UpdateAsync(User user) => throw new NotSupportedException(); public Task DeleteAsync(int id) => throw new NotSupportedException(); public Task<byte[]> ExportToPdfAsync() => throw new NotSupportedException(); public Task<byte[]> ExportToExcelAsync() => throw new NotSupportedException(); public Task SendEmailAsync(int userId, string subject, string body) => throw new NotSupportedException(); public Task SendSmsAsync(int userId, string message) => throw new NotSupportedException(); }
✅ Good Example (Following ISP)
// Segregated interfaces public interface IUserReader { Task<User> GetByIdAsync(int id); Task<IEnumerable<User>> GetAllAsync(); } public interface IUserWriter { Task CreateAsync(User user); Task UpdateAsync(User user); Task DeleteAsync(int id); } public interface IUserExporter { Task<byte[]> ExportToPdfAsync(); Task<byte[]> ExportToExcelAsync(); } public interface IUserNotifier { Task SendEmailAsync(int userId, string subject, string body); Task SendSmsAsync(int userId, string message); } // Implementations only implement what they need public class UserRepository : IUserReader, IUserWriter { public async Task<User> GetByIdAsync(int id) { // Implementation return new User(); } public async Task<IEnumerable<User>> GetAllAsync() { // Implementation return new List<User>(); } public async Task CreateAsync(User user) { // Implementation } public async Task UpdateAsync(User user) { // Implementation } public async Task DeleteAsync(int id) { // Implementation } } public class UserReportService : IUserExporter { private readonly IUserReader _userReader; public UserReportService(IUserReader userReader) { _userReader = userReader; } public async Task<byte[]> ExportToPdfAsync() { var users = await _userReader.GetAllAsync(); // Generate PDF return new byte[0]; } public async Task<byte[]> ExportToExcelAsync() { var users = await _userReader.GetAllAsync(); // Generate Excel return new byte[0]; } } public class UserNotificationService : IUserNotifier { private readonly IUserReader _userReader; public UserNotificationService(IUserReader userReader) { _userReader = userReader; } public async Task SendEmailAsync(int userId, string subject, string body) { var user = await _userReader.GetByIdAsync(userId); // Send email } public async Task SendSmsAsync(int userId, string message) { var user = await _userReader.GetByIdAsync(userId); // Send SMS } }
5. Dependency Inversion Principle (DIP)
"High-level modules should not depend on low-level modules. Both should depend on abstractions."
Depend on interfaces/abstractions rather than concrete implementations.
❌ Bad Example (Violating DIP)
// High-level module depending on low-level modules public class OrderService { private readonly SqlServerRepository _repository; // Concrete dependency private readonly SmtpEmailService _emailService; // Concrete dependency private readonly FileLogger _logger; // Concrete dependency public OrderService() { _repository = new SqlServerRepository(); // Hard-coded dependency _emailService = new SmtpEmailService(); // Hard-coded dependency _logger = new FileLogger(); // Hard-coded dependency } public async Task ProcessOrderAsync(Order order) { try { await _repository.SaveOrderAsync(order); await _emailService.SendOrderConfirmationAsync(order); _logger.LogInfo($"Order {order.Id} processed successfully"); } catch (Exception ex) { _logger.LogError($"Error processing order {order.Id}: {ex.Message}"); throw; } } } // Low-level modules public class SqlServerRepository { public async Task SaveOrderAsync(Order order) { /* SQL Server specific code */ } } public class SmtpEmailService { public async Task SendOrderConfirmationAsync(Order order) { /* SMTP specific code */ } } public class FileLogger { public void LogInfo(string message) { /* File logging code */ } public void LogError(string message) { /* File logging code */ } }
✅ Good Example (Following DIP)
// Abstractions public interface IOrderRepository { Task SaveOrderAsync(Order order); } public interface IEmailService { Task SendOrderConfirmationAsync(Order order); } public interface ILogger { void LogInfo(string message); void LogError(string message); } // High-level module depending on abstractions public class OrderService { private readonly IOrderRepository _repository; private readonly IEmailService _emailService; private readonly ILogger _logger; public OrderService( IOrderRepository repository, IEmailService emailService, ILogger logger) { _repository = repository; _emailService = emailService; _logger = logger; } public async Task ProcessOrderAsync(Order order) { try { await _repository.SaveOrderAsync(order); await _emailService.SendOrderConfirmationAsync(order); _logger.LogInfo($"Order {order.Id} processed successfully"); } catch (Exception ex) { _logger.LogError($"Error processing order {order.Id}: {ex.Message}"); throw; } } } // Low-level modules implementing abstractions public class SqlServerOrderRepository : IOrderRepository { private readonly string _connectionString; public SqlServerOrderRepository(string connectionString) { _connectionString = connectionString; } public async Task SaveOrderAsync(Order order) { // SQL Server specific implementation } } public class SmtpEmailService : IEmailService { public async Task SendOrderConfirmationAsync(Order order) { // SMTP specific implementation } } public class FileLogger : ILogger { public void LogInfo(string message) { // File logging implementation } public void LogError(string message) { // File logging implementation } } // Dependency injection configuration in Program.cs public class Program { public static void Main(string[] args) { var builder = WebApplication.CreateBuilder(args); // Register dependencies builder.Services.AddScoped<IOrderRepository, SqlServerOrderRepository>(); builder.Services.AddScoped<IEmailService, SmtpEmailService>(); builder.Services.AddScoped<ILogger, FileLogger>(); builder.Services.AddScoped<OrderService>(); var app = builder.Build(); app.Run(); } }
Top comments (0)