Skip to main content

Command Palette

Search for a command to run...

🧩 Mastering SOLID Principles in C#: A Developer’s Guide to Writing Clean and Scalable Code

Published
6 min read
🧩 Mastering SOLID Principles in C#: A Developer’s Guide to Writing Clean and Scalable Code
N

As a Senior Full-Stack Software Engineer with over 9 years of experience, I specialize in designing and delivering robust, scalable, and efficient software solutions. My expertise spans the full software development lifecycle—from requirements analysis and architecture to implementation, optimization, and maintenance. With a strong foundation in C#, .NET Core, Angular, and SQL, I excel at building high-performance applications that drive business growth. My technical background also includes JavaScript, CSS, Entity Framework, SQL Server, and Azure, enabling me to develop end-to-end solutions that seamlessly integrate with enterprise systems. I am passionate about problem-solving, continuous improvement, and user-centric design. I thrive on translating complex business needs into intuitive and reliable applications. Collaboration is central to my work ethic; I enjoy partnering with cross-functional teams to innovate, optimize workflows, and achieve measurable results. Whether it’s streamlining backend processes, developing dynamic web applications, or integrating cloud and third-party services, I bring a detail-oriented, results-driven mindset to every project. My goal is to contribute to impactful initiatives that not only meet expectations but consistently exceed them.

💡 Ever looked at a class and wondered why one small change breaks ten other things?
That’s usually a sign your code violates one of the SOLID principles — the five key guidelines that help developers build robust, maintainable, and testable software.

In this article, we’ll break down each principle with fresh, easy-to-grasp C# examples and discuss how to refactor code for clarity and flexibility.


1️⃣ Single Responsibility Principle (SRP)

“A class should have only one reason to change.”

Each class should handle one clearly defined responsibility. When validation, data persistence, and communication logic all sit together, any change in one area risks breaking another.

❌ Before – One Class Doing Everything

public class UserManager
{
    public void Register(string email, string password)
    {
        if (!email.Contains("@"))
            throw new Exception("Invalid email");

        var hash = BCrypt.Net.BCrypt.HashPassword(password);

        using (var connection = new SqlConnection("connectionString"))
        {
            // Save user details
        }

        var smtp = new SmtpClient("smtp.server.com");
        smtp.Send("noreply@system.com", email, "Welcome!", "Thanks for joining!");
    }
}

This single class handles validation, hashing, database operations, and email sending. That’s four separate reasons to change.

✅ After – Clear Separation of Concerns

public class EmailValidator
{
    public void Validate(string email)
    {
        if (!email.Contains("@"))
            throw new Exception("Invalid email");
    }
}

public class PasswordHasher
{
    public string Hash(string password) =>
        BCrypt.Net.BCrypt.HashPassword(password);
}

public class UserRepository
{
    public void Save(string email, string hash)
    {
        // Database code...
    }
}

public class NotificationService
{
    public void SendWelcomeEmail(string email)
    {
        // Email logic...
    }
}

public class UserManager
{
    private readonly EmailValidator _validator = new();
    private readonly PasswordHasher _hasher = new();
    private readonly UserRepository _repo = new();
    private readonly NotificationService _notifier = new();

    public void Register(string email, string password)
    {
        _validator.Validate(email);
        var hash = _hasher.Hash(password);
        _repo.Save(email, hash);
        _notifier.SendWelcomeEmail(email);
    }
}

✅ Each class now has a single, clear purpose — making your code easier to test, modify, and extend.


2️⃣ Open/Closed Principle (OCP)

“Open for extension, closed for modification.”

When new behavior is needed, we should be able to extend the code rather than modify what’s already working.

❌ Before – Modifications Everywhere

public class PaymentProcessor
{
    public double CalculateFee(string type, double amount)
    {
        if (type == "CreditCard") return amount * 0.02;
        if (type == "PayPal") return amount * 0.03;
        return 0;
    }
}

Each time we add a new payment type, we must edit the class — introducing risk.

✅ After – Using Abstraction for Extensibility

public interface IPaymentMethod
{
    double CalculateFee(double amount);
}

public class CreditCardPayment : IPaymentMethod
{
    public double CalculateFee(double amount) => amount * 0.02;
}

public class PayPalPayment : IPaymentMethod
{
    public double CalculateFee(double amount) => amount * 0.03;
}

