Skip to main content

C# SOLID Principles

Welcome to our guide on SOLID principles in C#! These principles are essential design guidelines that help developers create maintainable, flexible, and robust software.

Introduction

SOLID is an acronym that represents five principles of object-oriented design introduced by Robert C. Martin ("Uncle Bob"). These principles, when applied correctly, make your code more modular, testable, and easier to maintain over time.

The SOLID acronym stands for:

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

Let's explore each principle in detail with practical C# examples.

Single Responsibility Principle (SRP)

A class should have only one reason to change.

This principle states that a class should have only one responsibility or job. When a class has multiple responsibilities, changes to one aspect might affect others, leading to unexpected bugs.

Bad Example (Violating SRP)

csharp
public class Employee
{
public string Name { get; set; }
public decimal Salary { get; set; }

// Employee data management
public void SaveToDatabase()
{
// Code to save employee to database
Console.WriteLine($"Saving {Name} to database");
}

// Reporting functionality
public void GenerateEmployeeReport()
{
// Code to generate report
Console.WriteLine($"Generating report for {Name}");
}

// Salary calculation
public decimal CalculateMonthlyTax()
{
// Code to calculate tax
decimal tax = Salary * 0.2m;
Console.WriteLine($"Tax for {Name}: {tax}");
return tax;
}
}

The Employee class is doing too many things: managing employee data, generating reports, and calculating taxes.

Good Example (Following SRP)

csharp
// Employee class only manages employee data
public class Employee
{
public string Name { get; set; }
public decimal Salary { get; set; }
}

// Separate class for database operations
public class EmployeeRepository
{
public void Save(Employee employee)
{
// Code to save employee to database
Console.WriteLine($"Saving {employee.Name} to database");
}
}

// Separate class for reporting
public class EmployeeReportGenerator
{
public void GenerateReport(Employee employee)
{
// Code to generate report
Console.WriteLine($"Generating report for {employee.Name}");
}
}

// Separate class for tax calculations
public class TaxCalculator
{
public decimal CalculateMonthlyTax(Employee employee)
{
// Code to calculate tax
decimal tax = employee.Salary * 0.2m;
Console.WriteLine($"Tax for {employee.Name}: {tax}");
return tax;
}
}

Now each class has a single responsibility, making the code more maintainable and easier to test.

Open/Closed Principle (OCP)

Software entities should be open for extension but closed for modification.

This principle suggests that you should be able to add new functionality without changing existing code. This is typically achieved through abstractions and polymorphism.

Bad Example (Violating OCP)

csharp
public class Rectangle
{
public double Width { get; set; }
public double Height { get; set; }
}

public class Circle
{
public double Radius { get; set; }
}

public class AreaCalculator
{
public double CalculateArea(object shape)
{
if (shape is Rectangle rectangle)
{
return rectangle.Width * rectangle.Height;
}
else if (shape is Circle circle)
{
return Math.PI * circle.Radius * circle.Radius;
}

throw new ArgumentException("Unsupported shape type");
}
}

If you want to add a new shape, you'd have to modify the CalculateArea method, violating OCP.

Good Example (Following OCP)

csharp
public interface IShape
{
double CalculateArea();
}

public class Rectangle : IShape
{
public double Width { get; set; }
public double Height { get; set; }

public double CalculateArea()
{
return Width * Height;
}
}

public class Circle : IShape
{
public double Radius { get; set; }

public double CalculateArea()
{
return Math.PI * Radius * Radius;
}
}

public class Triangle : IShape // Adding a new shape without modifying existing code
{
public double Base { get; set; }
public double Height { get; set; }

public double CalculateArea()
{
return 0.5 * Base * Height;
}
}

public class AreaCalculator
{
public double CalculateArea(IShape shape)
{
return shape.CalculateArea();
}
}

Now we can add new shapes without modifying the AreaCalculator class.

Liskov Substitution Principle (LSP)

Subtypes must be substitutable for their base types without altering the correctness of the program.

This principle ensures that a derived class can replace its base class without affecting the program's behavior.

Bad Example (Violating LSP)

csharp
public class Bird
{
public virtual void Fly()
{
Console.WriteLine("I can fly");
}
}

