SOLID Principles in C#
- #solid
- #csharp
- #oop
- #clean-code
- #design
In questa lezione
- 1. Introduction
- 2. S - Single Responsibility Principle (SRP)
- 2.1 Bad example
- 2.2 Good example
- 3. O - Open/Closed Principle (OCP)
- 3.1 Bad example
- 3.2 Good example
- 4. L - Liskov Substitution Principle (LSP)
- 4.1 Bad example
- 4.2 Good example
- 5. I - Interface Segregation Principle (ISP)
- 5.1 Bad example
- 5.2 Good example
- 6. D - Dependency Inversion Principle (DIP)
- 6.1 Bad example
- 6.2 Good example
- 7. SOLID in practice
- 8. Interview questions
- 9. Quiz
- 10. Exercises
- 10.1 Split a God service
- 10.2 Remove the switch
- 10.3 Fix the hierarchy
1. Introduction
SOLID is an acronym for five object-oriented design principles, made popular by Robert C. Martin (“Uncle Bob”). They help you write code that is easy to change, easy to test and easy to understand.
| Letter | Principle | One-line idea |
|---|---|---|
| S | Single Responsibility | A class should have one reason to change |
| O | Open/Closed | Open for extension, closed for modification |
| L | Liskov Substitution | Subtypes must be usable in place of their base type |
| I | Interface Segregation | Many small interfaces are better than one big one |
| D | Dependency Inversion | Depend on abstractions, not on concrete classes |
In this lesson every principle has a bad example and a good example, using an engineering data domain: projects, documents, revisions, equipment and tags.
Nota
SOLID is not a set of laws. It is a set of guidelines that reduce coupling (how much classes depend on each other) and increase cohesion (how well the parts of a class belong together).
2. S - Single Responsibility Principle (SRP)
A class should have only one reason to change.
“Responsibility” means “reason to change”, usually linked to one actor or one concern (business rules, persistence, formatting, notifications…).
2.1 Bad example
public class DocumentService
{
public void Approve(Document doc)
{
doc.Status = DocumentStatus.Approved; // business rule
using var conn = new NpgsqlConnection("Host=..."); // persistence
conn.Open();
// UPDATE documents SET status = ...
var pdf = $"<h1>{doc.Title}</h1>"; // formatting
File.WriteAllText($"{doc.Id}.html", pdf);
new SmtpClient("smtp.local") // notification
.Send("noreply@x.com", doc.OwnerEmail, "Approved", doc.Title);
}
}
This class changes if the database changes, if the report layout changes, or if the email provider changes. It is also impossible to unit test without a real database and SMTP server.
2.2 Good example
public class DocumentApprovalService(
IDocumentRepository repository,
IDocumentNotifier notifier)
{
public async Task ApproveAsync(Guid documentId)
{
var doc = await repository.GetByIdAsync(documentId)
?? throw new KeyNotFoundException();
doc.Approve(); // rule lives in the entity
await repository.SaveAsync(doc); // persistence is delegated
await notifier.NotifyApprovedAsync(doc); // notification is delegated
}
}
Now each class has one job. The example uses a C# 12 primary constructor to keep the code short.
Attenzione
SRP does not mean “one method per class”. Splitting too much creates dozens of tiny classes that are hard to navigate. Group code that changes together, separate code that changes for different reasons.
3. O - Open/Closed Principle (OCP)
Software entities should be open for extension, but closed for modification.
You should be able to add new behavior without editing existing, tested code.
3.1 Bad example
public class TagValidator
{
public bool IsValid(Tag tag) => tag.Type switch
{
"Pump" => Regex.IsMatch(tag.Code, @"^P-\d{3}$"),
"Valve" => Regex.IsMatch(tag.Code, @"^V-\d{4}$"),
// every new equipment type = edit this switch
_ => false
};
}
3.2 Good example
public interface ITagRule
{
bool AppliesTo(string tagType);
bool IsValid(Tag tag);
}
public class PumpTagRule : ITagRule
{
public bool AppliesTo(string t) => t == "Pump";
public bool IsValid(Tag tag) => Regex.IsMatch(tag.Code, @"^P-\d{3}$");
}
public class TagValidator(IEnumerable<ITagRule> rules)
{
public bool IsValid(Tag tag) =>
rules.FirstOrDefault(r => r.AppliesTo(tag.Type))?.IsValid(tag) ?? false;
}
To support a new type you add a new ITagRule class and register it in DI. TagValidator never changes. This is the Strategy pattern (see Design Patterns in .NET).
Suggerimento
In ASP.NET Core, if you register several implementations of the same interface (AddScoped<ITagRule, PumpTagRule>(), AddScoped<ITagRule, ValveTagRule>()), you can inject them all as IEnumerable<ITagRule>. This is a very common way to apply OCP.
4. L - Liskov Substitution Principle (LSP)
If
Sis a subtype ofT, objects of typeTcan be replaced with objects of typeSwithout breaking the program.
A subclass must keep the contract of its base class: it should not throw unexpected exceptions, require stronger inputs, or return weaker results.
4.1 Bad example
public class Document
{
public virtual void AddRevision(Revision r) => Revisions.Add(r);
public List<Revision> Revisions { get; } = [];
}
public class ArchivedDocument : Document
{
public override void AddRevision(Revision r) =>
throw new InvalidOperationException("Archived documents are read-only");
}
// Caller code that works for Document but breaks for ArchivedDocument
void Import(Document doc, Revision rev) => doc.AddRevision(rev); // boom
ArchivedDocument “is a” Document in real life, but it does not behave like one. Every caller now needs if (doc is ArchivedDocument) checks, which is a classic LSP smell.
4.2 Good example
public interface IReadableDocument
{
string Title { get; }
IReadOnlyList<Revision> Revisions { get; }
}
public interface IEditableDocument : IReadableDocument
{
void AddRevision(Revision r);
}
public class ActiveDocument : IEditableDocument { /* ... */ }
public class ArchivedDocument : IReadableDocument { /* ... */ }
Now the type system tells the truth: you can only call AddRevision on something that really supports it.
Nota
The famous textbook example is Square : Rectangle. Setting Width on a square also changes Height, so code that expects a rectangle breaks. Interviewers like it, but a domain example like the one above shows you really understand it.
5. I - Interface Segregation Principle (ISP)
Clients should not be forced to depend on methods they do not use.
5.1 Bad example
public interface IEquipmentService
{
Equipment? GetById(int id);
IEnumerable<Equipment> Search(string text);
void Create(Equipment e);
void Delete(int id);
byte[] ExportToExcel();
void SyncWithErp();
}
// A read-only report class must implement (or depend on) everything
public class EquipmentReport(IEquipmentService service) { /* uses only Search */ }
A “fat” interface couples every client to every method. A fake for tests must implement six methods even if the test uses one.
5.2 Good example
public interface IEquipmentReader
{
Equipment? GetById(int id);
IEnumerable<Equipment> Search(string text);
}
public interface IEquipmentWriter
{
void Create(Equipment e);
void Delete(int id);
}
public interface IEquipmentExporter { byte[] ExportToExcel(); }
public class EquipmentReport(IEquipmentReader reader) { /* ... */ }
One class can still implement all three interfaces. The point is that each client depends only on what it needs. This idea is also the base of CQRS (separating reads from writes).
6. D - Dependency Inversion Principle (DIP)
High-level modules should not depend on low-level modules. Both should depend on abstractions.
The high-level module is your business logic. The low-level modules are details: database, file system, HTTP, email.
6.1 Bad example
public class ProjectImportService
{
private readonly PostgresProjectRepository _repo = new();
private readonly CsvFileReader _reader = new();
public void Import(string path)
{
foreach (var project in _reader.Read(path))
_repo.Insert(project);
}
}
Business logic is glued to PostgreSQL and CSV. You cannot import from an API or test without files and a database.
6.2 Good example
public interface IProjectSource { IAsyncEnumerable<Project> ReadAsync(); }
public interface IProjectRepository { Task AddAsync(Project p); }
public class ProjectImportService(IProjectSource source, IProjectRepository repo)
{
public async Task ImportAsync()
{
await foreach (var project in source.ReadAsync())
await repo.AddAsync(project);
}
}
// Program.cs (composition root)
builder.Services.AddScoped<IProjectSource, CsvProjectSource>();
builder.Services.AddScoped<IProjectRepository, EfProjectRepository>();
The abstractions (IProjectSource, IProjectRepository) belong to the business layer. The infrastructure layer implements them. The dependency arrow is inverted.
graph LR
A[ProjectImportService] --> B[IProjectSource]
A --> C[IProjectRepository]
D[CsvProjectSource] -.implements.-> B
E[EfProjectRepository] -.implements.-> C
Attenzione
DIP (a principle) is not the same as DI (a technique) or IoC container (a tool). Dependency Injection is how you usually apply DIP in .NET. See the Dependency Injection lesson for lifetimes and the built-in container.
7. SOLID in practice
- Don’t over-engineer. An interface with one implementation that will never change and is never mocked can be noise. Apply SOLID when change or testing is real.
- SOLID and testing go together. If a class is hard to unit test, it often breaks SRP or DIP. See Unit and Integration Testing in .NET.
- Related principles: DRY (Don’t Repeat Yourself), KISS (Keep It Simple), YAGNI (You Aren’t Gonna Need It), and “composition over inheritance”.
Suggerimento
Interview tip: when asked about SOLID, don’t just recite the definitions. Pick one or two principles and tell a short story from your own code: “In my last project we had a service that did X, Y and Z; we split it and it became easy to test.” Real examples are much more convincing.
8. Interview questions
Q: What does SOLID stand for? It’s five design principles: Single Responsibility, Open/Closed, Liskov Substitution, Interface Segregation and Dependency Inversion. Together they aim to reduce coupling and make code easier to change and test. In .NET you see them everywhere, for example in how ASP.NET Core uses interfaces and dependency injection.
Q: Can you give an example of a Single Responsibility violation? A typical one is a service method that validates data, writes to the database, builds a report and sends an email all in one place. It has many reasons to change, and you can’t test the business rule without a database. I would move persistence into a repository and notifications into a separate service, and keep only the orchestration in the original class.
Q: How do you apply the Open/Closed Principle in C#?
I usually put the varying behavior behind an interface and add new implementations instead of extending a big switch or if chain. With the built-in DI container I can register several implementations and inject them as an IEnumerable. So adding a new case means adding a new class, not editing tested code.
Q: What is the Liskov Substitution Principle, in simple words?
It means a subclass must be usable anywhere the base class is expected, without surprises. If an override throws NotSupportedException or ignores part of the contract, callers start checking concrete types, and that’s a sign the hierarchy is wrong. Usually the fix is to split the abstraction or prefer composition over inheritance.
Q: What is the difference between Dependency Inversion and Dependency Injection? Dependency Inversion is the principle: business logic should depend on abstractions, and details like the database should implement those abstractions. Dependency Injection is the technique: you pass dependencies in from outside, usually through the constructor. In ASP.NET Core the built-in container does the injection for you.
Q: Can SOLID be overused? Yes. If you create an interface for every class, or split logic into tiny pieces, the code becomes harder to read without real benefit. I apply the principles where there is real variation or a real need to test in isolation, and I keep things simple otherwise, following YAGNI.
9. Quiz
Mettiti alla prova
0/8 risposte
According to SRP, what is a 'responsibility'?
You must add support for a new equipment type without editing the existing validator. Which principle are you following?
A subclass overrides a method and throws
NotSupportedException. Which principle is most likely violated?A test fake must implement 10 methods, but the class under test calls only 1. Which principle would help?
In DIP, who should own the abstraction
IProjectRepository?Which statement about DIP and DI is correct?
How can you inject all registered implementations of
ITagRulein ASP.NET Core?Which is a sign that SOLID is being over-applied?
10. Exercises
10.1 Split a God service
Goal: apply SRP and DIP to a class that does too much.
- Write a
RevisionService.Publish(Revision r)method that validates the revision, saves it withNpgsqlConnection, writes a log file and sends an email. - List every “reason to change” of the class in a comment.
- Extract
IRevisionRepository,IAuditLogandINotifier, and inject them with a primary constructor. - Register the implementations in
Program.cs.
Hint: the final Publish method should be about 5 lines and contain only orchestration.
10.2 Remove the switch
Goal: apply OCP to document numbering.
- Create a
DocumentNumberGeneratorwith aswitchon discipline ("Piping","Electrical","Mechanical") that returns different number formats. - Refactor it into an
INumberingRuleinterface with one class per discipline. - Inject
IEnumerable<INumberingRule>and pick the right rule at runtime. - Add a new
"Instrumentation"rule without changing the generator.
Hint: add an AppliesTo(string discipline) method to the interface.
10.3 Fix the hierarchy
Goal: detect and fix an LSP and ISP violation.
- Create a base class
EquipmentwithStart(),Stop()andCalibrate(). - Add
Pump(supports everything) andTank(cannot start or stop, so it throws). - Write a method that starts a list of
Equipmentand observe the failure. - Redesign with small interfaces (
IStartable,ICalibratable) so each class implements only what it supports.
Hint: use equipment.OfType<IStartable>() to work only with items that support starting.