SOLID Principles

Master the five SOLID principles of object-oriented design to write maintainable, flexible, and scalable C# code

Design Patterns
SOLID
Architecture
Best Practices
OOP
20 min read

Overview

SOLID principles are fundamental to writing professional C# code and are frequently discussed in technical interviews, especially for mid-level and senior positions. Understanding not just what they are, but how to apply them in real scenarios, is essential.

Core Concepts

Single Responsibility Principle (SRP)

A class should have only one reason to change. This means a class should have only one job or responsibility.

// Bad - multiple responsibilities
public class Report
{
    public string Generate(List<Order> orders)
    {
        // Calculate totals
        var total = orders.Sum(o => o.Total);

        // Format report
        var report = $"Total: {total:C}\n";
        report += string.Join("\n", orders.Select(o => o.ToString()));

        // Save to file
        File.WriteAllText("report.txt", report);

        // Send email
        var smtp = new SmtpClient();
        smtp.Send("admin@company.com", "Report", report);

        return report;
    }
}

// Good - single responsibilities
public class ReportGenerator
{
    public ReportData Generate(List<Order> orders)
    {
        return new ReportData
        {
            Total = orders.Sum(o => o.Total),
            Orders = orders
        };
    }
}

public class ReportFormatter
{
    public string Format(ReportData data)
    {
        var report = $"Total: {data.Total:C}\n";
        report += string.Join("\n", data.Orders.Select(o => o.ToString()));
        return report;
    }
}

public class ReportSaver
{
    public void Save(string content, string filePath)
    {
        File.WriteAllText(filePath, content);
    }
}

public class ReportEmailer
{
    private readonly IEmailService _emailService;

    public async Task SendAsync(string content, string recipient)
    {
        await _emailService.SendAsync(recipient, "Report", content);
    }
}

Open/Closed Principle (OCP)

Classes should be open for extension but closed for modification. Use inheritance or composition to add functionality.

// Bad - must modify class to add new discount types
public class PriceCalculator
{
    public decimal Calculate(Product product, string discountType)
    {
        if (discountType == "Percentage")
            return product.Price * 0.9m;
        else if (discountType == "Fixed")
            return product.Price - 10;
        else if (discountType == "BOGO")  // Modifying existing code!
            return product.Price * 0.5m;

        return product.Price;
    }
}

// Good - extend through abstraction
public interface IDiscountStrategy
{
    decimal Apply(decimal price);
}

public class PercentageDiscount : IDiscountStrategy
{
    private readonly decimal _percentage;

    public PercentageDiscount(decimal percentage)
    {
        _percentage = percentage;
    }

    public decimal Apply(decimal price) => price * (1 - _percentage);
}

public class FixedDiscount : IDiscountStrategy
{
    private readonly decimal _amount;

    public FixedDiscount(decimal amount)
    {
        _amount = amount;
    }

    public decimal Apply(decimal price) => Math.Max(0, price - _amount);
}

public class BOGODiscount : IDiscountStrategy  // New class, no modifications
{
    public decimal Apply(decimal price) => price * 0.5m;
}

public class PriceCalculator
{
    public decimal Calculate(Product product, IDiscountStrategy discount)
    {
        return discount.Apply(product.Price);
    }
}

Liskov Substitution Principle (LSP)

Objects of a superclass should be replaceable with objects of its subclasses without breaking the application.

// Bad - violates LSP
public class Rectangle
{
    public virtual int Width { get; set; }
    public virtual int Height { get; set; }

    public int GetArea() => Width * Height;
}

public class Square : Rectangle
{
    public override int Width
    {
        get => base.Width;
        set
        {
            base.Width = value;
            base.Height = value;  // Side effect!
        }
    }

    public override int Height
    {
        get => base.Height;
        set
        {
            base.Height = value;
            base.Width = value;  // Side effect!
        }
    }
}

// Problem:
void TestArea(Rectangle rect)
{
    rect.Width = 5;
    rect.Height = 4;
    Assert.Equal(20, rect.GetArea());  // Fails for Square!
}

