SOLID Principles in C#: Real Code Examples Every Developer Should Know

·

·

Every C# developer has heard of SOLID. Far fewer can explain why a specific piece of code violates a principle — or more importantly, fix it. This post skips the definitions and goes straight to real code: what the violation looks like, why it causes problems, and the refactored version.

Table of Contents

Single Responsibility Principle

Definition: A class should have only one reason to change.

“One reason to change” means one stakeholder who can demand modifications. If a class handles both business logic and persistence, two different teams own it — and changes from either can break the other.

Violation

// Bad: UserService does too many things
public class UserService
{
    private readonly string _connectionString;

    public UserService(string connectionString)
    {
        _connectionString = connectionString;
    }

    public User GetById(int id)
    {
        using var conn = new SqlConnection(_connectionString);
        // database query logic
        return new User();
    }

    public void Register(User user)
    {
        // validation
        if (string.IsNullOrEmpty(user.Email))
            throw new ArgumentException("Email required");

        // persistence
        using var conn = new SqlConnection(_connectionString);
        // insert logic

        // email notification
        var smtpClient = new SmtpClient("smtp.example.com");
        smtpClient.Send("noreply@example.com", user.Email, "Welcome!", "Thanks for joining.");
    }
}

Three reasons to change: database structure changes, validation rules change, email provider changes. Any one of them touches this class.

Fixed

// Each class has one job
public class UserRepository
{
    private readonly string _connectionString;
    public UserRepository(string connectionString) => _connectionString = connectionString;

    public User? GetById(int id) { /* DB logic */ return null; }
    public void Save(User user) { /* insert/update */ }
}

public class UserValidator
{
    public void Validate(User user)
    {
        if (string.IsNullOrEmpty(user.Email))
            throw new ArgumentException("Email is required");
        if (!user.Email.Contains('@'))
            throw new ArgumentException("Invalid email format");
    }
}

public class EmailNotificationService
{
    public void SendWelcome(User user)
    {
        // email-only logic
    }
}

public class UserRegistrationService
{
    private readonly UserRepository _repo;
    private readonly UserValidator _validator;
    private readonly EmailNotificationService _email;

    public UserRegistrationService(
        UserRepository repo,
        UserValidator validator,
        EmailNotificationService email)
    {
        _repo = repo;
        _validator = validator;
        _email = email;
    }

    public void Register(User user)
    {
        _validator.Validate(user);
        _repo.Save(user);
        _email.SendWelcome(user);
    }
}

Now each class changes for exactly one reason. Unit testing each piece independently becomes trivial.

Open/Closed Principle

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

You should be able to add new behavior without editing existing tested code. If adding a new payment method means editing a payment processor class, that class is not closed for modification.

Violation

// Bad: every new payment type requires modifying this class
public class PaymentProcessor
{
    public void Process(PaymentRequest request)
    {
        if (request.Method == "CreditCard")
        {
            // credit card logic
        }
        else if (request.Method == "PayPal")
        {
            // PayPal logic
        }
        else if (request.Method == "Crypto")
        {
            // crypto logic — added a month ago, broke the credit card tests
        }
        // Adding "BankTransfer" means touching this switch again
    }
}

Fixed

// Abstract the extension point
public interface IPaymentHandler
{
    bool CanHandle(string method);
    void Process(PaymentRequest request);
}

public class CreditCardPaymentHandler : IPaymentHandler
{
    public bool CanHandle(string method) => method == "CreditCard";
    public void Process(PaymentRequest request) { /* credit card logic */ }
}

public class PayPalPaymentHandler : IPaymentHandler
{
    public bool CanHandle(string method) => method == "PayPal";
    public void Process(PaymentRequest request) { /* PayPal logic */ }
}

// PaymentProcessor never changes — just register a new IPaymentHandler
public class PaymentProcessor
{
    private readonly IEnumerable<IPaymentHandler> _handlers;

    public PaymentProcessor(IEnumerable<IPaymentHandler> handlers)
    {
        _handlers = handlers;
    }

    public void Process(PaymentRequest request)
    {
        var handler = _handlers.FirstOrDefault(h => h.CanHandle(request.Method))
            ?? throw new NotSupportedException($"Payment method '{request.Method}' not supported");

        handler.Process(request);
    }
}

Adding “BankTransfer” means creating BankTransferPaymentHandler and registering it in the DI container. Zero changes to existing code.

