SOLID Principles: The Foundation of Clean Code
Understanding the five principles that make your code maintainable and scalable
👋 Hey, I'm Ankit Bajpai!
Software engineer building scalable systems and exploring the intersection of clean code, architecture, and AI.
🎯 What I Write About
I document my learning journey through practical, code-heavy articles on:
- .NET & ASP.NET Core - Modern web development with C#
- System Design - LLD patterns, HLD architectures, and scalability
- Clean Code & Architecture - SOLID, design patterns, and maintainable systems
- Gen AI for Developers - LLMs, RAG, and AI-powered development
💡 My Approach
Learn → Build → Document → Share
I learn from books, courses, and real projects, then break down complex concepts into digestible tutorials with working code examples. Think of this as my public learning journal that helps you skip the confusion I faced.
🚀 Currently Exploring
- Clean Architecture in .NET
- LLM integration with Semantic Kernel
- System design interview patterns
- Microservices and event-driven architecture
📬 Let's Connect
Subscribe for weekly deep dives into .NET, system design, and modern software engineering. No fluff—just practical knowledge you can apply immediately.
Happy coding! 🎉
Introduction
When I started my software development journey, I wrote code that "worked" but was painful to maintain. Every new feature felt like defusing a bomb—one wrong move and the entire application could break. Then I discovered SOLID principles, and everything changed.
SOLID isn't just an acronym; it's a mindset shift that transforms how you design classes and systems. In this article, I'll break down each principle with practical C# examples that you can apply immediately.
The Problem: Why Bad Code Happens
Before diving into SOLID, let's understand what happens without it:
public class UserService
{
public void RegisterUser(string email, string password)
{
// Validate email
if (!email.Contains("@")) throw new Exception("Invalid email");
// Hash password
var hashedPassword = BCrypt.HashPassword(password);
// Save to database
using var conn = new SqlConnection("connection_string");
conn.Open();
var cmd = new SqlCommand("INSERT INTO Users...", conn);
cmd.ExecuteNonQuery();
// Send welcome email
var smtp = new SmtpClient("smtp.gmail.com");
smtp.Send(new MailMessage("noreply@app.com", email, "Welcome!", "..."));
// Log activity
File.AppendAllText("log.txt", $"User {email} registered at {DateTime.Now}");
}
}
Problems:
Hard to test (database, email, file system dependencies)
Hard to maintain (one method does everything)
Hard to extend (want to add SMS notifications? Modify this method)
Hard to reuse (validation logic is trapped inside)
SOLID principles solve these exact problems.
Understanding SOLID
SOLID stands for:
Single Responsibility Principle (SRP)
Open/Closed Principle (OCP)
Liskov Substitution Principle (LSP)
Interface Segregation Principle (ISP)
Dependency Inversion Principle (DIP)
Let's explore each one.
1. Single Responsibility Principle (SRP)
"A class should have only one reason to change."
Before SRP (Bad)
public class Invoice
{
public decimal Total { get; set; }
public List<InvoiceItem> Items { get; set; }
public decimal CalculateTotal()
{
return Items.Sum(x => x.Price * x.Quantity);
}
public void SaveToDatabase()
{
// Database logic
}
public void GeneratePDF()
{
// PDF generation logic
}
public void SendEmail()
{
// Email logic
}
}
Problem: Invoice has 4 responsibilities! Changes to PDF format, email templates, or database schema all affect this class.
After SRP (Good)
// Responsibility 1: Business logic
public class Invoice
{
public decimal Total { get; set; }
public List<InvoiceItem> Items { get; set; }
public decimal CalculateTotal()
{
return Items.Sum(x => x.Price * x.Quantity);
}
}
// Responsibility 2: Persistence
public class InvoiceRepository
{
public void Save(Invoice invoice)
{
// Database logic
}
}
// Responsibility 3: PDF generation
public class InvoicePdfGenerator
{
public byte[] Generate(Invoice invoice)
{
// PDF generation logic
}
}
// Responsibility 4: Notification
public class InvoiceEmailService
{
public void Send(Invoice invoice, string email)
{
// Email logic
}
}
Benefits:
Each class has one reason to change
Easy to test in isolation
Easy to reuse (want to generate PDF for other entities? Reuse the pattern)
2. Open/Closed Principle (OCP)
"Software entities should be open for extension but closed for modification."
Before OCP (Bad)
public class DiscountCalculator
{
public decimal CalculateDiscount(string customerType, decimal amount)
{
if (customerType == "Regular")
return amount * 0.05m;
else if (customerType == "Premium")
return amount * 0.10m;
else if (customerType == "VIP")
return amount * 0.20m;
return 0;
}
}
Problem: Every new customer type requires modifying this class (violates "closed for modification").
After OCP (Good)
public interface IDiscountStrategy
{
decimal CalculateDiscount(decimal amount);
}
public class RegularCustomerDiscount : IDiscountStrategy
{
public decimal CalculateDiscount(decimal amount) => amount * 0.05m;
}
public class PremiumCustomerDiscount : IDiscountStrategy
{
public decimal CalculateDiscount(decimal amount) => amount * 0.10m;
}
public class VIPCustomerDiscount : IDiscountStrategy
{
public decimal CalculateDiscount(decimal amount) => amount * 0.20m;
}
public class DiscountCalculator
{
private readonly IDiscountStrategy _strategy;
public DiscountCalculator(IDiscountStrategy strategy)
{
_strategy = strategy;
}
public decimal Calculate(decimal amount)
{
return _strategy.CalculateDiscount(amount);
}
}
Benefits:
Add new discount types without modifying existing code
Strategy pattern makes it extensible
Easy to test each strategy independently
3. Liskov Substitution Principle (LSP)
"Derived classes must be substitutable for their base classes."
Before LSP (Bad)
public class Bird
{
public virtual void Fly()
{
Console.WriteLine("Flying...");
}
}
public class Penguin : Bird
{
public override void Fly()
{
throw new NotImplementedException("Penguins can't fly!");
}
}
// Usage breaks LSP
Bird bird = new Penguin();
bird.Fly(); // 💥 Exception! Penguin violates LSP
After LSP (Good)
public abstract class Bird
{
public abstract void Move();
}
public class FlyingBird : Bird
{
public override void Move()
{
Console.WriteLine("Flying...");
}
}
public class Penguin : Bird
{
public override void Move()
{
Console.WriteLine("Swimming...");
}
}
// Usage works correctly
Bird sparrow = new FlyingBird();
Bird penguin = new Penguin();
sparrow.Move(); // ✅ Flying...
penguin.Move(); // ✅ Swimming...
Benefits:
Inheritance hierarchies make sense
No unexpected exceptions
Polymorphism works correctly
4. Interface Segregation Principle (ISP)
"Clients should not be forced to depend on interfaces they don't use."
Before ISP (Bad)
public interface IWorker
{
void Work();
void Eat();
void Sleep();
}
public class Robot : IWorker
{
public void Work() { /* ... */ }
public void Eat() { throw new NotImplementedException(); } // ❌
public void Sleep() { throw new NotImplementedException(); } // ❌
}
After ISP (Good)
public interface IWorkable
{
void Work();
}
public interface IFeedable
{
void Eat();
}
public interface ISleepable
{
void Sleep();
}
public class Human : IWorkable, IFeedable, ISleepable
{
public void Work() { /* ... */ }
public void Eat() { /* ... */ }
public void Sleep() { /* ... */ }
}
public class Robot : IWorkable
{
public void Work() { /* ... */ }
}
Benefits:
Smaller, focused interfaces
Classes implement only what they need
No "fat" interfaces
5. Dependency Inversion Principle (DIP)
"Depend on abstractions, not concretions."
Before DIP (Bad)
public class EmailService
{
public void SendEmail(string to, string message)
{
// Send email
}
}
public class NotificationService
{
private EmailService _emailService = new EmailService(); // ❌ Tight coupling
public void Notify(string message)
{
_emailService.SendEmail("user@example.com", message);
}
}
After DIP (Good)
public interface INotificationSender
{
void Send(string to, string message);
}
public class EmailService : INotificationSender
{
public void Send(string to, string message)
{
// Send email
}
}
public class SmsService : INotificationSender
{
public void Send(string to, string message)
{
// Send SMS
}
}
public class NotificationService
{
private readonly INotificationSender _sender;
public NotificationService(INotificationSender sender)
{
_sender = sender; // ✅ Depends on abstraction
}
public void Notify(string to, string message)
{
_sender.Send(to, message);
}
}
// Usage with Dependency Injection
var emailNotifier = new NotificationService(new EmailService());
var smsNotifier = new NotificationService(new SmsService());
Benefits:
Loose coupling
Easy to swap implementations
Testable (inject mocks)
Foundation for Dependency Injection
Real-World Example: Refactoring UserService
Let's refactor our initial UserService using SOLID:
// SRP: Separate concerns
public interface IEmailValidator
{
bool IsValid(string email);
}
public interface IPasswordHasher
{
string Hash(string password);
}
public interface IUserRepository
{
void Save(User user);
}
public interface INotificationService
{
void SendWelcomeEmail(string email);
}
public interface ILogger
{
void Log(string message);
}
// OCP & DIP: Depend on abstractions
public class UserRegistrationService
{
private readonly IEmailValidator _emailValidator;
private readonly IPasswordHasher _passwordHasher;
private readonly IUserRepository _userRepository;
private readonly INotificationService _notificationService;
private readonly ILogger _logger;
public UserRegistrationService(
IEmailValidator emailValidator,
IPasswordHasher passwordHasher,
IUserRepository userRepository,
INotificationService notificationService,
ILogger logger)
{
_emailValidator = emailValidator;
_passwordHasher = passwordHasher;
_userRepository = userRepository;
_notificationService = notificationService;
_logger = logger;
}
public void Register(string email, string password)
{
if (!_emailValidator.IsValid(email))
throw new ArgumentException("Invalid email");
var hashedPassword = _passwordHasher.Hash(password);
var user = new User { Email = email, PasswordHash = hashedPassword };
_userRepository.Save(user);
_notificationService.SendWelcomeEmail(email);
_logger.Log($"User {email} registered");
}
}
Now you can:
Unit test without database/email/file system
Swap implementations (SQL → NoSQL, Email → SMS)
Extend without modifying core logic
Key Takeaways
SRP: One class, one responsibility
OCP: Extend behavior without modifying code
LSP: Subtypes must be substitutable for base types
ISP: Small, focused interfaces
DIP: Depend on abstractions, inject dependencies
Pro Tip: Apply SOLID when refactoring, not necessarily on first pass. Get it working, then make it clean.
Practice Exercise
Refactor this code using SOLID principles:
public class OrderProcessor
{
public void ProcessOrder(Order order)
{
// Validate order
if (order.Items.Count == 0) throw new Exception("Empty order");
// Calculate total
var total = order.Items.Sum(x => x.Price);
// Charge credit card
var cardProcessor = new CreditCardProcessor();
cardProcessor.Charge(order.CardNumber, total);
// Update inventory
var db = new SqlConnection("...");
// ...update database
// Send confirmation email
var smtp = new SmtpClient();
smtp.Send(new MailMessage("..."));
}
}
Hint: Identify responsibilities, create interfaces, inject dependencies.
Resources
Pluralsight: SOLID Principles of Object-Oriented Design
Microsoft Docs: Design Principles
GitHub: SOLID Examples in C#
What's Next?
In the next article, we'll dive deeper into Dependency Injection in ASP.NET Core and see how the framework applies SOLID principles automatically.
Question: Which SOLID principle do you find hardest to apply? Let me know in the comments!
Follow me for more clean code tips and .NET best practices. Happy coding! 🚀