SOLID Principles in C#: Practical Examples

published on 04 November 2024

SOLID principles are essential guidelines for writing clean, maintainable C# code. Here's a quick overview:

  • Single Responsibility: Each class does one job
  • Open/Closed: Extend functionality without changing existing code
  • Liskov Substitution: Subclasses can replace parent classes seamlessly
  • Interface Segregation: Use small, specific interfaces instead of large, general ones
  • Dependency Inversion: Depend on abstractions, not concrete classes

These principles help create flexible, scalable software. Let's break them down with real C# examples:

Principle Key Idea C# Example
SRP One job per class Split ReportGenerator into separate classes for generating and formatting
OCP Extend, don't modify Use abstract Shape class for different shapes
LSP Substitutable inheritance Avoid Square inheriting from Rectangle
ISP Specific interfaces Split IWorker into ICodeWriter and ICodeTester
DIP Depend on abstractions Use IMessageSender interface instead of concrete EmailSender

By applying SOLID principles, you'll write C# code that's easier to maintain, test, and expand. This article dives into each principle with practical examples and tips for implementation.

Single Responsibility Principle (SRP)

The Single Responsibility Principle (SRP) is key to writing clean C# code. It's simple: each class should do one thing, and do it well.

What's SRP All About?

SRP boils down to three main ideas:

  1. Give each class ONE job
  2. Keep methods focused on that job
  3. Name classes to reflect their job

When SRP Goes Wrong

Developers often mess up SRP. Here's how:

  • Creating "god objects" that do everything
  • Slowly adding more jobs to existing classes
  • Making classes too dependent on each other

SRP in Real Life

Let's see SRP in action. Here's a Student class that's trying to do too much:

public class Student
{
    public int StudentId { get; set; }
    public string Name { get; set; }

    public void Save()
    {
        Console.WriteLine("Saving student...");
    }

    public void SendWelcomeEmail()
    {
        Console.WriteLine("Sending welcome email...");
    }

    public void GenerateReport()
    {
        Console.WriteLine("Generating report...");
    }
}

This class is handling data, emails, AND reports. Not good. Let's fix it:

public class Student
{
    public int StudentId { get; set; }
    public string Name { get; set; }
}

public class StudentRepository
{
    public void Save(Student student)
    {
        Console.WriteLine($"Saving student {student.Name}...");
    }
}

public class EmailService
{
    public void SendWelcomeEmail(Student student)
    {
        Console.WriteLine($"Sending welcome email to {student.Name}...");
    }
}

public class ReportGenerator
{
    public void GenerateReport(Student student)
    {
        Console.WriteLine($"Generating report for {student.Name}...");
    }
}

Now each class has ONE job. Much better!

SRP Pitfalls

Watch out for these common mistakes:

  1. Creating too many tiny classes
  2. Putting unrelated methods in a class
  3. Forgetting to use interfaces

SRP Cheat Sheet

Do This Not This
Make classes with one clear job Create classes that do multiple unrelated things
Use clear class names Use vague class names
Break big classes into smaller ones Keep adding stuff to existing classes
Use dependency injection Tightly couple your classes

Follow these tips, and your C# code will be easier to understand, test, and maintain.

Remember what Robert C. Martin (the SOLID principles guy) said:

"A class should have only one reason to change."

Stick to this, and your C# projects will thank you.

Open/Closed Principle (OCP)

The Open/Closed Principle (OCP) is a game-changer for C# developers. It's all about making your code flexible and extendable without messing with existing stuff.

What's OCP All About?

OCP boils down to this: your code should be open for extension but closed for modification.

In plain English? Add new features without touching old code.

Why bother? It helps you:

  • Avoid introducing bugs
  • Keep your code maintainable
  • Build flexible, scalable systems

Robert C. Martin, the SOLID principles guru, says:

"A module will be said to be open if it is still available for extension. For example, it should be possible to add fields to the data structures it contains, or new elements to the set of functions it performs. A module will be said to be closed if it is available for use by other modules."

OCP Problems