public class Ostrich : Bird
{
public override void Fly()
{
// Ostriches can't fly, so this violates LSP
throw new NotImplementedException("Ostriches can't fly!");
}
}

// Using the classes
public void MakeBirdFly(Bird bird)
{
bird.Fly(); // Will throw an exception if bird is an Ostrich!
}

Good Example (Following LSP)

csharp
public abstract class Bird
{
public abstract void Move();
}

public class FlyingBird : Bird
{
public override void Move()
{
Console.WriteLine("I can fly");
}

public void Fly()
{
Console.WriteLine("Flying high");
}
}

public class Ostrich : Bird
{
public override void Move()
{
Console.WriteLine("I can run");
}

public void Run()
{
Console.WriteLine("Running fast");
}
}

// Using the classes
public void MakeBirdMove(Bird bird)
{
bird.Move(); // Works correctly for all types of birds
}

This approach ensures that all derived classes can fulfill the contract of the base class without unexpected behavior.

Interface Segregation Principle (ISP)

Clients should not be forced to depend on interfaces they do not use.

This principle suggests that you should create small, specific interfaces rather than large, general-purpose ones.

Bad Example (Violating ISP)

csharp
public interface IWorker
{
void Work();
void Eat();
void Sleep();
}

// This class needs all methods
public class Human : IWorker
{
public void Work() => Console.WriteLine("Working");
public void Eat() => Console.WriteLine("Eating");
public void Sleep() => Console.WriteLine("Sleeping");
}

// This class doesn't need the Sleep and Eat methods
public class Robot : IWorker
{
public void Work() => Console.WriteLine("Working efficiently");

public void Eat()
{
// Robots don't eat, but forced to implement
throw new NotImplementedException();
}

public void Sleep()
{
// Robots don't sleep, but forced to implement
throw new NotImplementedException();
}
}

Good Example (Following ISP)

csharp
public interface IWorkable
{
void Work();
}

public interface IEatable
{
void Eat();
}

public interface ISleepable
{
void Sleep();
}

// Human implements all interfaces
public class Human : IWorkable, IEatable, ISleepable
{
public void Work() => Console.WriteLine("Working");
public void Eat() => Console.WriteLine("Eating");
public void Sleep() => Console.WriteLine("Sleeping");
}

// Robot only implements what it needs
public class Robot : IWorkable
{
public void Work() => Console.WriteLine("Working efficiently");
}

Now classes only implement the interfaces they actually need, leading to cleaner code.

Dependency Inversion Principle (DIP)

High-level modules should not depend on low-level modules. Both should depend on abstractions.

The principle suggests that we should depend on abstractions rather than concrete implementations.

Bad Example (Violating DIP)

csharp
public class EmailNotifier
{
public void SendNotification(string message)
{
// Code to send an email notification
Console.WriteLine($"Sending email: {message}");
}
}

public class OrderProcessor
{
private EmailNotifier _notifier = new EmailNotifier();

public void ProcessOrder(string order)
{
// Process the order
Console.WriteLine($"Processing order: {order}");

// Send notification
_notifier.SendNotification($"Order {order} was processed");
}
}

OrderProcessor depends directly on the concrete EmailNotifier class, making it hard to change notification methods.

Good Example (Following DIP)

csharp
public interface INotifier
{
void SendNotification(string message);
}

public class EmailNotifier : INotifier
{
public void SendNotification(string message)
{
// Code to send an email notification
Console.WriteLine($"Sending email: {message}");
}
}

public class SMSNotifier : INotifier
{
public void SendNotification(string message)
{
// Code to send an SMS notification
Console.WriteLine($"Sending SMS: {message}");
}
}

public class OrderProcessor
{
private readonly INotifier _notifier;

// Dependency is injected through constructor
public OrderProcessor(INotifier notifier)
{
_notifier = notifier;
}

public void ProcessOrder(string order)
{
// Process the order
Console.WriteLine($"Processing order: {order}");

// Send notification
_notifier.SendNotification($"Order {order} was processed");
}
}

// Usage
INotifier emailNotifier = new EmailNotifier();
OrderProcessor processor = new OrderProcessor(emailNotifier);
processor.ProcessOrder("12345");

