SOLID Principles Using C#

SOLID is an acronym for five object-oriented design principles that help developers write maintainable, scalable, and testable code. Introduced by Robert C. Martin (Uncle Bob), these principles serve

Introduction#

SOLID is an acronym for five object-oriented design principles that help developers write maintainable, scalable, and testable code. Introduced by Robert C. Martin (Uncle Bob), these principles serve as a foundation for clean software design.

The five principles are:

  • S - Single Responsibility Principle (SRP)
  • O - Open/Closed Principle (OCP)
  • L - Liskov Substitution Principle (LSP)
  • I - Interface Segregation Principle (ISP)
  • D - Dependency Inversion Principle (DIP)

Single Responsibility Principle (SRP)#

A class should have only one reason to change — meaning it should have only one responsibility.

Violation Example#

1
2
3
4
5
6
7
8
9
10
11
12
// BAD: UserService handles both user logic and email sending
public class UserService
{
    public void CreateUser(string name, string email)
    {
        // Save user to database
        Console.WriteLine($"Saving user {name} to database");

        // Send welcome email — this is a second responsibility
        Console.WriteLine($"Sending welcome email to {email}");
    }
}

Correct Implementation#

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
// User persistence responsibility
public class UserRepository
{
    public void Save(string name, string email)
    {
        Console.WriteLine($"Saving user {name} to database");
    }
}

// Email responsibility
public class EmailService
{
    public void SendWelcomeEmail(string email)
    {
        Console.WriteLine($"Sending welcome email to {email}");
    }
}

// Orchestration only — delegates to focused classes
public class UserService
{
    private readonly UserRepository _userRepository;
    private readonly EmailService _emailService;

    public UserService(UserRepository userRepository, EmailService emailService)
    {
        _userRepository = userRepository;
        _emailService = emailService;
    }

    public void CreateUser(string name, string email)
    {
        _userRepository.Save(name, email);
        _emailService.SendWelcomeEmail(email);
    }
}

Each class now has a single reason to change. Changing email logic does not affect UserRepository.

Open/Closed Principle (OCP)#

Software entities should be open for extension but closed for modification. You should be able to add new behavior without changing existing code.

Violation Example#

1
2
3
4
5
6
7
8
9
10
11
12
13
// BAD: Adding a new shape requires modifying AreaCalculator
public class AreaCalculator
{
    public double Calculate(object shape)
    {
        if (shape is Circle c)
            return Math.PI * c.Radius * c.Radius;
        else if (shape is Rectangle r)
            return r.Width * r.Height;
        // Adding Triangle requires editing this method
        return 0;
    }
}

Correct Implementation#

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
// Define an abstraction
public interface IShape
{
    double Area();
}

public class Circle : IShape
{
    public double Radius { get; set; }

    public double Area() => Math.PI * Radius * Radius;
}

public class Rectangle : IShape
{
    public double Width { get; set; }
    public double Height { get; set; }

    public double Area() => Width * Height;
}

// New shape — no changes to AreaCalculator required
public class Triangle : IShape
{
    public double Base { get; set; }
    public double Height { get; set; }

    public double Area() => 0.5 * Base * Height;
}

public class AreaCalculator
{
    public double Calculate(IShape shape) => shape.Area();
}

Adding a new shape only requires creating a new class. Existing code is untouched.

Liskov Substitution Principle (LSP)#

Objects of a derived class should be substitutable for objects of the base class without altering the correctness of the program.

Violation Example#

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
public class Bird
{
    public virtual void Fly()
    {
        Console.WriteLine("Flying");
    }
}

// BAD: Penguin cannot fly, but inherits Fly()
public class Penguin : Bird
{
    public override void Fly()
    {
        throw new NotSupportedException("Penguins cannot fly");
    }
}

// This breaks when a Penguin is passed instead of Bird
public void MakeBirdFly(Bird bird)
{
    bird.Fly(); // throws for Penguin
}

