Applying SOLID and OOP Principles in Modern .NET Development

Building Maintainable Applications with OOP

If you've ever worked on a .NET application that became harder to change over time, you've felt the pain of poor design decisions. Classes grow bloated, dependencies tangle into knots, and small feature requests turn into week-long refactoring projects. SOLID principles and core OOP concepts offer a way out of this mess.

These principles aren't abstract theory—they're practical guidelines that help you write code that's easier to test, extend, and maintain. When you apply Single Responsibility, Open/Closed, Liskov Substitution, Interface Segregation, and Dependency Inversion alongside encapsulation and polymorphism, you create systems that accommodate change instead of resisting it.

You'll learn how to recognize when to use each principle, see concrete C# implementations, and understand which patterns work best for different scenarios. By the end, you'll have patterns you can apply immediately to your .NET projects.

Single Responsibility Principle

The Single Responsibility Principle states that a class should have one reason to change. When a class handles multiple concerns—like business logic, data access, and logging—changes in any area force you to modify and retest everything. Separating these concerns makes each class focused and easier to maintain.

In .NET applications, you'll often see this principle violated when controller classes handle validation, database access, and business rules. Instead, delegate each responsibility to specialized classes. This creates natural boundaries in your code and makes testing straightforward since you can test each concern independently.

OrderService.cs
// Each class has one clear responsibility
public class OrderService
{
    private readonly IOrderRepository _repository;
    private readonly IOrderValidator _validator;
    private readonly INotificationService _notifications;

    public OrderService(IOrderRepository repository,
        IOrderValidator validator, INotificationService notifications)
    {
        _repository = repository;
        _validator = validator;
        _notifications = notifications;
    }

    public async Task CreateOrderAsync(Order order)
    {
        var validationResult = _validator.Validate(order);
        if (!validationResult.IsValid)
            return Result.Failure(validationResult.Errors);

        await _repository.SaveAsync(order);
        await _notifications.NotifyOrderCreatedAsync(order);

        return Result.Success();
    }
}

public class OrderValidator : IOrderValidator
{
    public ValidationResult Validate(Order order)
    {
        var errors = new List();

        if (order.Items == null || !order.Items.Any())
            errors.Add("Order must contain items");

        if (order.Total <= 0)
            errors.Add("Order total must be positive");

        return errors.Any()
            ? ValidationResult.Invalid(errors)
            : ValidationResult.Valid();
    }
}

The OrderService coordinates operations but doesn't handle validation logic or notification details. Each dependency has a single, well-defined purpose. When validation rules change, you modify only OrderValidator. This isolation makes your code predictable and reduces the risk of introducing bugs.

Open/Closed Principle with Polymorphism

Software entities should be open for extension but closed for modification. In .NET, you achieve this through interfaces and abstract classes that let you add new functionality without changing existing code. This principle is particularly valuable when building plugin architectures or systems that need to support multiple implementations.

Rather than using switch statements or if-else chains to handle different types, define an interface and create implementations for each type. When you need to add a new type, create a new class without touching existing ones.

PaymentProcessing.cs
public interface IPaymentProcessor
{
    Task ProcessAsync(decimal amount, PaymentDetails details);
}

public class CreditCardProcessor : IPaymentProcessor
{
    public async Task ProcessAsync(
        decimal amount, PaymentDetails details)
    {
        // Credit card specific logic
        var token = await TokenizeCard(details.CardNumber);
        return await ChargeCard(token, amount);
    }

    private async Task TokenizeCard(string cardNumber) =>
        await Task.FromResult($"token_{cardNumber}");

    private async Task ChargeCard(string token, decimal amount) =>
        await Task.FromResult(PaymentResult.Success(token));
}

public class PayPalProcessor : IPaymentProcessor
{
    public async Task ProcessAsync(
        decimal amount, PaymentDetails details)
    {
        // PayPal specific logic
        var authToken = await AuthenticatePayPal(details.Email);
        return await ChargePayPal(authToken, amount);
    }