Liskov Substitution Principle

Definition: Subtypes must be substitutable for their base types without altering program correctness.

If you need to check the runtime type before calling a method, LSP is violated. The classic example is the Rectangle/Square problem.

Violation

public class Rectangle
{
    public virtual int Width { get; set; }
    public virtual int Height { get; set; }
    public int Area() => Width * Height;
}

public class Square : Rectangle
{
    // Square "fixes" the inconsistency by setting both dimensions
    public override int Width
    {
        get => base.Width;
        set { base.Width = value; base.Height = value; }
    }
    public override int Height
    {
        get => base.Height;
        set { base.Height = value; base.Width = value; }
    }
}

// This method works for Rectangle but silently breaks for Square
public void Resize(Rectangle rect)
{
    rect.Width = 10;
    rect.Height = 5;
    // Expected area: 50
    // Actual area when rect is a Square: 25 (last assignment wins)
    Console.WriteLine(rect.Area()); // 25 if Square — wrong!
}

Fixed

// Don't inherit — use a common abstraction
public interface IShape
{
    int Area();
}

public class Rectangle : IShape
{
    public int Width { get; init; }
    public int Height { get; init; }
    public int Area() => Width * Height;
}

public class Square : IShape
{
    public int Side { get; init; }
    public int Area() => Side * Side;
}

// Code works correctly for any IShape
public void PrintArea(IShape shape)
{
    Console.WriteLine($"Area: {shape.Area()}");
}

When you find yourself writing if (obj is Square) inside a method that accepts Rectangle, stop — that’s the LSP smell.

Real-World LSP Violation: Throwing in Overrides

// Common violation pattern — base promises a return; derived throws
public class ReadOnlyCollection<T> : Collection<T>
{
    public override void Add(T item)
    {
        throw new NotSupportedException(); // Breaks callers expecting Add to work
    }
}

This is why IReadOnlyList<T> and IList<T> are separate interfaces in .NET rather than ReadOnlyList inheriting from List.

Interface Segregation Principle

Definition: Clients should not be forced to depend on methods they do not use.

Fat interfaces force implementors to provide stub implementations for methods they don’t support — the classic sign of an ISP violation.

Violation

// Fat interface — not every document can do everything
public interface IDocument
{
    void Open();
    void Close();
    void Save();
    void Print();
    void Scan();       // Printers can't scan? Now they throw NotSupportedException
    void SendByFax();  // Fax? Really?
    void Export(string format);
}

// Forced to implement everything even though it can't
public class BasicPrinter : IDocument
{
    public void Print() { /* actual print logic */ }
    public void Open() { /* ok */ }
    public void Close() { /* ok */ }

    // Can't do these — forced stubs
    public void Save() => throw new NotSupportedException();
    public void Scan() => throw new NotSupportedException();
    public void SendByFax() => throw new NotSupportedException();
    public void Export(string format) => throw new NotSupportedException();
}

Fixed

// Split into focused interfaces
public interface IOpenable
{
    void Open();
    void Close();
}

public interface ISaveable
{
    void Save();
    void Export(string format);
}

public interface IPrintable
{
    void Print();
}

public interface IScannable
{
    void Scan();
}

// Each class implements only what it supports
public class BasicPrinter : IOpenable, IPrintable
{
    public void Open() { }
    public void Close() { }
    public void Print() { /* real logic */ }
}

public class AllInOneDevice : IOpenable, IPrintable, IScannable, ISaveable
{
    public void Open() { }
    public void Close() { }
    public void Print() { }
    public void Scan() { }
    public void Save() { }
    public void Export(string format) { }
}

Real-world guideline: if you’re writing throw new NotSupportedException() in an interface implementation, your interface is too fat.

Dependency Inversion Principle

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

This is the principle that makes dependency injection make sense. It’s not just about injecting classes — it’s about depending on interfaces so high-level business logic doesn’t couple to infrastructure details.

Violation

// Bad: OrderService is tightly coupled to SQL Server
public class OrderService
{
    // Direct dependency on concrete infrastructure
    private readonly SqlOrderRepository _repository = new SqlOrderRepository();
    private readonly SmtpEmailSender _emailSender = new SmtpEmailSender("smtp.example.com");

