No history yet

SOLID Principles Revisited

Beyond the Acronym

You already know what SOLID stands for. The five principles of object-oriented design are a staple of software engineering. But memorizing the acronym is different from applying the principles to build systems that don't crumble under their own weight. The real value of SOLID is architectural. These aren't just rules for classes; they are strategies for fighting and fragility in large-scale applications.

Rigidity is when a simple change requires a cascade of other changes throughout the system. Fragility is when a change in one area breaks seemingly unrelated parts of the application. SOLID principles, when applied thoughtfully, help you build systems that are resilient, maintainable, and testable.

Single Responsibility at Scale

The Single Responsibility Principle (SRP) states that a class should have only one reason to change. In an enterprise context, a "reason to change" often maps directly to a business capability. Think about an e-commerce system. Processing an order, managing user profiles, and handling inventory are all distinct business concerns.

If one massive class handles all of these, a change to the inventory logic could inadvertently break order processing. This is where SRP scales up from a class-level guideline to an architectural pattern. The philosophy behind microservices is essentially SRP applied to an entire system. Each service owns a single business capability, has its own data, and can be developed, deployed, and scaled independently.

A change to the payment processing logic shouldn't require you to redeploy the product catalog service. By isolating responsibilities, you isolate change.

// Violates SRP: Multiple reasons to change
public class MonolithicOrderService
{
    public void ProcessOrder(Order order)
    {
        // 1. Business logic for processing the order
        Console.WriteLine("Processing order...");

        // 2. Responsibility: Inventory management
        UpdateInventory(order.ProductId);

        // 3. Responsibility: Sending notifications
        SendOrderConfirmationEmail(order.CustomerEmail);
    }

    private void UpdateInventory(int productId) { /* ... */ }
    private void SendOrderConfirmationEmail(string email) { /* ... */ }
}

Following SRP, you would decompose this. The OrderProcessor would focus solely on order logic and then publish an event, like OrderPlaced. An InventoryService and a NotificationService would listen for this event and perform their respective tasks. The components are now decoupled and have a single, clear responsibility.

Extending Without Breaking

The Open/Closed Principle (OCP) is crucial for building stable systems. It advises that software entities should be open for extension but closed for modification. Every time you modify existing, tested code, you risk introducing bugs. Extending it with new code is far safer.

In C#, interfaces are the primary tool for achieving this. Imagine a reporting module that needs to export data in different formats. Instead of a giant switch statement that you have to modify for every new format, you can define an interface.

public interface IReportExporter
{
    void Export(ReportData data);
}

public class PdfExporter : IReportExporter
{
    public void Export(ReportData data) => Console.WriteLine("Exporting to PDF...");
}

public class CsvExporter : IReportExporter
{
    public void Export(ReportData data) => Console.WriteLine("Exporting to CSV...");
}

// This class is now closed for modification.
// To add a new format, you just create a new class.
public class ReportGenerator
{
    public void Generate(ReportData data, IReportExporter exporter)
    {
        // ... logic to prepare data
        exporter.Export(data);
    }
}

To add an XML exporter, you simply create a new XmlExporter class that implements IReportExporter. The ReportGenerator class never needs to change. Extension methods in C# also provide a way to adhere to OCP by allowing you to add new functions to existing types without modifying their source code.

Inheritance and Substitutability

The Liskov Substitution Principle (LSP) demands that objects of a superclass should be replaceable with objects of its subclasses without breaking the application. It’s a contract that ensures inheritance hierarchies are sound.

Violating LSP can lead to subtle, hard-to-find runtime errors. A common violation occurs when a subclass throws an exception that the base class method does not, or when it weakens a precondition. For example, if a Square class inherits from Rectangle and you set the width, you'd expect the height to remain unchanged. But for a square, setting the width must also set the height, breaking the Rectangle's contract. This is a classic, if simple, example of an LSP violation.

In modern C#, LSP's principles are also reflected in features like covariance and contravariance in generics. allows you to use a more derived type than originally specified. For example, you can assign an IEnumerable<string> to a variable of type IEnumerable<object>, because string is a type of object. This works because IEnumerable<T> is covariant (IEnumerable<out T>). This substitution is safe and upholds the spirit of LSP at the type system level.

Lean Interfaces and Inverted Dependencies

The Interface Segregation Principle (ISP) advises that clients should not be forced to depend on interfaces they do not use. It’s about creating small, cohesive interfaces rather than large, monolithic ones. If a class only needs to perform one action from a giant interface, it's still forced to acknowledge the existence of all the other methods, creating unnecessary coupling.

Instead of one IWorker interface with Work(), Eat(), and Sleep() methods, you might create IWorkable, IEatable, and ISleepable. A RobotWorker class would only implement IWorkable.

This leads directly to the Dependency Inversion Principle (DIP). DIP states that high-level modules should not depend on low-level modules; both should depend on abstractions. It also states that abstractions should not depend on details; details should depend on abstractions.

This is often confused with Dependency Injection (DI), but they aren't the same. DIP is the principle; DI is a common pattern for implementing it. In .NET, this is most often achieved using an Inversion of Control (IoC) container, like the one built into Microsoft.Extensions.DependencyInjection.

public interface INotificationService { void Notify(string message); }

public class EmailService : INotificationService { /* ... */ }

// High-level module depends on the abstraction (INotificationService)
public class OrderProcessor
{
    private readonly INotificationService _notifier;

    // The dependency is "injected" via the constructor
    public OrderProcessor(INotificationService notifier)
    {
        _notifier = notifier;
    }

    public void Process()
    {
        // ... process order
        _notifier.Notify("Order processed!");
    }
}

The OrderProcessor doesn't know or care about the concrete EmailService. It only knows about the INotificationService abstraction. The IoC container is responsible for creating the EmailService instance and

SOLID principles are not dogma. They are tools. Used correctly, they help you manage complexity, reduce coupling, and build software that can evolve with business needs instead of collapsing.