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…
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();
}
}