SOLID Principles in C# and .NET: A Beginner-Friendly Guide
Learn the 5 SOLID principles in C# and .NET with simple words, real-life examples, diagrams, and clean code you can copy and try yourself today.
Introduction
Think about a good kitchen in an Indian home. The person who cooks the food does not also wash the dishes, buy the vegetables, and pay the electricity bill at the same moment. Each job has its own place and its own person. The cook cooks. The shopkeeper sells. The electricity board sends the bill. Because the jobs are split, if the cook is sick, the shop still runs. If the bill changes, the cooking does not stop.
Good code works the same way. When every part of your program has one clear job, life becomes easy. When one small class tries to do everything, every change becomes scary, because touching one thing breaks ten other things.
SOLID is a set of five simple rules that help you split jobs nicely in your C# code. The rules were collected by a teacher named Robert C. Martin (people call him "Uncle Bob"). The word SOLID is just the first letters of the five rules, put together so they are easy to remember.
In this guide you will learn each rule with plain words, a real-life example, and small C# code you can try. You do not need to be an expert. If you can follow a recipe, you can follow this.
What does SOLID stand for?
Here is the full list in one table. Keep this table near you while you read the rest.
| Letter | Name | One-line meaning |
|---|---|---|
| S | Single Responsibility | One class should do one job. |
| O | Open/Closed | Add new behaviour without editing old code. |
| L | Liskov Substitution | A child class must work wherever the parent works. |
| I | Interface Segregation | Many small interfaces beat one giant interface. |
| D | Dependency Inversion | Depend on ideas (interfaces), not on exact classes. |
Now let us take them one by one.
S — Single Responsibility Principle
The rule is short: a class should have only one reason to change. In simple words, a class should do only one job.
Think about a school report card machine. Imagine one machine that calculates marks, prints the report on paper, and also sends an SMS to parents. If the SMS company changes its rules, you have to open the same machine that calculates marks. That is dangerous. You might break the marks while fixing the SMS.
Here is messy code that breaks this rule. One class is doing three jobs.
// Bad: this class calculates, saves, and emails. Three jobs.
public class ReportCard
{
public int CalculateTotal(int[] marks)
{
var total = 0;
foreach (var m in marks) total += m;
return total;
}
public void SaveToFile(string text)
{
File.WriteAllText("report.txt", text);
}
public void SendEmail(string toAddress, string text)
{
// pretend this talks to an email server
Console.WriteLine($"Emailing {toAddress}: {text}");
}
}Now we split the jobs. Each class has one reason to change.
// Good: each class has one job.
public class MarksCalculator
{
public int CalculateTotal(int[] marks) => marks.Sum();
}
public class ReportFileSaver
{
public void Save(string text) => File.WriteAllText("report.txt", text);
}
public class ReportEmailer
{
public void Send(string toAddress, string text)
=> Console.WriteLine($"Emailing {toAddress}: {text}");
}Now if the email rules change, you touch only ReportEmailer. The marks logic stays safe and untouched. That is the whole point.
Splitting one big class into single-job classes
Steps
Big class
Does calculate, save, email
Find the jobs
List each separate task
Make one class per job
Calculator, Saver, Emailer
O — Open/Closed Principle
The rule: code should be open for extension but closed for modification. In simple words, you should be able to add new behaviour without editing the code that already works.
Think about a power board with sockets. When you buy a new fan, you do not call an electrician to open the wall and rewire the house. You just plug the fan into a free socket. The wall is "closed" (you do not change it), but it is "open" because you can plug in new things.
Here is code that breaks the rule. Every time we add a new shape, we must edit the same method.
// Bad: adding a new shape forces us to edit this method again and again.
public class AreaCalculator
{
public double Area(object shape)
{
if (shape is Circle c) return 3.14 * c.Radius * c.Radius;
if (shape is Square s) return s.Side * s.Side;
// tomorrow we add Triangle... and edit here again. Risky.
return 0;
}
}The clean way uses an interface. New shapes plug in without touching the old code.
public interface IShape
{
double Area();
}
public class Circle : IShape
{
public double Radius { get; init; }
public double Area() => 3.14 * Radius * Radius;
}
public class Square : IShape
{
public double Side { get; init; }
public double Area() => Side * Side;
}
// This never changes when we add a new shape.
public class AreaCalculator
{
public double Total(IEnumerable<IShape> shapes)
=> shapes.Sum(shape => shape.Area());
}Now adding a Triangle means writing a new class. The AreaCalculator stays closed and safe.
L — Liskov Substitution Principle
This name sounds scary but the idea is gentle. A child class must be usable anywhere its parent is used, without surprises.
Think about chairs. A plastic chair and a wooden chair are both chairs. If someone says "bring a chair", any of them works. Nobody falls down. But imagine a "decoration chair" that breaks the moment you sit on it. It is called a chair, but it does not behave like a real chair. That is a Liskov problem. It looks like the parent but it betrays the promise.
A classic example is Square and Rectangle. People think a square is a kind of rectangle. But in code, forcing a square to behave like a rectangle creates surprises.
public class Rectangle
{
public virtual int Width { get; set; }
public virtual int Height { get; set; }
public int Area() => Width * Height;
}
// Bad: a Square that breaks the parent's promise.
public class Square : Rectangle
{
public override int Width { set { base.Width = base.Height = value; } }
public override int Height { set { base.Width = base.Height = value; } }
}Now any code that sets width to 4 and height to 5 expects an area of 20. With this Square, it suddenly gets 25. The child surprised the caller. That breaks Liskov.
The fix is to not force a fake parent-child link. Give each one its own honest shape.
public interface IShape
{
int Area();
}
public class Rectangle : IShape
{
public int Width { get; set; }
public int Height { get; set; }
public int Area() => Width * Height;
}
public class Square : IShape
{
public int Side { get; set; }
public int Area() => Side * Side;
}Now nobody is pretending. A Square is honest about being a square, and no caller gets a shock.
| Situation | Liskov-friendly? | Why |
|---|---|---|
| Plastic chair used as a chair | Yes | Same promise, no surprise |
| Decoration chair that breaks when you sit | No | Breaks the parent's promise |
| Square that changes height when you set width | No | Caller expects normal rectangle behaviour |
I — Interface Segregation Principle
The rule: many small interfaces are better than one big fat interface. Do not force a class to carry methods it does not need.
Think about a job advertisement. A bad advertisement says "you must cook, drive, code, and sing". Very few people can do all four. A good advertisement asks only for the skill the job needs. Then the right person can apply easily.
Here is a fat interface that forces everyone to implement everything.
// Bad: a fat interface. Not every worker can do every job.
public interface IWorker
{
void Cook();
void Drive();
void Code();
}
public class Programmer : IWorker
{
public void Cook() => throw new NotSupportedException(); // ugly!
public void Drive() => throw new NotSupportedException(); // ugly!
public void Code() => Console.WriteLine("Writing C#...");
}The Programmer is forced to write fake methods that just throw errors. That is a smell. Split the interface into small honest pieces.
public interface ICook { void Cook(); }
public interface IDrive { void Drive(); }
public interface ICode { void Code(); }
// A programmer only takes the interface it actually uses.
public class Programmer : ICode
{
public void Code() => Console.WriteLine("Writing C#...");
}
// A chef only takes the cooking interface.
public class Chef : ICook
{
public void Cook() => Console.WriteLine("Cooking biryani...");
}Now each class carries only the methods it truly uses. No fake methods, no surprises.
Breaking a fat interface into small ones
Steps
Fat IWorker
Cook, Drive, Code together
Split by skill
ICook, IDrive, ICode
Implement only needed
Programmer takes ICode only
D — Dependency Inversion Principle
The rule: depend on abstractions (interfaces), not on concrete classes. High-level code should not be glued to low-level details.
Think about a wall switch. Your switch does not care which company made the bulb. It just sends power. You can change an old bulb for a new LED bulb, and the switch keeps working. The switch depends on the idea of "a bulb", not on one exact bulb brand.
Here is tightly glued code. The OrderService builds its own emailer inside. You cannot swap it out for testing or for SMS.
// Bad: OrderService is glued to one exact class.
public class EmailSender
{
public void Send(string msg) => Console.WriteLine($"Email: {msg}");
}
public class OrderService
{
private readonly EmailSender _sender = new EmailSender(); // glued!
public void PlaceOrder()
=> _sender.Send("Your order is placed.");
}Now we invert the dependency. The service depends on an interface. The real class is handed in from outside.
public interface IMessageSender
{
void Send(string msg);
}
public class EmailSender : IMessageSender
{
public void Send(string msg) => Console.WriteLine($"Email: {msg}");
}
public class OrderService
{
private readonly IMessageSender _sender;
// The sender is given to us. We do not build it ourselves.
public OrderService(IMessageSender sender) => _sender = sender;
public void PlaceOrder() => _sender.Send("Your order is placed.");
}This is exactly how modern .NET works. The built-in dependency injection container in ASP.NET Core hands these objects to you. You register the interface and its real class once, and .NET wires it up for you.
// In Program.cs (ASP.NET Core)
builder.Services.AddScoped<IMessageSender, EmailSender>();
builder.Services.AddScoped<OrderService>();Tomorrow, if you want to send SMS instead of email, you write an SmsSender and change one line of registration. The OrderService never changes. That is the power of depending on ideas instead of exact classes.
A small note for 2026: some popular libraries that used to be free, like MediatR and MassTransit, now use a commercial licence for many cases. The good news is that SOLID does not need any library. The dependency injection container built into .NET is free and already gives you the Dependency Inversion Principle out of the box.
How the five rules help each other
The five rules are friends. They support each other.
- Single Responsibility keeps classes small, which makes them easy to put behind an interface.
- Interfaces let you follow Open/Closed, because you plug in new classes instead of editing old ones.
- Interface Segregation keeps those interfaces small and honest.
- Liskov makes sure the classes you plug in do not lie about their behaviour.
- Dependency Inversion ties it together, because your code talks to interfaces, so swapping a class is painless.
How SOLID flows together
Steps
Small classes
From Single Responsibility
Interfaces
From Open/Closed and ISP
Safe swaps
From Liskov and DIP
Easy tests
You can pass fake objects
A common mistake: do not overdo it
SOLID is a set of helpful habits, not a holy law. Beginners sometimes make twenty tiny interfaces for a small school project and the code becomes harder to read, not easier. That is the wrong direction.
A simple guide:
| Project size | How much SOLID? |
|---|---|
| Tiny script or homework | Keep it simple, maybe just clear names and one job per method |
| Small app with real users | Use Single Responsibility and Dependency Inversion first |
| Large team project | Use all five, they really pay off here |
Start small. Add a principle when you feel the pain it removes. If a class keeps changing for many reasons, that is the moment Single Responsibility helps. If you cannot test a class because it builds its own helpers, that is the moment Dependency Inversion helps.
Why SOLID matters for testing
One big reason teams love SOLID is testing. When your code depends on interfaces, you can pass a fake object during a test. You do not need a real email server or a real database.
// A fake sender used only in a test. No real email goes out.
public class FakeSender : IMessageSender
{
public string LastMessage = "";
public void Send(string msg) => LastMessage = msg;
}
// In the test
var fake = new FakeSender();
var service = new OrderService(fake);
service.PlaceOrder();
// Now we can check fake.LastMessage equals "Your order is placed."Because OrderService depends on IMessageSender and not on the exact EmailSender, slipping in a fake is easy. This is the everyday payoff of the Dependency Inversion Principle. Good design and easy testing usually come together.
References and further reading
- Architectural principles (Microsoft Learn) — Microsoft's own guidance, which echoes SOLID ideas.
- Dependency injection in .NET (Microsoft Learn) — the official guide to the DI container that powers Dependency Inversion.
- SOLID Principles in C# with Examples (Dot Net Tutorials) — a free community course with many examples.
- Mastering SOLID Principles in C# (Syncfusion Blog) — a practical walkthrough for .NET developers.
Quick recap
- SOLID is five rules for clean, easy-to-change object-oriented code in C#.
- S — Single Responsibility: one class, one job, one reason to change.
- O — Open/Closed: add new behaviour by plugging in new classes, not by editing old ones.
- L — Liskov Substitution: a child class must behave correctly anywhere the parent is used.
- I — Interface Segregation: prefer many small interfaces over one giant interface.
- D — Dependency Inversion: depend on interfaces, not on exact classes; .NET's built-in DI does this for you.
- The five rules support each other and make your code easy to test with fake objects.
- Do not overdo it. Start with Single Responsibility and Dependency Inversion, and add the rest when you feel the need.
Related Posts
8 Tips to Write Clean Code in C# and .NET
Learn 8 simple, beginner-friendly tips to write clean C# and .NET code with clear names, small methods, good error handling, and easy-to-read structure.
How to Write Better and Cleaner Code in .NET
A beginner-friendly guide to writing better, cleaner C# and .NET code using clear names, small methods, modern C# 14 features, and simple structure.
How to Build a High-Performance Cache in C# Without External Libraries
Build a fast, thread-safe, size-limited LRU cache in C# using only the .NET base class library. Clear diagrams, code, and student-friendly explanations.
DRY Is the Most Misunderstood Rule in Programming
DRY is not about deleting repeated code. It is about knowledge. Learn the real meaning of Don't Repeat Yourself with simple C# examples.
How to Be a Better Software Engineer in 2023 (A Beginner's Guide)
Simple, beginner-friendly habits to grow as a software engineer: clean code, testing, SOLID, version control, refactoring, and learning the right way.
How to Avoid Code Duplication in Vertical Slice Architecture in .NET
Learn how to avoid code duplication in Vertical Slice Architecture in .NET without breaking your slices. Rule of three, pipeline behaviors, shared infrastructure, and clear examples.