Correct Implementation#

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
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");
    }
}

// Works correctly for all Bird subtypes
public void MakeBirdMove(Bird bird)
{
    bird.Move();
}

The hierarchy now reflects actual behavior, and substitution is safe.

Interface Segregation Principle (ISP)#

Clients should not be forced to depend on interfaces they do not use. Prefer many small, specific interfaces over one large general-purpose interface.

Violation Example#

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// BAD: Forces all implementors to define methods they may not need
public interface IWorker
{
    void Work();
    void Eat();
    void Sleep();
}

// A robot does not eat or sleep
public class Robot : IWorker
{
    public void Work() => Console.WriteLine("Robot working");
    public void Eat() => throw new NotImplementedException();
    public void Sleep() => throw new NotImplementedException();
}

Correct Implementation#

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
public interface IWorkable
{
    void Work();
}

public interface IFeedable
{
    void Eat();
}

public interface ISleepable
{
    void Sleep();
}

// Human implements all relevant interfaces
public class Human : IWorkable, IFeedable, ISleepable
{
    public void Work() => Console.WriteLine("Human working");
    public void Eat() => Console.WriteLine("Human eating");
    public void Sleep() => Console.WriteLine("Human sleeping");
}

// Robot only implements what it needs
public class Robot : IWorkable
{
    public void Work() => Console.WriteLine("Robot working");
}

Classes implement only the contracts relevant to them.

Dependency Inversion Principle (DIP)#

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.

Violation Example#

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// BAD: OrderService directly depends on a concrete SqlOrderRepository
public class SqlOrderRepository
{
    public void Save(string order)
    {
        Console.WriteLine($"Saving order to SQL: {order}");
    }
}

public class OrderService
{
    private readonly SqlOrderRepository _repository = new SqlOrderRepository();

    public void PlaceOrder(string order)
    {
        _repository.Save(order);
    }
}

Switching to a different data store requires modifying OrderService.

Correct Implementation#

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
// Define abstraction
public interface IOrderRepository
{
    void Save(string order);
}

// SQL implementation
public class SqlOrderRepository : IOrderRepository
{
    public void Save(string order)
    {
        Console.WriteLine($"Saving order to SQL: {order}");
    }
}

// MongoDB implementation — drop-in replacement
public class MongoOrderRepository : IOrderRepository
{
    public void Save(string order)
    {
        Console.WriteLine($"Saving order to MongoDB: {order}");
    }
}

// High-level module depends only on the abstraction
public class OrderService
{
    private readonly IOrderRepository _repository;

    public OrderService(IOrderRepository repository)
    {
        _repository = repository;
    }

    public void PlaceOrder(string order)
    {
        _repository.Save(order);
    }
}

Usage with dependency injection:

1
2
3
4
// Easily swap implementations without touching OrderService
IOrderRepository repository = new MongoOrderRepository();
var orderService = new OrderService(repository);
orderService.PlaceOrder("Order #1001");

This pattern integrates naturally with .NET’s built-in dependency injection container in ASP.NET Core:

1
2
3
// Program.cs
builder.Services.AddScoped<IOrderRepository, SqlOrderRepository>();
builder.Services.AddScoped<OrderService>();

Best Practices#

  • Apply SOLID incrementally — refactor when a class grows beyond one responsibility.
  • Use interfaces over concrete types for all dependencies between layers.
  • Favor constructor injection for mandatory dependencies.
  • Write unit tests alongside each class — SOLID code is naturally testable.
  • Do not over-engineer small scripts or utilities; SOLID applies where code evolves and scales.

Conclusion#

SOLID principles guide you toward a codebase that is easier to extend, test, and maintain over time. In C#, these principles integrate seamlessly with features like interfaces, abstract classes, generics, and the ASP.NET Core DI container. Applying them consistently results in systems that accommodate change without cascading rewrites.

Contents