    private async Task AuthenticatePayPal(string email) =>
        await Task.FromResult($"auth_{email}");

    private async Task ChargePayPal(string token, decimal amount) =>
        await Task.FromResult(PaymentResult.Success(token));
}

// Adding cryptocurrency support doesn't modify existing processors
public class CryptoProcessor : IPaymentProcessor
{
    public async Task ProcessAsync(
        decimal amount, PaymentDetails details)
    {
        var wallet = await ValidateWallet(details.WalletAddress);
        return await TransferCrypto(wallet, amount);
    }

    private async Task ValidateWallet(string address) =>
        await Task.FromResult($"wallet_{address}");

    private async Task TransferCrypto(string wallet, decimal amount) =>
        await Task.FromResult(PaymentResult.Success(wallet));
}

When you need cryptocurrency support, you add CryptoProcessor without modifying existing payment processors. The consuming code depends on IPaymentProcessor, so it works with any implementation. This extensibility makes your system flexible and reduces regression risk.

Interface Segregation and Dependency Inversion

Interface Segregation states that clients shouldn't depend on interfaces they don't use. Large interfaces force implementers to provide stub methods they don't need. Instead, create smaller, focused interfaces that represent specific capabilities. This pairs naturally with Dependency Inversion, which states that high-level modules shouldn't depend on low-level modules—both should depend on abstractions.

In ASP.NET Core applications, you'll use dependency injection to wire up these abstractions. Define interfaces for your dependencies, register implementations in the DI container, and let the framework handle object creation. This makes your code testable and allows you to swap implementations without changing consumers.

UserManagement.cs
// Small, focused interfaces (Interface Segregation)
public interface IUserReader
{
    Task GetByIdAsync(int id);
    Task> GetAllAsync();
}

public interface IUserWriter
{
    Task CreateAsync(User user);
    Task UpdateAsync(User user);
    Task DeleteAsync(int id);
}

// Implementations depend on abstractions (Dependency Inversion)
public class UserService
{
    private readonly IUserReader _reader;
    private readonly IUserWriter _writer;
    private readonly ILogger _logger;

    public UserService(IUserReader reader, IUserWriter writer,
        ILogger logger)
    {
        _reader = reader;
        _writer = writer;
        _logger = logger;
    }

    public async Task GetUserAsync(int id)
    {
        _logger.LogInformation("Fetching user {UserId}", id);
        return await _reader.GetByIdAsync(id);
    }

    public async Task CreateUserAsync(User user)
    {
        _logger.LogInformation("Creating user {Email}", user.Email);
        return await _writer.CreateAsync(user);
    }
}

// Read-only consumers only need IUserReader
public class UserDisplayService
{
    private readonly IUserReader _reader;

    public UserDisplayService(IUserReader reader)
    {
        _reader = reader; // No write methods forced on us
    }

    public async Task FormatUserListAsync()
    {
        var users = await _reader.GetAllAsync();
        return string.Join("\n", users.Select(u => $"{u.Id}: {u.Name}"));
    }
}

UserDisplayService only depends on IUserReader because it never modifies data. This prevents accidental writes and makes the code's intent clear. When testing, you can mock just the interface you need. The abstractions also let you replace implementations—perhaps swapping a SQL repository for a caching decorator—without changing consumers.

Try It Yourself

Create a console application that demonstrates SOLID principles with a notification system. You'll implement multiple notification channels using the Open/Closed principle and dependency injection to wire them together.

Steps

  1. Init a new console project with dotnet new console -n SolidPrinciples
  2. Open the project folder with cd SolidPrinciples
  3. Replace Program.cs with the implementation below
  4. Update SolidPrinciples.csproj as shown
  5. Execute with dotnet run
SolidPrinciples.csproj
<Project Sdk="Microsoft.NET.Sdk">
  <PropertyGroup>
    <OutputType>Exe</OutputType>
    <TargetFramework>net8.0</TargetFramework>
    <Nullable>enable</Nullable>
    <ImplicitUsings>enable</ImplicitUsings>
  </PropertyGroup>
