November 23, 2025

Mastering SOLID: The 5 Pillars of Better Code

Mastering the Core of Clean Code Architecture

Mastering SOLID: The 5 Pillars of Better Code

SOLID is an acronym for five fundamental design principles that help developers create software that is easy to maintain, extend, and test. When you understand these, you stop fighting your own code and start building robust architectures.

Here is how to understand them once and for all, with C# examples :)


1. S: Single Responsibility Principle (SRP)

"A class should have only one reason to change."

A class should handle one specific job. If a class handles business logic and file I/O and email notifications, it has too many reasons to change.

❌ The Bad Way

Here, the UserService is doing too much: registering a user AND sending an email. If the email logic changes, we risk breaking the user registration.

csharp
public class UserService { public void Register(string email, string password) { // 1. Validation Logic if (!email.Contains("@")) throw new ValidationException("Invalid email"); // 2. Database Logic var user = new User(email, password); Database.Save(user); // 3. Email Logic (Violation: This belongs elsewhere!) var smtpClient = new SmtpClient("smtp.google.com"); smtpClient.Send("admin@app.com", email, "Welcome!", "Hi there!"); } }

✅ The SOLID Way

We extract the email responsibility to its own class. UserService now only focuses on the user.

csharp
public class EmailService { public void SendWelcomeEmail(string email) { // Email logic isolated here Console.WriteLine($"Sending email to {email}"); } } public class UserService { private readonly EmailService _emailService; public UserService(EmailService emailService) { _emailService = emailService; } public void Register(string email, string password) { // Validation & Database logic... Database.Save(new User(email, password)); // Delegate email responsibility _emailService.SendWelcomeEmail(email); } }

2. O: Open/Closed Principle (OCP)

"Software entities should be open for extension, but closed for modification."

You should be able to add new features (like a new shape or payment method) without rewriting existing, tested code.

❌ The Bad Way

If we want to add a Triangle, we have to modify the AreaCalculator class and add a new case to the switch statement. This risks introducing bugs in existing logic.

csharp
public class AreaCalculator { public double CalculateArea(object shape) { if (shape is Rectangle r) { return r.Width * r.Height; } else if (shape is Circle c) { return Math.PI * c.Radius * c.Radius; } // Every new shape requires modifying this file! return 0; } }

✅ The SOLID Way

We use polymorphism. AreaCalculator doesn't care what shape it is, as long as it implements Shape. We can add a Triangle class later without touching AreaCalculator.

csharp
public abstract class Shape { public abstract double CalculateArea(); } public class Rectangle : Shape { public double Width { get; set; } public double Height { get; set; } public override double CalculateArea() => Width * Height; } public class Circle : Shape { public double Radius { get; set; } public override double CalculateArea() => Math.PI * Radius * Radius; } public class AreaCalculator { // Closed for modification: This method never needs to change again public double CalculateArea(Shape shape) { return shape.CalculateArea(); } }

3. L: Liskov Substitution Principle (LSP)

"Subtypes must be substitutable for their base types."

If code works with a Bird class, it should work with any class that inherits from Bird (like Penguin) without blowing up.

❌ The Bad Way

A Penguin is a Bird, but it can't fly. Throwing an exception violates the expectation of the base class.

csharp
public class Bird { public virtual void Fly() { Console.WriteLine("I am flying!"); } } public class Penguin : Bird { public override void Fly() { // Violation: Breaking the behavior of the parent class throw new NotImplementedException("Penguins can't fly!"); } }

✅ The SOLID Way

Separate the hierarchies. Not all birds fly.

csharp
public abstract class Bird { /* common bird stuff like Eat() */ } public interface IFlyable { void Fly(); } public class Sparrow : Bird, IFlyable { public void Fly() => Console.WriteLine("Flying high!"); } public class Penguin : Bird { // No Fly method here, so no false expectations. public void Swim() => Console.WriteLine("Swimming!"); }

4. I: Interface Segregation Principle (ISP)

"Clients should not be forced to depend on interfaces they do not use."

Don't create massive "God Interfaces." It's better to have three specific interfaces than one general-purpose one.

❌ The Bad Way

We have an IMachine interface. A simple "Old Fashioned Printer" can't scan, but it's forced to implement the Scan method just to satisfy the interface.

csharp
public interface IMachine { void Print(Document d); void Scan(Document d); void Fax(Document d); } public class OldPrinter : IMachine { public void Print(Document d) { /* print */ } // Violation: Forced to implement methods it doesn't need public void Scan(Document d) => throw new NotImplementedException(); public void Fax(Document d) => throw new NotImplementedException(); }

✅ The SOLID Way

Break the interface down. Now OldPrinter only implements what it can actually do.

csharp
public interface IPrinter { void Print(Document d); } public interface IScanner { void Scan(Document d); } public interface IFaxer { void Fax(Document d); } public class OldPrinter : IPrinter { public void Print(Document d) { /* print */ } } public class SuperSmartPhotocopier : IPrinter, IScanner, IFaxer { public void Print(Document d) { /* ... */ } public void Scan(Document d) { /* ... */ } public void Fax(Document d) { /* ... */ } }

5. D: Dependency Inversion Principle (DIP)

"Depend on abstractions, not on concretions."

High-level modules (business logic) should not depend on low-level modules (database connections, file writers). Both should depend on interfaces.

❌ The Bad Way

The NotificationService is tightly coupled to EmailSender. If we want to switch to SMS, we have to rewrite the NotificationService.

csharp
public class EmailSender { public void Send(string message) => Console.WriteLine($"Email: {message}"); } public class NotificationService { private EmailSender _sender; public NotificationService() { // Violation: Directly creating a dependency (tight coupling) _sender = new EmailSender(); } public void Notify(string message) { _sender.Send(message); } }

✅ The SOLID Way

We introduce an interface IMessageSender. The NotificationService doesn't care if it's Email or SMS; it just knows it can SendMessage(). This is typically wired up using Dependency Injection.

csharp
// 1. The Abstraction public interface IMessageSender { void SendMessage(string message); } // 2. The Low-Level Details public class EmailSender : IMessageSender { public void SendMessage(string message) => Console.WriteLine($"Email: {message}"); } public class SmsSender : IMessageSender { public void SendMessage(string message) => Console.WriteLine($"SMS: {message}"); } // 3. The High-Level Module public class NotificationService { private readonly IMessageSender _sender; // We inject the interface via the constructor public NotificationService(IMessageSender sender) { _sender = sender; } public void Notify(string message) { _sender.SendMessage(message); } }

Summary Table

PrincipleMain IdeaC# Keyword to remember
SRPOne class, one job.N/A (Organizational)
OCPAdd new code, don't change old code.abstract, override
LSPParent class features must work in Child class.Inheritance (:)
ISPSmall interfaces > Big interfaces.interface
DIPUse interfaces to decouple classes.interface, Constructor Injection