    public void PlaceOrder(Order order)
    {
        _repository.Save(order);
        _emailSender.Send(order.CustomerEmail, "Order Confirmed", BuildEmailBody(order));
    }
}

Can’t test without a real SQL Server connection. Can’t swap to SendGrid without changing OrderService. Business logic is coupled to infrastructure.

Fixed

// Define abstractions (interfaces) that both sides depend on
public interface IOrderRepository
{
    void Save(Order order);
    Order? GetById(int id);
}

public interface IEmailSender
{
    void Send(string to, string subject, string body);
}

// High-level module depends on abstractions only
public class OrderService
{
    private readonly IOrderRepository _repository;
    private readonly IEmailSender _emailSender;

    public OrderService(IOrderRepository repository, IEmailSender emailSender)
    {
        _repository = repository;
        _emailSender = emailSender;
    }

    public void PlaceOrder(Order order)
    {
        _repository.Save(order);
        _emailSender.Send(order.CustomerEmail, "Order Confirmed", BuildEmailBody(order));
    }
}

// Low-level modules implement the abstractions
public class SqlOrderRepository : IOrderRepository
{
    public void Save(Order order) { /* SQL logic */ }
    public Order? GetById(int id) { /* SQL query */ return null; }
}

public class SendGridEmailSender : IEmailSender
{
    public void Send(string to, string subject, string body) { /* SendGrid API call */ }
}
// Registration in ASP.NET Core / MAUI / any DI container
builder.Services.AddScoped<IOrderRepository, SqlOrderRepository>();
builder.Services.AddSingleton<IEmailSender, SendGridEmailSender>();
builder.Services.AddScoped<OrderService>();

Now OrderService tests use mock implementations. Switching from SQL Server to MongoDB means creating a new IOrderRepository implementation and changing one DI registration. [INTERNAL_LINK: Dependency injection in ASP.NET Core guide]

Using SOLID Together

The principles compound. A class that follows SRP is easier to make OCP-compliant. Classes that follow DIP are easier to test for LSP violations. Here’s a quick smell checklist:

  • SRP violation: class name contains “And” (UserValidatorAndEmailSender), or the class has more than 2–3 constructor dependencies
  • OCP violation: adding a new feature type requires modifying a switch/if-else chain in existing code
  • LSP violation: override methods throw NotSupportedException, or callers check is type before calling base methods
  • ISP violation: interface has methods that some implementors stub out with exceptions or empty bodies
  • DIP violation: class uses new ConcreteClass() inside its body rather than receiving dependencies

FAQ

Is it worth applying SOLID to small projects or scripts?

For throwaway scripts: no. For anything that will be maintained, extended, or tested by more than one person: yes. The overhead of applying SOLID upfront is far less than the cost of refactoring a tightly-coupled codebase six months later.

Does SOLID conflict with YAGNI (You Aren’t Gonna Need It)?

Somewhat. YAGNI says don’t build abstractions for hypothetical future requirements. Apply OCP and DIP at natural extension points (payment processors, notification channels, persistence layers) — not everywhere. Don’t create interfaces for classes that will never have more than one implementation.

How many constructor parameters is too many (SRP indicator)?

More than 3–4 dependencies typically signals SRP violation. A class with 7 constructor parameters likely has too many responsibilities. Split it before it grows further.

Does .NET’s built-in DI enforce DIP?

The DI container makes DIP easy to implement but doesn’t enforce it. You can still register concrete types directly and skip interfaces. DIP is a design discipline — the container is just the mechanism for delivering it.

Should every class have an interface (DIP)?

No. Create interfaces when you need multiple implementations, when you need to mock for testing, or when you’re crossing architectural boundaries (infrastructure ↔ domain). Internal implementation classes within a layer often don’t need interfaces.

Conclusion

SOLID isn’t a checklist — it’s a set of heuristics for writing code that stays manageable as it grows. The violations shown here aren’t academic edge cases; they appear in most production codebases. Recognizing them is the first step.

Start with DIP and SRP — they deliver the most immediate payoff in testability and changeability. OCP follows naturally once your dependencies are properly inverted. LSP and ISP are more subtle but become obvious when you find yourself writing NotSupportedException or checking is types.

[INTERNAL_LINK: Clean Architecture in ASP.NET Core] [INTERNAL_LINK: Dependency injection in .NET complete guide]


Leave a Reply

Your email address will not be published. Required fields are marked *