</Project>
Program.cs
// Interface segregation - focused contracts
public interface INotificationSender
{
    Task SendAsync(string recipient, string message);
}

// Open/Closed - extend with new implementations
public class EmailNotification : INotificationSender
{
    public Task SendAsync(string recipient, string message)
    {
        Console.WriteLine($"[EMAIL] To: {recipient}");
        Console.WriteLine($"        Message: {message}");
        return Task.CompletedTask;
    }
}

public class SmsNotification : INotificationSender
{
    public Task SendAsync(string recipient, string message)
    {
        Console.WriteLine($"[SMS] To: {recipient}");
        Console.WriteLine($"      Message: {message}");
        return Task.CompletedTask;
    }
}

// Single Responsibility - only coordinates sending
public class NotificationService
{
    private readonly List _senders;

    public NotificationService(List senders)
    {
        _senders = senders;
    }

    public async Task NotifyAsync(string recipient, string message)
    {
        foreach (var sender in _senders)
        {
            await sender.SendAsync(recipient, message);
        }
    }
}

// Dependency Inversion - depend on abstractions
var senders = new List
{
    new EmailNotification(),
    new SmsNotification()
};

var service = new NotificationService(senders);
await service.NotifyAsync("user@example.com", "Your order has shipped!");

Console.WriteLine("\nAdding push notification (Open/Closed):");
senders.Add(new PushNotification());
await service.NotifyAsync("user@example.com", "Payment confirmed!");

public class PushNotification : INotificationSender
{
    public Task SendAsync(string recipient, string message)
    {
        Console.WriteLine($"[PUSH] To: {recipient}");
        Console.WriteLine($"       Message: {message}");
        return Task.CompletedTask;
    }
}

Run Result

[EMAIL] To: user@example.com
        Message: Your order has shipped!
[SMS] To: user@example.com
      Message: Your order has shipped!

Adding push notification (Open/Closed):
[EMAIL] To: user@example.com
        Message: Payment confirmed!
[SMS] To: user@example.com
      Message: Payment confirmed!
[PUSH] To: user@example.com
       Message: Payment confirmed!

Choosing the Right Approach

SOLID principles provide guidance, but applying them requires judgment. Overusing abstractions can make simple code unnecessarily complex, while underusing them creates rigid systems. Understanding when to apply each principle helps you balance flexibility with simplicity.

Choose inheritance when you have a clear is-a relationship and the base class represents a stable abstraction that won't change frequently. Inheritance works well for framework extension points where the base class provides template methods or overridable behavior. However, favor composition when you need runtime flexibility or when multiple unrelated classes need to share behavior.

Apply Interface Segregation when your interfaces grow large or when different consumers need different subsets of functionality. Small interfaces make testing easier and reduce coupling. However, don't create an interface for every class—interfaces add value when you genuinely need multiple implementations or want to enable testing through mocking.

Use Dependency Inversion consistently for external dependencies like databases, APIs, and file systems. These boundaries naturally benefit from abstraction. For internal utility classes that are unlikely to change or need substitution, concrete dependencies may be simpler and more maintainable than adding interface overhead.

Quick Answers

When should I favor composition over inheritance in .NET?

Use composition when you need flexible behavior changes at runtime or when inheriting would create tight coupling. Inheritance works best for is-a relationships with stable hierarchies. Composition gives you testability through interface injection and easier refactoring when requirements shift.

How does dependency injection enforce SOLID principles?

DI enforces Dependency Inversion by making classes depend on abstractions instead of concrete types. It supports Open/Closed by letting you swap implementations without modifying consumers. Combined with interfaces, it enables Interface Segregation and Single Responsibility.

What's the difference between encapsulation and abstraction?

Encapsulation hides implementation details using access modifiers and exposes only necessary members. Abstraction defines contracts through interfaces and abstract classes that hide complexity. Use encapsulation for data protection within a class and abstraction to define behavior contracts.

Back to Articles