This repository demonstrates the SOLID principles with practical C# examples, comparing bad design vs. good design.
SOLID is an acronym for five design principles that help create maintainable, scalable, and testable software.
| Principle | Description |
|---|---|
| S | Single Responsibility Principle |
| O | Open/Closed Principle |
| L | Liskov Substitution Principle |
| I | Interface Segregation Principle |
| D | Dependency Inversion Principle |
A class should be responsible to one, and only one, actor.
Every class should have responsibility over a single part of the functionality and that responsibility should be entirely encapsulated by this class.
Example. We need to model a Car with the following requirements:
-
Store car data:
- Model name (string)
- Year of issue (must be between 2010 and 2019)
-
Print car details to the console
classDiagram
direction RL
class Car {
-int _year
+string Model
+int Year
+PrintDetails()
}
note for Car "❌ Handles both data storage AND printing</br>❌ Reasons to change:</br>1. Data structure changes. </br> 2. Print format changes"
One class does everything. The bad design Car class has two responsibilities:
- Business logic (storing data, validation)
- Output logic (printing)
If printing format changes (e.g., to JSON, XML, or a different text format), we have to modify this class - even though its core job is just being a Car.
Class Car: Responsible ONLY for car data and validation
Class Printer: Responsible ONLY for printing
classDiagram
direction LR
class Car {
-int _year
+string Model
+int Year
+Car()
}
class CarPrinter {
+PrintDetails(car)
}
Car --> CarPrinter : passed as parameter
note for Car "✅ Single Responsibility</br>Only responsible for:</br>- Storing car data</br>- Validating year range"
note for CarPrinter "✅ Single Responsibility</br>Only responsible for:</br>- Output/printing logic"
Classes should be open for extension, but closed for modification.
Example. We need a Reporting system that can:
- Generate reports about a Car object
- Support multiple formats: PDF format, DOCX format
- Be easily extensible to add new formats in the future (JSON, XML, HTML, etc.)
Modifying existing code for each new format.
classDiagram
direction RL
class Report {
-Car _car
+PrintData(format)
}
note for Report "❌ New format = modify this class</br>❌ if-else chain grows forever"
Every time we want to add a new report format (JSON, XML, HTML, CSV, etc.), we have to:
- Open the
Reportclass - Add another
else-ifbranch - Potentially break existing functionality
- This violates OCP because the class is not closed for modification.
Create an abstract base class with a virtual method. Each format (PDF, DOCX) gets its own derived class that overrides the method. Adding a new format = adding a new class, not changing existing code.
classDiagram
class Report {
<<abstract>>
#Car _car
+PrintData()*
}
class PdfReport {
+PrintData()
}
class DocxReport {
+PrintData()
}
class JsonReport {
+PrintData()
}
Report <|-- PdfReport
Report <|-- DocxReport
Report <|-- JsonReport
note for Report "✅ Open for extension</br>✅ Closed for modification"
note for JsonReport "✅ New format = new class</br>✅ No existing code changes!"
Derived classes must be substitutable for their base classes without altering the correctness of the program.
Example. If you have a base class Vehicle and a derived class Car, you should be able to replace Vehicle with Car anywhere in the code without unexpected errors or behavior changes.
classDiagram
class Vehicle {
+CalculateRent(days)
+GetVehicleDetails()
}
class Car {
+CalculateRent(days)
+GetVehicleDetails()
}
class Bus {
+CalculateRent(days)
+GetVehicleDetails()
}
Vehicle <|-- Car
Vehicle <|-- Bus
note for Bus "❌ Throws NotImplementedException"
Bus violates the contract of Vehicle. The base class promises that CalculateRent() works, but Bus throws an exception. Substituting Bus for Vehicle breaks the program.
Bus no longer has a fake CalculateRent() method. You can safely use List<Vehicle> with both Car and Bus for GetDetails(). You can safely use List<RentableVehicle> only with vehicles that support renting (like car).
classDiagram
class Vehicle {
<<abstract>>
+GetVehicleDetails()*
}
class RentableVehicle {
<<abstract>>
+CalculateRent(days)*
}
class Car {
+CalculateRent(days)
+GetVehicleDetails()
}
class Bus {
+GetVehicleDetails()
+GetPassengerCapacity()
}
Vehicle <|-- RentableVehicle
RentableVehicle <|-- Car
Vehicle <|-- Bus
note for Car "✅ Can be rented"
note for Bus "✅ Cannot be rented"
Clients should not be forced to depend on interfaces they do not use.
Example. We have two types of vehicles: Car and Bus. Requirements:
- Print details about any vehicle (both Car and Bus)
- Add a new vehicle to the system (but only Cars can be added, Buses cannot)
classDiagram
class IVehicle {
<<interface>>
+ShowDetails(id)
+AddNewVehicle()
}
class Car {
+ShowDetails(id)
+AddNewVehicle()
}
class Bus {
+ShowDetails(id)
+AddNewVehicle()
}
IVehicle <|.. Car
IVehicle <|.. Bus
note for Bus "❌ Forced to implement AddNewVehicle(),</br>even though it doesn't support it"
The Bus class is forced to implement AddNewVehicle() even though it doesn't support this operation. This leads to:
- Fake implementations that return false
- Throwing NotImplementedException
- Confusing API — client doesn't know which vehicles support adding
classDiagram
class IVehicleDetails {
<<interface>>
+ShowDetails(id)
}
class IVehicleAdd {
<<interface>>
+AddNewVehicle()
}
class Car {
+ShowDetails(id)
+AddNewVehicle()
}
class Bus {
+ShowDetails(id)
}
IVehicleDetails <|.. Car
IVehicleAdd <|.. Car
IVehicleDetails <|.. Bus
note for Car "✅ Supports both interfaces"
note for Bus "✅ Supports only what it needs"
High-level modules should not depend on low-level modules. Both should depend on abstractions.
Example. We need a Notification system that can send messages.
Requirements:
- Send notifications via Email
- Easily extend to support WhatsApp, SMS, Push notifications, etc.
classDiagram
direction LR
class Notification {
-Email _email
+SendNotification()
}
class Email {
+Send()
}
Notification --> Email : depends on
note for Notification "❌ Tight coupling</br>❌ Hard to extend"
The Notification class is tightly coupled to the Email class:
- To add WhatsApp support, you must modify
Notification - Cannot reuse
Notificationwith different message types - Hard to unit test (can't mock
Email) - Violates DIP because high-level module depends on low-level details
classDiagram
class IMessenger {
<<interface>>
+Send()
}
class Notification {
-IMessenger _messenger
+SendNotification()
}
class Email {
+Send()
}
class WhatsApp {
+Send()
}
class SMS {
+Send()
}
Notification --> IMessenger : depends on (abstraction)
IMessenger <|.. Email : implements
IMessenger <|.. WhatsApp : implements
IMessenger <|.. SMS : implements
note for Notification "✅ Loose coupling</br>✅ Easy to extend"
note for IMessenger "✅ Abstraction"
note for Email "✅ Low-level detail"
DI is a design pattern where an object receives its dependencies from an external source rather than creating them internally. It's the mechanism that enables the Dependency Inversion Principle (DIP).
| Problem without DI | Solution with DI |
|---|---|
| Class creates its own dependencies using new | Dependencies are provided from outside |
| Tight coupling between classes | Loose coupling through abstractions |
| Hard to unit test (can't mock dependencies) | Easy to test (inject mocks) |
| Difficult to change behavior without modifying class | Flexible — swap implementations freely |
- Dependencies are provided through the class constructor.
- Best for: Required dependencies (the class cannot work without them)
classDiagram
direction LR
class Client {
-IMessenger _messenger
+Client(messenger)
+DoWork()
}
class IMessenger {
<<interface>>
+Send()
}
class Email {
+Send()
}
class WhatsApp {
+Send()
}
Client --> IMessenger : depends on</br>(via constructor)
IMessenger <|.. Email
IMessenger <|.. WhatsApp
- Dependencies are provided through public properties after object creation.
- Best for: Optional dependencies (the class can work without them)
classDiagram
direction LR
class Client {
-IMessenger _messenger
+IMessenger Messenger
+DoWork()
}
class IMessenger {
<<interface>>
+Send()
}
class Email {
+Send()
}
class WhatsApp {
+Send()
}
Client --> IMessenger : depends on</br>(via property setter)
IMessenger <|.. Email
IMessenger <|.. WhatsApp
note for Client "Opt. dependency"
- Dependencies are provided as method parameters when the method is called.
- Best for: Dependencies that vary with each method call (stateless operations).
classDiagram
direction LR
class Client {
+DoWork(messenger)
}
class IMessenger {
<<interface>>
+Send()
}
class Email {
+Send()
}
class WhatsApp {
+Send()
}
Client --> IMessenger : depends on</br>(via method parameter)
IMessenger <|.. Email
IMessenger <|.. WhatsApp
note for Client "Dependency varies</br>per method call"
- Testability — Easily replace real dependencies with mocks
- Flexibility — Change behavior without modifying classes
- Maintainability — Clear what each class needs to function
- Reusability — Same class works with different implementations