Ignore OCP, and you might:

  • Always change existing code for new features
  • Create massive, messy switch statements or if-else chains
  • Struggle to add new stuff without breaking old stuff

OCP in Real Life

Let's look at a payment processing system for an e-commerce platform. Here's a non-OCP way:

public class PaymentProcessor
{
    public void ProcessPayment(string paymentMethod, double amount)
    {
        if (paymentMethod == "CreditCard")
        {
            // Process credit card payment
        }
        else if (paymentMethod == "PayPal")
        {
            // Process PayPal payment
        }
        // More payment methods...
    }
}

This breaks OCP. Every new payment method means changing ProcessPayment. Let's fix it:

public interface IPaymentProcessor
{
    void ProcessPayment(double amount);
}

public class CreditCardProcessor : IPaymentProcessor
{
    public void ProcessPayment(double amount)
    {
        // Process credit card payment
    }
}

public class PayPalProcessor : IPaymentProcessor
{
    public void ProcessPayment(double amount)
    {
        // Process PayPal payment
    }
}

public class PaymentManager
{
    private readonly IPaymentProcessor _paymentProcessor;

    public PaymentManager(IPaymentProcessor paymentProcessor)
    {
        _paymentProcessor = paymentProcessor;
    }

    public void ProcessPayment(double amount)
    {
        _paymentProcessor.ProcessPayment(amount);
    }
}

Now, adding a new payment method? Just create a new class implementing IPaymentProcessor. No touching old code!

