S.O.L.I.D.: The 5 Commandments of Clean C# Code¶
Every codebase starts clean. The first file is beautiful. The architecture diagram looks like it belongs in a museum. Then the deadline hits, feature requests multiply like rabbits, and suddenly your UserService.cs is 3,000 lines long, does everything from authentication to sending birthday emails, and nobody dares touch it because the last person who tried is still on therapy leave.
Sound familiar? Yeah, we have all been there.
The SOLID principles are five guidelines that help you avoid this slow descent into madness. They were coined by Robert C. Martin (a.k.a. Uncle Bob) in the early 2000s, and they have become the bedrock of professional software development. Whether you are building a weekend project or architecting the next enterprise monolith, SOLID will help you write code that does not make future-you want to throw your laptop out the window.
Let’s break them down, one by one, with real C# code showing both the “please don’t do this” version and the “ah, much better” version.
S: Single Responsibility Principle (SRP)¶
The Rule¶
A class should have one, and only one, reason to change.
In plain English: each class should do one thing, and it should do it well. If you find yourself saying “this class handles users AND sends emails AND generates reports AND makes coffee,” you have a problem.
Why Should You Care?¶
When a class juggles multiple jobs, it becomes:
- Hard to test: You need to set up unrelated dependencies just to test one behavior.
- Hard to maintain: A change in one area can introduce bugs in another.
- Hard to understand: New developers stare at it like a deer in headlights.
- Hard to reuse: You cannot reuse one piece of functionality without dragging along everything else.
Think of It This Way¶
Imagine a Swiss Army knife versus specialized kitchen tools. A Swiss Army knife can do many things, but none of them particularly well. A chef uses a dedicated paring knife, a bread knife, and a chef’s knife, each excels at its specific task. SRP tells you to build chef’s knives, not Swiss Army knives.
Bad Example: The “God Class” That Does Everything¶
public class Employee
{
public string Name { get; set; }
public decimal Salary { get; set; }
// Responsibility 1: Business logic
public decimal CalculateBonus()
{
return Salary * 0.1m;
}
// Responsibility 2: Persistence
public void SaveToDatabase()
{
using var connection = new SqlConnection("Server=.;Database=HR;");
connection.Open();
var command = new SqlCommand(
$"INSERT INTO Employees (Name, Salary) VALUES ('{Name}', {Salary})",
connection);
command.ExecuteNonQuery();
}
// Responsibility 3: Presentation / Reporting
public string GenerateReport()
{
return $"Employee Report\n" +
$"Name: {Name}\n" +
$"Salary: {Salary:C}\n" +
$"Bonus: {CalculateBonus():C}";
}
// Responsibility 4: Notification
public void SendEmail(string message)
{
var client = new SmtpClient("smtp.company.com");
client.Send("hr@company.com", "employee@company.com", "Update", message);
}
}
This Employee class has four reasons to change: business rules, database schema, report format, and email infrastructure. Change your database provider? You are editing the same class that calculates salaries. That is like asking your dentist to also fix your plumbing, technically possible, probably a bad idea.
Good Example: Each Class Has One Job¶
// Responsibility: Domain data and business logic only
public class Employee
{
public string Name { get; set; }
public decimal Salary { get; set; }
public decimal CalculateBonus()
{
return Salary * 0.1m;
}
}
// Responsibility: Data persistence
public class EmployeeRepository
{
private readonly string _connectionString;
public EmployeeRepository(string connectionString)
{
_connectionString = connectionString;
}
public void Save(Employee employee)
{
using var connection = new SqlConnection(_connectionString);
connection.Open();
using var command = new SqlCommand(
"INSERT INTO Employees (Name, Salary) VALUES (@Name, @Salary)",
connection);
command.Parameters.AddWithValue("@Name", employee.Name);
command.Parameters.AddWithValue("@Salary", employee.Salary);
command.ExecuteNonQuery();
}
}
// Responsibility: Report generation
public class EmployeeReportGenerator
{
public string Generate(Employee employee)
{
return $"Employee Report\n" +
$"Name: {employee.Name}\n" +
$"Salary: {employee.Salary:C}\n" +
$"Bonus: {employee.CalculateBonus():C}";
}
}
// Responsibility: Notifications
public class EmailService
{
private readonly SmtpClient _client;
public EmailService(SmtpClient client)
{
_client = client;
}
public void Send(string to, string subject, string body)
{
_client.Send("hr@company.com", to, subject, body);
}
}
Now each class has exactly one reason to change. If the database schema changes, only EmployeeRepository is affected. If the report format changes, only EmployeeReportGenerator is modified. Each class is independently testable and reusable. Beautiful, isn’t it?
O: Open/Closed Principle (OCP)¶
The Rule¶
Software entities (classes, modules, functions) should be open for extension but closed for modification.
Translation: you should be able to add new behavior to a system without changing existing, tested code. Every time you crack open a working class to shoehorn in a new feature, you risk introducing regressions. And regressions are like uninvited guests at a party, they always show up at the worst time.
Think of It This Way¶
Consider a power strip. You can plug in any device (a lamp, a phone charger, a laptop) without rewiring the strip itself. The power strip is closed for modification (you do not open it up and solder new outlets) but open for extension (any new device with a standard plug can use it).
Bad Example: The Ever-Growing If/Else Chain¶
public class AreaCalculator
{
public double CalculateArea(object shape)
{
if (shape is Rectangle rect)
{
return rect.Width * rect.Height;
}
else if (shape is Circle circle)
{
return Math.PI * circle.Radius * circle.Radius;
}
// Every new shape requires modifying this method!
else if (shape is Triangle triangle)
{
return 0.5 * triangle.Base * triangle.Height;
}
throw new ArgumentException("Unknown shape");
}
}
Every time a new shape is added (hexagon, ellipse, trapezoid…), you must open this class and add another else if branch. Before you know it, this method is 200 lines long and nobody wants to touch it.
Good Example: Extend Without Modifying¶
public interface IShape
{
double CalculateArea();
}
public class Rectangle : IShape
{
public double Width { get; set; }
public double Height { get; set; }
public double CalculateArea() => Width * Height;
}
public class Circle : IShape
{
public double Radius { get; set; }
public double CalculateArea() => Math.PI * Radius * Radius;
}
public class Triangle : IShape
{
public double Base { get; set; }
public double Height { get; set; }
public double CalculateArea() => 0.5 * Base * Height;
}
// This class NEVER needs to change when new shapes are added
public class AreaCalculator
{
public double CalculateTotalArea(IEnumerable<IShape> shapes)
{
return shapes.Sum(s => s.CalculateArea());
}
}
// Adding a new shape is pure extension -- no existing code is modified
public class Hexagon : IShape
{
public double Side { get; set; }
public double CalculateArea() => (3 * Math.Sqrt(3) / 2) * Side * Side;
}
Now AreaCalculator is closed for modification, it works with any IShape. Adding a hexagon? Just create a new class. The existing, battle-tested code stays untouched. Your future self just sent you a thank-you card.
L: Liskov Substitution Principle (LSP)¶
The Rule¶
Objects of a superclass should be replaceable with objects of a subclass without altering the correctness of the program.
In other words: if class B is a subtype of class A, then you should be able to use B anywhere A is expected without any surprises. If swapping in a subclass causes weird side effects or broken behavior, your inheritance hierarchy is lying to you.
Why This One Bites Hard¶
LSP violations lead to subtle, “works on my machine,” hair-pulling bugs. Code that works perfectly with a base class suddenly explodes when a derived class is substituted in. This defeats the entire purpose of polymorphism, which is, you know, the whole point of OOP.
Think of It This Way¶
Imagine you hire a “driver.” You expect anyone with a driver’s license to operate your car. If you hire someone whose license only covers motorcycles and they cannot drive your car, the substitution fails, they are not a valid “driver” in the context you need. LSP says: if you claim to be a driver, you must be able to do everything a driver is expected to do. No excuses.
Bad Example: The Classic Rectangle/Square Trap¶
public class Rectangle
{
public virtual double Width { get; set; }
public virtual double Height { get; set; }
public double GetArea() => Width * Height;
}
public class Square : Rectangle
{
// A square enforces equal sides, overriding setters
public override double Width
{
get => base.Width;
set
{
base.Width = value;
base.Height = value; // Side effect!
}
}
public override double Height
{
get => base.Height;
set
{
base.Height = value;
base.Width = value; // Side effect!
}
}
}
// This method works with Rectangle but BREAKS with Square
public void TestArea(Rectangle rect)
{
rect.Width = 5;
rect.Height = 4;
// We expect area = 20 for any Rectangle
Debug.Assert(rect.GetArea() == 20);
// FAILS for Square! Area is 16 because setting Height also changed Width to 4
}
When you pass a Square to code expecting a Rectangle, the behavior changes behind your back. Setting Height silently changes Width. This is the kind of bug that makes you question your career choices at 11 PM on a Friday.
Good Example: Honest Abstraction¶
public interface IShape
{
double GetArea();
}
public class Rectangle : IShape
{
public double Width { get; set; }
public double Height { get; set; }
public double GetArea() => Width * Height;
}
public class Square : IShape
{
public double Side { get; set; }
public double GetArea() => Side * Side;
}
// Both can be used interchangeably via IShape without surprises
public void PrintArea(IShape shape)
{
Console.WriteLine($"Area: {shape.GetArea()}");
// Works correctly for both Rectangle and Square
}
Now Rectangle and Square are siblings under a shared interface rather than having a lying inheritance relationship. Each type fully satisfies the IShape contract. No surprises, no late-night debugging sessions.
I: Interface Segregation Principle (ISP)¶
The Rule¶
Clients should not be forced to depend on interfaces they do not use.
Rather than creating one massive “god” interface that tries to be everything to everyone, break it into smaller, focused interfaces so that implementing classes only provide what is actually relevant to them.
Think of It This Way¶
Imagine a restaurant menu that forces you to order an appetizer, main course, dessert, AND a drink, even if you just want a coffee. That is a fat interface. ISP says: offer separate menus (drinks menu, dessert menu, etc.) so customers only interact with what they need. Nobody should be forced to deal with a five-course meal when they just want caffeine.
Bad Example: The “Everything and the Kitchen Sink” Interface¶
public interface IWorker
{
void Work();
void Eat();
void Sleep();
void AttendMeeting();
void WriteReport();
}
// A human worker can do all of these
public class HumanWorker : IWorker
{
public void Work() => Console.WriteLine("Working...");
public void Eat() => Console.WriteLine("Eating lunch...");
public void Sleep() => Console.WriteLine("Sleeping...");
public void AttendMeeting() => Console.WriteLine("In a meeting...");
public void WriteReport() => Console.WriteLine("Writing report...");
}
// A robot worker cannot eat or sleep!
public class RobotWorker : IWorker
{
public void Work() => Console.WriteLine("Working...");
public void Eat() => throw new NotSupportedException("Robots don't eat!");
public void Sleep() => throw new NotSupportedException("Robots don't sleep!");
public void AttendMeeting() => Console.WriteLine("Joining virtual meeting...");
public void WriteReport() => Console.WriteLine("Generating report...");
}
RobotWorker is forced to implement Eat() and Sleep() even though they make zero sense. The result? Runtime exceptions that should have been caught at compile time. It’s like making a fish implement a Climb() method, technically possible, but deeply wrong.
Good Example: Small, Focused Interfaces¶
public interface IWorkable
{
void Work();
}
public interface IFeedable
{
void Eat();
}
public interface ISleepable
{
void Sleep();
}
public interface ICollaborator
{
void AttendMeeting();
void WriteReport();
}
// Human implements all relevant interfaces
public class HumanWorker : IWorkable, IFeedable, ISleepable, ICollaborator
{
public void Work() => Console.WriteLine("Working...");
public void Eat() => Console.WriteLine("Eating lunch...");
public void Sleep() => Console.WriteLine("Sleeping...");
public void AttendMeeting() => Console.WriteLine("In a meeting...");
public void WriteReport() => Console.WriteLine("Writing report...");
}
// Robot only implements what makes sense
public class RobotWorker : IWorkable, ICollaborator
{
public void Work() => Console.WriteLine("Working...");
public void AttendMeeting() => Console.WriteLine("Joining virtual meeting...");
public void WriteReport() => Console.WriteLine("Generating report...");
}
// Methods only depend on what they actually need
public class LunchScheduler
{
public void ScheduleLunch(IFeedable worker)
{
worker.Eat(); // Only feedable workers can be passed here
}
}
Now RobotWorker is not forced to implement irrelevant methods. The LunchScheduler only accepts IFeedable entities, so a robot can never accidentally be passed to it, the compiler catches the mistake, not your production error logs at 3 AM.
Why was the interface feeling bloated? Because it hadn’t been segregated in years!
D: Dependency Inversion Principle (DIP)¶
The Rule¶
High-level modules should not depend on low-level modules. Both should depend on abstractions. Abstractions should not depend on details. Details should depend on abstractions.
That sounds fancy, but here is the simple version: don’t let your business logic directly know about (or create) specific implementations. Instead, depend on interfaces. Let someone else (usually a DI container) decide which concrete class to plug in.
Think of It This Way¶
Think of an electrical outlet standard. Your laptop does not care whether the electricity comes from a coal plant, a wind farm, or solar panels. It depends on the abstraction (the outlet standard), not the detail (the power source). You can switch power providers without replacing your laptop. That is Dependency Inversion in action.
Bad Example: Hardwired Dependencies¶
// Low-level module
public class SqlServerDatabase
{
public void Save(string data)
{
Console.WriteLine($"Saving '{data}' to SQL Server...");
}
}
// High-level module directly depends on the low-level module
public class OrderService
{
private readonly SqlServerDatabase _database = new SqlServerDatabase();
public void PlaceOrder(string orderData)
{
// Business logic
Console.WriteLine("Processing order...");
// Directly tied to SQL Server -- cannot switch to MongoDB,
// cannot test without a real database
_database.Save(orderData);
}
}
OrderService directly creates and depends on SqlServerDatabase. Want to test OrderService? Better spin up a SQL Server instance. Want to switch to PostgreSQL? Time to modify your business logic class. This is like your car engine being welded to one specific brand of fuel pump, good luck swapping anything out.
Good Example: Depending on Abstractions¶
// The abstraction -- both high and low-level modules depend on this
public interface IDatabase
{
void Save(string data);
}
// Low-level module implements the abstraction
public class SqlServerDatabase : IDatabase
{
public void Save(string data)
{
Console.WriteLine($"Saving '{data}' to SQL Server...");
}
}
public class MongoDatabase : IDatabase
{
public void Save(string data)
{
Console.WriteLine($"Saving '{data}' to MongoDB...");
}
}
// High-level module depends on the abstraction, not the detail
public class OrderService
{
private readonly IDatabase _database;
// Dependency is injected through the constructor
public OrderService(IDatabase database)
{
_database = database;
}
public void PlaceOrder(string orderData)
{
Console.WriteLine("Processing order...");
_database.Save(orderData);
}
}
// Usage with Dependency Injection
// In production:
var service = new OrderService(new SqlServerDatabase());
service.PlaceOrder("Order #1234");
// Easily switch implementations:
var mongoService = new OrderService(new MongoDatabase());
mongoService.PlaceOrder("Order #5678");
// In unit tests -- use a mock:
public class MockDatabase : IDatabase
{
public List<string> SavedData { get; } = new();
public void Save(string data) => SavedData.Add(data);
}
[Test]
public void PlaceOrder_ShouldSaveOrderData()
{
var mockDb = new MockDatabase();
var service = new OrderService(mockDb);
service.PlaceOrder("Test Order");
Assert.That(mockDb.SavedData, Contains.Item("Test Order"));
}
Now OrderService depends on the IDatabase interface, not any specific implementation. Swap databases without touching business logic. Test with a mock in milliseconds. Your code is flexible, testable, and your coworkers will actually enjoy reviewing your PRs.
The Cheat Sheet¶
Here is the whole thing on one table, so you can pin it above your monitor:
| Principle | Key Idea | Violation Symptom | Solution |
|---|---|---|---|
| S - Single Responsibility | One class, one reason to change | God classes doing everything | Split into focused classes |
| O - Open/Closed | Extend behavior without modifying existing code | Growing if/else or switch chains | Use interfaces and polymorphism |
| L - Liskov Substitution | Subtypes must honor the base type’s contract | Overrides that throw NotSupportedException or change expected behavior | Favor composition; redesign the hierarchy |
| I - Interface Segregation | No client should depend on methods it does not use | Classes with empty or exception-throwing method stubs | Break fat interfaces into small, focused ones |
| D - Dependency Inversion | Depend on abstractions, not concretions | new keyword scattered through business logic; untestable classes | Constructor injection; depend on interfaces |
Final Thoughts¶
Here is the thing: SOLID principles are not rigid laws, they are guidelines. Applying them blindly to every class in a trivial application is like using a fire truck to water your houseplants. Technically effective, wildly overkill.
The goal is to develop a nose for code smells. When a class is becoming hard to change, test, or understand, reach for the right principle. Over time, writing SOLID code becomes second nature. Your future self (and your teammates) will thank you. Probably with coffee. Hopefully with a raise.
“The only way to go fast is to go well.” - Robert C. Martin
And remember: friends don’t let friends write God classes.