Design Patterns & Practices  

Understand SOLID Design Principles in 5 Minutes

To build a solid house, you need a good plan.

In object-oriented programming, our code is that house. The plan is its design . When the design is weak, the code slowly becomes messy, hard to understand, and difficult to maintain. Classes grow too large, responsibilities overlap, and changes start breaking unrelated parts of the system.

This problem is known as tight coupling , this is where design principles come in.

Design Principles vs Design Patterns

Although often confused, they serve different purposes:

Design Principles

  • High-level guidelines on how to think when writing code

  • Focus on structure and responsibility

  • Similar to a guidebook

Design Patterns

  • Concrete solutions to recurring problems

  • Focus on implementation

  • Similar to a manual

What Is SOLID?

SOLID is a set of five object-oriented design principles introduced by Robert C. Martin (Uncle Bob) . These principles help developers write code that is easier to maintain, extend, and test.

Let’s go through them one by one.

S – Single Responsibility Principle (SRP)

A class should have only one reason to change.

Each class should do one thing and do it well .

Why it matters

  • Easier maintenance

  • Better readability

  • Less risk when making changes

Real-life example

A chef who cooks, serves customers, and washes dishes violates SRP. Splitting these roles makes the system clearer and more scalable.

1

O – Open-Closed Principle (OCP)

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

What this really means

  • You should be able to add new behavior

  • Without changing existing, tested code

A very common violation is using conditionals that grow over time.

public class PaymentService
{
    public void Pay(string paymentType)
    {
        if (paymentType == "Card")
        {
            // Card payment logic
        }
        else if (paymentType == "Paypal")
        {
            // Paypal logic
        }
        else if (paymentType == "Crypto")
        {
            // Crypto logic
        }
    }
}

Why this violates OCP

  • Every new payment method requires modifying this class

  • Risk of breaking existing behavior

  • Class becomes harder to maintain and test

A better choice is:

Step 1: Define an abstraction

public interface IPaymentMethod
{
    void Pay();
}

Step 2: Create implementations

public class CardPayment : IPaymentMethod
{
    public void Pay() { /* Card logic */ }
}

public class PaypalPayment : IPaymentMethod
{
    public void Pay() { /* Paypal logic */ }
}

Step 3: Depend on the abstraction

public class PaymentService
{
    private readonly IPaymentMethod paymentMethod;

    public PaymentService(IPaymentMethod paymentMethod)
    {
        paymentMethod = paymentMethod;
    }

    public void Pay()
    {
        paymentMethod.Pay();
    }
}

L – Liskov Substitution Principle (LSP)

Subtypes must be substitutable for their base types.

If a class inherits from another, it must behave like it.

Common problem

A Bicycle inheriting from Vehicle that requires an StartEngine() method breaks LSP, because a bicycle has no engine.

public abstract class Vehicle
{
    public abstract void StartEngine();
}

public class Car : Vehicle
{
    public override void StartEngine()
    {
        Console.WriteLine("Engine started");
    }
}

public class Bicycle : Vehicle
{
    public override void StartEngine()
    {
        throw new NotSupportedException();
    }
}

Correct approach

Step 1: Split responsibilities

public interface IMovable
{
    void Move();
}

public interface IEnginePowered
{
    void StartEngine();
}

Step 2: Implement only what makes sense

public class Car : IMovable, IEnginePowered
{
    public void Move() { }
    public void StartEngine() { }
}

public class Bicycle : IMovable
{
    public void Move() { }
}

I – Interface Segregation Principle (ISP)

No client should be forced to depend on methods it does not use.

Large interfaces lead to fragile designs. Smaller, focused interfaces are easier to implement and maintain.

Common ISP Violation

public interface IUserService
{
    User GetById(Guid id);
    void Create(User user);
    void Update(User user);
    void Delete(Guid id);
    void ExportToCsv();
    void SendEmailNotification();
}

Split it by responsibilities:

public interface IUserReader
{
    User GetById(Guid id);
}

public interface IUserWriter
{
    void Create(User user);
    void Update(User user);
    void Delete(Guid id);
}  

D – Dependency Inversion Principle (DIP)

High-level modules should not depend on low-level modules.
Both should depend on abstractions.

  • Your business logic (high-level code) should not directly create or depend on concrete implementations.

  • Instead, depend on interfaces or abstract classes (abstractions).

  • Low-level classes (like database access, APIs) implement these interfaces.

Instead of:

public class EmailSender
{
    public void SendEmail(string message)
    {
        // Code to send an email
    }
}

public class NotificationService
{
    private readonly EmailSender emailSender;

    public NotificationService()
    {
        this.emailSender = new EmailSender(); // direct dependency
    }

    public void Notify(string message)
    {
        this.emailSender.SendEmail(message);
    }
}

Problems:

  • NotificationService is tightly coupled to EmailSender.

  • If we want to send SMS instead, we need to modify NotificationService.

  • Harder to test: we can’t mock EmailSender easily.

So instead:

Step 1: Introduce an abstraction

public interface IMessageSender
{
    void SendMessage(string message);
}

Step 2: Implement the interface

public class EmailSender : IMessageSender
{
    public void SendMessage(string message)
    {
        // Send email logic
    }
}

public class SmsSender : IMessageSender
{
    public void SendMessage(string message)
    {
        // Send SMS logic
    }
}

Step 3: High-level module depends on abstraction

public class NotificationService
{
    private readonly IMessageSender messageSender;

    public NotificationService(IMessageSender messageSender)
    {
        messageSender = messageSender;
    }

    public void Notify(string message)
    {
        messageSender.SendMessage(message);
    }
}

SOLID principles are not strict rules. They are guidelines that help you think better about software design. When applied thoughtfully, they lead to:

  • Cleaner code

  • Easier testing

  • Better scalability

  • Lower maintenance cost

Mastering SOLID is not about memorizing definitions, but about recognizing design problems early and applying the right principle at the right time.