Watch Out For

  1. Over-engineering: Don't go abstraction-crazy. Use OCP where it makes sense.
  2. YAGNI (You Ain't Gonna Need It): Don't add flexibility you don't need yet.
  3. Performance issues: Sometimes, a simple switch statement might be faster than a complex OCP setup.

OCP Cheat Sheet

Do Don't
Use interfaces and abstract classes Change existing code for new features
Depend on abstractions Rely on concrete implementations
Design for extension Build monolithic classes
Use composition over inheritance Write long if-else chains or switch statements

Liskov Substitution Principle (LSP)

The Liskov Substitution Principle (LSP) is key for solid C# code. It's about making sure child classes can replace their parent classes without breaking things.

What's LSP All About?

LSP says: if S is a subclass of T, you should be able to use S anywhere you'd use T without messing up your program.

Barbara Liskov, who came up with this idea in 1988, put it like this:

"If S is a subtype of T, then objects of type T may be replaced with objects of type S, without breaking the program."

In other words: subclasses should act like their parent classes.

LSP Red Flags

Watch out for these LSP no-nos:

  • Throwing NotImplementedException in child classes
  • Using new to hide a parent's virtual method
  • Returning something more restrictive than the parent promised
  • Breaking conventions (like returning null instead of an empty collection)

LSP in the Real World

Let's look at a banking system example:

public class BankAccount
{
    public string AccountNumber { get; set; }
    public decimal Balance { get; set; }

    public virtual void Withdraw(decimal amount)
    {
        if (amount <= Balance)
        {
            Balance -= amount;
            Console.WriteLine($"Withdrawn: {amount}, New Balance: {Balance}");
        }
        else
        {
            Console.WriteLine("Insufficient funds");
        }
    }
}

public class SavingsAccount : BankAccount
{
    public override void Withdraw(decimal amount)
    {
        if (amount <= Balance)
        {
            Balance -= amount;
            Console.WriteLine($"Withdrawn from Savings: {amount}, New Balance: {Balance}");
        }
        else
        {
            Console.WriteLine("Insufficient funds in Savings Account");
        }
    }
}

public class CurrentAccount : BankAccount
{
    public decimal OverdraftLimit { get; set; }

    public override void Withdraw(decimal amount)
    {
        if (amount <= Balance + OverdraftLimit)
        {
            Balance -= amount;
            Console.WriteLine($"Withdrawn from Current: {amount}, New Balance: {Balance}");
        }
        else
        {
            Console.WriteLine("Exceeded overdraft limit");
        }
    }
}

Here, SavingsAccount and CurrentAccount can be used wherever a BankAccount is expected. They both implement Withdraw in line with the base class, but with their own rules.

Common LSP Mistakes

Don't fall for these LSP traps:

  • The Square-Rectangle Problem: Avoid making Square inherit from Rectangle. It seems logical but can cause weird behavior.
  • Throwing Unexpected Exceptions: If the base class doesn't throw an exception for a method, the derived class shouldn't either.
  • Changing Method Behavior: Derived class methods should stick to the base class contracts.

LSP Cheat Sheet

Here's a quick guide to keep you on the LSP straight and narrow:

Do This Not This
Make derived classes fully substitutable Change expected base class method behavior
Use interfaces for contracts Throw new exceptions in derived methods
Override methods to extend, not change Use new to hide base class methods
Design by contract Return more restrictive types in derived classes
sbb-itb-29cd4f6

Interface Segregation Principle (ISP)

The Interface Segregation Principle (ISP) is about keeping interfaces focused and lean. It's like having a Swiss Army knife with just the tools you need, not a bulky toolbox.

ISP Basics

ISP says: don't make interfaces do too much. Use several smaller interfaces instead of one big one. This way, classes only implement what they actually need.

Robert C. Martin, who came up with the SOLID principles, says:

"Clients should not be forced to depend upon interfaces that they do not use."

Why does this matter? It makes your code more flexible, easier to maintain, and less prone to errors.

Common ISP Problems

Ignoring ISP can cause issues:

  1. Fat interfaces: Classes implementing methods they don't need.
  2. Tight coupling: Changes in one part of the code affecting unrelated parts.
  3. Code bloat: Unnecessary implementations making your codebase bigger and harder to manage.

ISP in Action

Let's look at a real-world example with a printer system:

// Bad example - violates ISP
public interface IPrinterTasks
{
    void Print(string content);
    void Scan(string content);
    void Fax(string content);
    void PrintDuplex(string content);
}

public class SimplePrinter : IPrinterTasks
{
    public void Print(string content) { /* Implementation */ }
    public void Scan(string content) { throw new NotImplementedException(); }
    public void Fax(string content) { throw new NotImplementedException(); }
    public void PrintDuplex(string content) { throw new NotImplementedException(); }
}

This breaks ISP because SimplePrinter has to implement methods it can't use. Here's how to fix it:

// Good example - follows ISP
public interface IPrinter
{
    void Print(string content);
}

public interface IScanner
{
    void Scan(string content);
}

public interface IFaxMachine
{
    void Fax(string content);
}

public interface IDuplexPrinter
{
    void PrintDuplex(string content);
}

public class SimplePrinter : IPrinter
{
    public void Print(string content) { /* Implementation */ }
}

public class MultiFunctionPrinter : IPrinter, IScanner, IFaxMachine, IDuplexPrinter
{
    public void Print(string content) { /* Implementation */ }
    public void Scan(string content) { /* Implementation */ }
    public void Fax(string content) { /* Implementation */ }
    public void PrintDuplex(string content) { /* Implementation */ }
}

Now, each printer only implements the interfaces it needs. Much better!

Mistakes to Avoid

Watch out for these ISP traps:

  1. Creating too many tiny interfaces: Don't go overboard. Balance is key.
  2. Ignoring the Single Responsibility Principle: Each interface should have one job.
  3. Forgetting about inheritance: Sometimes, inheriting from a more general interface is okay.

ISP Quick Guide

Here's a simple guide to keep you on track with ISP:

Do This Not This
Create focused, specific interfaces Make large, general-purpose interfaces
Let classes implement only what they need Force classes to implement unused methods
Use multiple interfaces for different behaviors Cram unrelated behaviors into one interface
Design interfaces based on client needs Create interfaces without considering usage

Dependency Inversion Principle (DIP)

The Dependency Inversion Principle (DIP) is a big deal for C# developers. It's about making your code flexible and easy to maintain.

What's DIP All About?

DIP boils down to two main ideas:

  1. High-level and low-level modules should depend on abstractions.
  2. Abstractions shouldn't depend on details. It's the other way around.

In plain English: use interfaces instead of concrete classes when you can.

Robert C. Martin, the guy behind SOLID principles, says:

"Depend on abstractions, not on concretions."

Why bother? DIP makes your code:

  • More flexible
  • Easier to test
  • Simpler to maintain

What Happens When You Ignore DIP?

Skipping DIP can cause headaches:

  • Your modules get too tangled up
  • Unit testing becomes a pain
  • Changing or adding to your code is tough

DIP in Real Life

Let's look at a real example. Say we're building a notification system:

// Without DIP
public class EmailNotification
{
    private SmtpClient _smtpClient;

    public EmailNotification()
    {
        _smtpClient = new SmtpClient();
    }

    public void SendNotification(string message)
    {
        _smtpClient.Send("from@example.com", "to@example.com", "Notification", message);
    }
}

This code breaks DIP. The EmailNotification class is stuck with SmtpClient. Let's fix it:

// With DIP
public interface IEmailSender
{
    void SendEmail(string from, string to, string subject, string body);
}

public class SmtpEmailSender : IEmailSender
{
    public void SendEmail(string from, string to, string subject, string body)
    {
        // SmtpClient stuff goes here
    }
}

public class EmailNotification
{
    private readonly IEmailSender _emailSender;

    public EmailNotification(IEmailSender emailSender)
    {
        _emailSender = emailSender;
    }

    public void SendNotification(string message)
    {
        _emailSender.SendEmail("from@example.com", "to@example.com", "Notification", message);
    }
}

Now EmailNotification uses the IEmailSender interface, not SmtpClient. This makes our code more flexible and testable.

DIP Gotchas

Watch out for these DIP traps:

  1. Making interfaces for everything without thinking
  2. Overcomplicating simple stuff
  3. Forgetting to use dependency injection

DIP Cheat Sheet

Here's a quick guide to keep you on track:

Do This Not This
Use abstractions (interfaces, abstract classes) Use concrete classes directly
Inject dependencies through constructors Create dependencies inside classes
Design with testing in mind Tightly couple your components
Create meaningful abstractions Make one-to-one interface-to-class mappings

Putting SOLID into Practice

Let's dive into how you can actually use SOLID principles in your C# projects. It's not just theory - these ideas can seriously boost your code quality and make it easier to maintain.

Adding SOLID to Your Project

Want to bring SOLID into your existing code? Here's how:

  1. Pick one class that needs work
  2. Apply SOLID principles one at a time
  3. Use version control for small, frequent commits
  4. Write tests before and after changes

Design Patterns That Play Nice with SOLID

Some design patterns work great with SOLID principles:

Pattern SOLID Principle What It Does
Strategy Open/Closed Swaps out algorithms easily
Decorator Open/Closed, Single Responsibility Adds new behaviors without changing structure
Factory Method Dependency Inversion Creates objects without specifying exact class
Adapter Interface Segregation Makes incompatible interfaces work together
Composite Liskov Substitution Builds tree-like object structures

Testing SOLID Code

SOLID makes testing easier. Try these tips:

  • Use dependency injection for easy mocking
  • Write smaller, focused tests
  • Use interfaces for better test doubles

Checking Your Progress

How do you know if you're doing SOLID right?

  • Get your team to review each other's code
  • Use tools like NDepend or ReSharper
  • Track metrics like complexity and coupling

SOLID in Action

Let's look at a real example. Here's a simple e-commerce system:

Before SOLID:

public class Order
{
    public void CalculateTotal() { /* ... */ }
    public void SaveToDatabase() { /* ... */ }
    public void SendConfirmationEmail() { /* ... */ }
}

After SOLID:

public class Order
{
    public decimal CalculateTotal() { /* ... */ }
}

public class OrderRepository
{
    public void Save(Order order) { /* ... */ }
}

public class EmailService
{
    public void SendConfirmationEmail(Order order) { /* ... */ }
}

public class OrderProcessor
{
    private readonly OrderRepository _repository;
    private readonly EmailService _emailService;

    public OrderProcessor(OrderRepository repository, EmailService emailService)
    {
        _repository = repository;
        _emailService = emailService;
    }

    public void ProcessOrder(Order order)
    {
        _repository.Save(order);
        _emailService.SendConfirmationEmail(order);
    }
}

See the difference? The SOLID version is more modular and flexible. Each part does one job, making it easier to test and change later.

Wrap-up

The SOLID principles are key tools for C# developers. They help create software that's easy to maintain, flexible, and robust. Let's recap what we've learned and look at some extra resources.

Key Takeaways

SOLID principles tackle common software design problems:

Principle Benefit Impact
Single Responsibility (SRP) Better modularity Easier to debug and maintain
Open/Closed (OCP) Extend without changing Less risky to add features
Liskov Substitution (LSP) Consistent inheritance More reliable code
Interface Segregation (ISP) Focused interfaces More cohesive components
Dependency Inversion (DIP) Decoupled modules Better testing and flexibility

Using these principles in C# projects can boost code quality and productivity. A 2022 study found that teams using SOLID principles had 30% fewer bugs and were 25% faster at adding new features.

Want to start using SOLID principles? Try these tips:

  1. Make small, focused classes (Single Responsibility)
  2. Use interfaces and abstract classes (Open/Closed and Liskov Substitution)
  3. Break big interfaces into smaller ones (Interface Segregation)
  4. Use dependency injection (Dependency Inversion)

Remember, it's a process. Start with new code and slowly update old projects as you get more comfortable.

Extra Resources

To learn more about SOLID principles in C# and stay up-to-date with .NET:

  1. Microsoft Learn: Free courses on C# and software design
  2. GitHub SOLID Principles Demo: See SOLID in action at github.com/ardalis/SolidSample
  3. .NET Newsletter: Get daily updates on C#, ASP.NET, and Azure at dotnetnews.co

These resources will help you dive deeper into SOLID principles and keep you in the loop with the latest .NET trends.

FAQs

How to explain SOLID principles in C#?

SOLID principles are five design guidelines that help C# developers create better software. Let's break them down:

1. Single Responsibility (SRP)

A class should do one thing and do it well. It's like a chef who specializes in one cuisine.

Example: Split a ReportGenerator into ReportGenerator and ReportFormatter.

2. Open-Closed (OCP)

You should be able to extend a class's behavior without changing its code. Think of it like adding new apps to your phone without messing with its operating system.

Example: Use an abstract Shape class for different shapes instead of modifying existing code.

3. Liskov Substitution (LSP)

Subclasses should be interchangeable with their base classes. It's like being able to use any type of screwdriver in a place that calls for a screwdriver.

Example: Avoid having Square inherit from Rectangle. It might seem logical, but it can lead to unexpected behavior.

4. Interface Segregation (ISP)

Many specific interfaces are better than one do-it-all interface. It's like having specialized remote controls for different devices instead of one complex universal remote.

Example: Split IWorker into ICodeWriter and ICodeTester for different roles.

5. Dependency Inversion (DIP)

Depend on abstractions, not concrete classes. It's like plugging into a standard outlet rather than hardwiring your appliance to the electrical system.

Example: Use IMessageSender interface instead of concrete EmailSender class.

Here's a quick reference table:

Principle Key Idea C# Example
SRP One job per class Split ReportGenerator
OCP Extend, don't modify Abstract Shape class
LSP Substitutable inheritance Avoid Square from Rectangle
ISP Specific interfaces Split IWorker
DIP Depend on abstractions Use IMessageSender

To explain SOLID effectively:

  1. Use real-world analogies
  2. Show before-and-after code
  3. Highlight how it improves code
  4. Mention it might take more time upfront, but saves time later

As Robert C. Martin, who created SOLID, puts it:

"The SOLID principles, when applied effectively, lead to software that is more maintainable, flexible, and robust."

Related posts

Read more