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.