Skip to content

LSIND/SOLID-principles

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

4 Commits
 
 
 
 
 
 
 
 
 
 

Repository files navigation

SOLID-principles

This repository demonstrates the SOLID principles with practical C# examples, comparing bad design vs. good design.

What are SOLID Principles?

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:

  1. Store car data:

    • Model name (string)
    • Year of issue (must be between 2010 and 2019)
  2. Print car details to the console

❌ Bad design

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"
Loading

What's wrong?

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.

✅ Good Design

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"
Loading

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.)

❌ Bad Design

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"
Loading

What's wrong?

Every time we want to add a new report format (JSON, XML, HTML, CSV, etc.), we have to:

  • Open the Report class
  • Add another else-if branch
  • Potentially break existing functionality
  • This violates OCP because the class is not closed for modification.

✅ Good Design

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!"
Loading

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.

❌ Bad design

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"
Loading

What's wrong?

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.

✅ Good Design

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"
Loading

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)

❌ Bad design

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"

Loading

What's wrong?

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

✅ Good Design

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"
Loading

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.

❌ Bad design

classDiagram
direction LR
    class Notification {
        -Email _email
        +SendNotification()
    }    
    class Email {
        +Send()
    }    
    Notification --> Email : depends on
    
    note for Notification "❌ Tight coupling</br>❌ Hard to extend"
Loading

What's wrong?

The Notification class is tightly coupled to the Email class:

  • To add WhatsApp support, you must modify Notification
  • Cannot reuse Notification with different message types
  • Hard to unit test (can't mock Email)
  • Violates DIP because high-level module depends on low-level details

✅ Good Design

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"
Loading

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

Three Common Patterns

1. Constructor Injection (Most Common)

  • 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
Loading

2. Property Injection (Setter Injection)

  • 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"
Loading

3. Method Injection

  • 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"
Loading

✅ Key Benefits

  • 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

About

S.O.L.I.D. principles VS bad design

Resources

License

Stars

Watchers

Forks

Releases

No releases published

Packages

 
 
 

Contributors

Languages