// We can easily switch to SMS notifications
INotifier smsNotifier = new SMSNotifier();
processor = new OrderProcessor(smsNotifier);
processor.ProcessOrder("67890");

Now OrderProcessor depends on an abstraction (INotifier), not a concrete class, making it more flexible.

Real-World Application Example

Let's implement a simple document conversion system using SOLID principles:

csharp
// 1. Define interfaces (SRP, ISP)
public interface IDocumentReader
{
string Read(string filePath);
}

public interface IDocumentWriter
{
void Write(string content, string filePath);
}

public interface IDocumentConverter
{
string Convert(string source);
}

// 2. Implement concrete classes
public class PdfReader : IDocumentReader
{
public string Read(string filePath)
{
Console.WriteLine($"Reading PDF file: {filePath}");
return $"Content of {filePath}";
}
}

public class WordReader : IDocumentReader
{
public string Read(string filePath)
{
Console.WriteLine($"Reading Word file: {filePath}");
return $"Content of {filePath}";
}
}

public class HtmlWriter : IDocumentWriter
{
public void Write(string content, string filePath)
{
Console.WriteLine($"Writing HTML to {filePath}: {content}");
}
}

public class MarkdownConverter : IDocumentConverter
{
public string Convert(string source)
{
Console.WriteLine("Converting content to Markdown");
return $"{source} (converted to Markdown)";
}
}

// 3. Create high-level module that depends on abstractions (DIP)
public class DocumentProcessor
{
private readonly IDocumentReader _reader;
private readonly IDocumentConverter _converter;
private readonly IDocumentWriter _writer;

public DocumentProcessor(
IDocumentReader reader,
IDocumentConverter converter,
IDocumentWriter writer)
{
_reader = reader;
_converter = converter;
_writer = writer;
}

public void ProcessDocument(string sourcePath, string destinationPath)
{
// Read
string content = _reader.Read(sourcePath);

// Convert
string convertedContent = _converter.Convert(content);

// Write
_writer.Write(convertedContent, destinationPath);

Console.WriteLine("Document processed successfully");
}
}

// 4. Client code
public void RunExample()
{
// We can easily configure different combinations
var processor = new DocumentProcessor(
new PdfReader(),
new MarkdownConverter(),
new HtmlWriter()
);

processor.ProcessDocument("document.pdf", "output.html");

// We can easily switch implementations
var wordProcessor = new DocumentProcessor(
new WordReader(),
new MarkdownConverter(),
new HtmlWriter()
);

wordProcessor.ProcessDocument("document.docx", "output.html");
}

This example follows all SOLID principles:

  • SRP: Each class has a single responsibility (reading, writing, converting)
  • OCP: We can add new readers, writers, and converters without modifying existing code
  • LSP: Any implementation of our interfaces can be used interchangeably
  • ISP: Interfaces are small and focused on specific capabilities
  • DIP: The DocumentProcessor depends on abstractions, not concrete implementations

Summary

The SOLID principles are essential design guidelines for creating maintainable and flexible object-oriented code:

  1. Single Responsibility Principle: A class should have only one reason to change
  2. Open/Closed Principle: Classes should be open for extension but closed for modification
  3. Liskov Substitution Principle: Derived classes should be substitutable for their base classes
  4. Interface Segregation Principle: Many client-specific interfaces are better than one general-purpose interface
  5. Dependency Inversion Principle: Depend on abstractions, not concrete implementations

Applying these principles helps you write code that is:

  • Easier to maintain and extend
  • More reusable
  • More testable
  • More robust against changes

Remember that SOLID principles are guidelines, not strict rules. Use them thoughtfully based on your project's requirements and constraints.

Exercises

  1. Refactor a class: Take an existing class from your project that has multiple responsibilities and refactor it according to SRP.

  2. Apply OCP: Identify a switch statement or if-else chain in your code and refactor it using polymorphism to follow OCP.

  3. Implement ISP: Look for interfaces in your code that are too large and split them into smaller, more focused interfaces.

  4. Practice DIP: Refactor a class that depends on concrete implementations to use dependency injection instead.

Additional Resources

Happy coding with SOLID principles!



If you spot any mistakes on this website, please let me know at [email protected]. I’d greatly appreciate your feedback! :)