// Good - proper abstraction
public interface IShape
{
    int GetArea();
}

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

    public int GetArea() => Width * Height;
}

public class Square : IShape
{
    public int Size { get; set; }

    public int GetArea() => Size * Size;
}

Interface Segregation Principle (ISP)

Clients should not be forced to depend on interfaces they don't use.

// Bad - fat interface
public interface IWorker
{
    void Work();
    void Eat();
    void Sleep();
}

public class Human : IWorker
{
    public void Work() { /* ... */ }
    public void Eat() { /* ... */ }
    public void Sleep() { /* ... */ }
}

public class Robot : IWorker
{
    public void Work() { /* ... */ }
    public void Eat() { throw new NotImplementedException(); }  // Forced to implement!
    public void Sleep() { throw new NotImplementedException(); }  // Forced to implement!
}

// Good - segregated interfaces
public interface IWorkable
{
    void Work();
}

public interface IFeedable
{
    void Eat();
}

public interface ISleepable
{
    void Sleep();
}

public class Human : IWorkable, IFeedable, ISleepable
{
    public void Work() { /* ... */ }
    public void Eat() { /* ... */ }
    public void Sleep() { /* ... */ }
}

public class Robot : IWorkable
{
    public void Work() { /* ... */ }  // Only implements what it needs
}

Dependency Inversion Principle (DIP)

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

// Bad - high-level depends on low-level
public class EmailService  // Low-level
{
    public void SendEmail(string to, string message)
    {
        // SMTP logic
    }
}

public class UserService  // High-level
{
    private readonly EmailService _emailService = new EmailService();  // Direct dependency!

    public void RegisterUser(User user)
    {
        // Save user
        _emailService.SendEmail(user.Email, "Welcome!");
    }
}

// Good - both depend on abstraction
public interface IMessageService  // Abstraction
{
    Task SendAsync(string to, string message);
}

public class EmailService : IMessageService  // Low-level
{
    public Task SendAsync(string to, string message)
    {
        // SMTP logic
        return Task.CompletedTask;
    }
}

public class SmsService : IMessageService  // Another low-level implementation
{
    public Task SendAsync(string to, string message)
    {
        // SMS logic
        return Task.CompletedTask;
    }
}

public class UserService  // High-level
{
    private readonly IMessageService _messageService;

    public UserService(IMessageService messageService)  // Depends on abstraction
    {
        _messageService = messageService;
    }

    public async Task RegisterUserAsync(User user)
    {
        // Save user
        await _messageService.SendAsync(user.Email, "Welcome!");
    }
}

Interview Tips

Tip 1: Use real-world examples when explaining SOLID. Don't just recite definitions - show code violations and fixes.

Tip 2: Know that SOLID principles sometimes conflict. Be ready to discuss trade-offs and when to pragmatically bend rules.

Tip 3: Connect SOLID to practical benefits: testability, maintainability, flexibility. Interviewers care about outcomes, not just theory.

Common Interview Questions

  1. Explain SOLID principles with examples.

    • See detailed explanations above. Focus on one principle at a time with before/after code.
  2. How does SRP relate to microservices?

    • SRP at class level → one reason to change. Microservices apply SRP at service level → one business capability per service.
  3. When might you violate SOLID principles?

    • Pragmatism: simple CRUD apps, prototypes, performance-critical code. Over-engineering is also a problem.
  4. How does DIP enable testability?

    • Depending on abstractions (interfaces) allows injecting mocks/fakes in tests instead of real implementations.
  5. What's the relationship between OCP and the Strategy pattern?

    • Strategy pattern is an implementation of OCP - add new strategies (extensions) without modifying existing code.
  6. How do you identify SRP violations?

    • Look for: multiple reasons to change, large classes (>300 lines), words like "And" in class names (UserAndEmailService).
  7. Can you have too many interfaces (ISP)?

    • Yes, over-segregation creates complexity. Balance: cohesive interfaces without forcing unnecessary dependencies.