public class PaymentProcessor
{
    public double Process(IPaymentMethod payment, double amount)
    {
        return payment.CalculateFee(amount);
    }
}

✅ New payment methods can be added without changing the existing logic — just implement IPaymentMethod.


3️⃣ Liskov Substitution Principle (LSP)

“Objects of a derived class should be usable in place of their base class without altering behavior.”

If a subclass breaks the expectations of its base class, inheritance has been misused.

❌ Before – Violating Behavior

public class Document
{
    public virtual void Print() => Console.WriteLine("Printing document...");
}

public class DigitalDocument : Document
{
    public override void Print()
    {
        throw new NotSupportedException("Digital documents can’t be printed!");
    }
}

Passing DigitalDocument where a Document is expected will crash the program.

✅ After – Redefine the Hierarchy

public abstract class Document
{
    public abstract void Display();
}

public class PhysicalDocument : Document
{
    public override void Display() => Console.WriteLine("Printing document...");
}

public class DigitalDocument : Document
{
    public override void Display() => Console.WriteLine("Opening digital file...");
}

✅ Both classes fulfill the same contract (Display) in their own valid way. No surprises, no crashes.


4️⃣ Interface Segregation Principle (ISP)

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

Instead of large “do-everything” interfaces, create smaller, focused ones.

❌ Before – Overloaded Interface

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

Robots are being forced to implement irrelevant methods.

✅ After – Splitting Interfaces

public interface IWorkable { void Work(); }
public interface IFeedable { void Eat(); }
public interface IRestable { void Sleep(); }

public class Human : IWorkable, IFeedable, IRestable
{
    public void Work() => Console.WriteLine("Human working...");
    public void Eat() => Console.WriteLine("Human eating...");
    public void Sleep() => Console.WriteLine("Human sleeping...");
}

public class Robot : IWorkable
{
    public void Work() => Console.WriteLine("Robot assembling...");
}

✅ Each class implements only the behaviors it truly needs — flexible and clean.


5️⃣ Dependency Inversion Principle (DIP)

“Depend on abstractions, not on concrete implementations.”

High-level classes shouldn’t be tightly coupled to specific low-level classes. This enables better testing and easier replacement of dependencies.

❌ Before – Hard Dependency

public class OrderService
{
    private EmailSender _emailSender = new();

    public void CompleteOrder(Order order)
    {
        _emailSender.Send(order);
    }
}

You can’t switch to another notification method (like SMS) without modifying this class.

✅ After – Introduce Abstractions

public interface INotifier
{
    void Notify(Order order);
}

public class EmailNotifier : INotifier
{
    public void Notify(Order order)
    {
        Console.WriteLine($"Email sent for order #{order.Id}");
    }
}

public class SmsNotifier : INotifier
{
    public void Notify(Order order)
    {
        Console.WriteLine($"SMS sent for order #{order.Id}");
    }
}

public class Order
{
    public int Id { get; set; }
}

public class OrderService
{
    private readonly INotifier _notifier;

    public OrderService(INotifier notifier)
    {
        _notifier = notifier;
    }

    public void CompleteOrder(Order order)
    {
        Console.WriteLine($"Processing order #{order.Id}");
        _notifier.Notify(order);
    }
}

Usage:

var orderService = new OrderService(new SmsNotifier());
orderService.CompleteOrder(new Order { Id = 501 });

OrderService now depends on an abstraction (INotifier), not a specific class.
That’s clean, testable, and future-proof.


🧠 Wrapping Up

PrincipleCore IdeaBenefit
S – Single ResponsibilityOne class = one jobEasy maintenance
O – Open/ClosedExtend, don’t modifySafer scalability
L – Liskov SubstitutionValid inheritancePredictable behavior
I – Interface SegregationSmall, focused contractsCleaner dependencies
D – Dependency InversionDepend on abstractionsBetter testability

💬 Final Thoughts

The SOLID principles are more than just theory — they’re a practical way to write clean, adaptable code that evolves with your project.
Start small: refactor one class, separate one concern, introduce one abstraction.
Over time, you’ll notice your code becoming easier to test, easier to extend, and — best of all — easier to understand.


✍️ Written by Nalaka Jayasinghe
🚀 Senior Software Engineer | .NET & Angular Enthusiast | Clean Code Advocate