← Back to all posts

SOLID Principles in .NET

SOLID Principles in .NET

Reading Time: 5 minutes

Why SOLID Still Matters

The SOLID principles are often introduced early in a developer’s career, but their real value shows up later, when systems become harder to change.

In .NET applications, code tends to start clean and then slowly accumulate:

  • large services
  • tightly coupled dependencies
  • hard-to-test logic
  • ripple-effect changes across multiple classes

SOLID is not about academic perfection. It is about making code easier to:

  • understand
  • test
  • extend
  • maintain

What Does SOLID Stand For?

  • S: Single Responsibility Principle
  • O: Open/Closed Principle
  • L: Liskov Substitution Principle
  • I: Interface Segregation Principle
  • D: Dependency Inversion Principle

Let’s go through each one with a practical .NET mindset.

1. Single Responsibility Principle (SRP)

A class should have one reason to change.

Bad example

public class OrderService
{
    public void CreateOrder(Order order)
    {
        // Save to database
    }

    public void SendConfirmationEmail(Order order)
    {
        // Send email
    }

    public string ExportOrderCsv(Order order)
    {
        // Generate CSV
        return string.Empty;
    }
}

This class handles persistence, notification, and export formatting. Those are separate concerns.

Better example

public class OrderService
{
    private readonly IOrderRepository _orders;

    public OrderService(IOrderRepository orders)
    {
        _orders = orders;
    }

    public Task CreateOrderAsync(Order order)
    {
        return _orders.SaveAsync(order);
    }
}

public class OrderEmailService
{
    public Task SendConfirmationAsync(Order order)
    {
        return Task.CompletedTask;
    }
}

public class OrderCsvExporter
{
    public string Export(Order order)
    {
        return string.Empty;
    }
}

Each class now has a more focused responsibility.

Practical SRP signal

If a class changes for unrelated reasons, it likely violates SRP.

2. Open/Closed Principle (OCP)

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

You should be able to add new behavior without constantly editing existing core logic.

Bad example

public class DiscountService
{
    public decimal Calculate(string customerType, decimal total)
    {
        if (customerType == "Regular") return total;
        if (customerType == "Premium") return total * 0.9m;
        if (customerType == "Vip") return total * 0.8m;

        throw new InvalidOperationException("Unknown customer type");
    }
}

Every new discount rule requires modifying the method.

Better example

public interface IDiscountStrategy
{
    bool CanHandle(string customerType);
    decimal Apply(decimal total);
}

public class PremiumDiscountStrategy : IDiscountStrategy
{
    public bool CanHandle(string customerType) => customerType == "Premium";
    public decimal Apply(decimal total) => total * 0.9m;
}

public class VipDiscountStrategy : IDiscountStrategy
{
    public bool CanHandle(string customerType) => customerType == "Vip";
    public decimal Apply(decimal total) => total * 0.8m;
}

public class DiscountService
{
    private readonly IEnumerable<IDiscountStrategy> _strategies;

    public DiscountService(IEnumerable<IDiscountStrategy> strategies)
    {
        _strategies = strategies;
    }

    public decimal Calculate(string customerType, decimal total)
    {
        var strategy = _strategies.FirstOrDefault(x => x.CanHandle(customerType));
        return strategy?.Apply(total) ?? total;
    }
}

Now new discount types can be added without rewriting the existing service.

3. Liskov Substitution Principle (LSP)

Derived types should be substitutable for their base types without breaking behavior.

A common failure mode is inheritance that looks correct structurally but violates expectations behaviorally.

Bad example

public class Bird
{
    public virtual void Fly()
    {
    }
}

public class Penguin : Bird
{
    public override void Fly()
    {
        throw new NotSupportedException();
    }
}

If callers expect every Bird to be able to Fly, Penguin breaks that contract.

Better example

public abstract class Bird
{
}

public interface IFlyingBird
{
    void Fly();
}

public class Sparrow : Bird, IFlyingBird
{
    public void Fly()
    {
    }
}

public class Penguin : Bird
{
}

Now the model expresses capability correctly.

Practical LSP signal

If a subtype needs to throw NotSupportedException for core inherited behavior, your abstraction is probably wrong.

4. Interface Segregation Principle (ISP)

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

Bad example

public interface IWorker
{
    Task WriteCodeAsync();
    Task TestCodeAsync();
    Task DeployAsync();
    Task PreparePayrollAsync();
}

Most implementations will only use a subset of this interface.

Better example

public interface IDeveloper
{
    Task WriteCodeAsync();
}

public interface ITester
{
    Task TestCodeAsync();
}

public interface IDeployer
{
    Task DeployAsync();
}

public interface IPayrollProcessor
{
    Task PreparePayrollAsync();
}

Small, focused interfaces make implementations cleaner and reduce accidental coupling.

Practical ISP signal

If your implementation has several methods that are empty, throw exceptions, or are never used, the interface is probably too broad.

5. Dependency Inversion Principle (DIP)

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

This is one of the foundations of maintainable .NET architecture and works naturally with the built-in DI container.

Bad example

public class UserService
{
    private readonly SqlUserRepository _repository = new();

    public Task<User?> GetByIdAsync(int id)
    {
        return _repository.GetByIdAsync(id);
    }
}

The service is tightly coupled to one concrete data-access implementation.

Better example

public interface IUserRepository
{
    Task<User?> GetByIdAsync(int id);
}

public class UserService
{
    private readonly IUserRepository _repository;

    public UserService(IUserRepository repository)
    {
        _repository = repository;
    }

    public Task<User?> GetByIdAsync(int id)
    {
        return _repository.GetByIdAsync(id);
    }
}

And register via DI:

builder.Services.AddScoped<IUserRepository, SqlUserRepository>();
builder.Services.AddScoped<UserService>();

This makes testing and replacement much easier.

SOLID in Real ASP.NET Core Applications

In practice, SOLID usually shows up as:

  • controllers staying thin
  • repositories abstracting persistence details
  • strategy/facade/policy classes handling variations in behavior
  • DI used to connect abstractions to implementations
  • cohesive interfaces instead of giant contracts

You do not need to aggressively split every class. The point is not maximum indirection, the point is sustainable change.

Common Misuse of SOLID

SOLID can also be overapplied.

Examples:

  • creating interfaces for every tiny class with no variation or testing need
  • splitting logic so aggressively that it becomes harder to follow
  • using inheritance where composition would be simpler
  • introducing patterns before the problem exists

Good design is about balance, not ceremony.

A Practical Rule of Thumb

Use SOLID when it helps you answer “yes” to these questions:

  • Can I change this behavior without touching unrelated code?
  • Can I test this unit in isolation?
  • Can I replace this dependency without rewriting the caller?
  • Does this abstraction reflect real behavior instead of forcing it?

If yes, you are probably applying the principles well.

Summary

SOLID principles remain useful in modern .NET because they directly improve maintainability.

  • SRP keeps responsibilities focused
  • OCP supports safe extension
  • LSP protects behavioral correctness
  • ISP keeps contracts small and relevant
  • DIP reduces coupling through abstractions

The goal is not to memorize definitions. The goal is to write code that stays understandable and adaptable as the system grows.

References