.NET & C# Development · Lesson 55 of 92

SOLID Principles in Real .NET Code (Not Just Slides)

The Context: OrderFlow API

All examples come from a simplified order management API with commands, payment processing, and notifications. No bank account shapes. No animal hierarchies.


S — Single Responsibility Principle

One reason to change. If your class changes for two different reasons, it has two responsibilities.

Before:

C#
// Bad: handles business logic AND sends email AND logs
public class OrderService
{
    public async Task PlaceOrderAsync(PlaceOrderCommand cmd)
    {
        // 1. Business logic
        var order = new Order(cmd.CustomerId, cmd.Items);
        await _repository.AddAsync(order);

        // 2. Email — changes when template changes
        await _emailClient.SendAsync(cmd.Email, "Order Confirmation", BuildEmailBody(order));

        // 3. Logging — changes when log format changes
        _logger.LogInformation("Order {Id} placed at {Time}", order.Id, DateTime.UtcNow);
    }
}

After — split into a thin handler that orchestrates, and separate services:

C#
// Handler: only knows about the order creation sequence
public class PlaceOrderHandler : IRequestHandler<PlaceOrderCommand, OrderId>
{
    private readonly IOrderRepository _orders;
    private readonly IPublisher _publisher;

    public PlaceOrderHandler(IOrderRepository orders, IPublisher publisher)
    {
        _orders    = orders;
        _publisher = publisher;
    }

    public async Task<OrderId> Handle(PlaceOrderCommand cmd, CancellationToken ct)
    {
        var order = new Order(cmd.CustomerId, cmd.Items);
        await _orders.AddAsync(order, ct);
        await _publisher.Publish(new OrderPlacedEvent(order.Id, cmd.Email), ct);
        return order.Id;
    }
}

// Email handler — only knows about sending confirmations, changes with email logic
public class SendOrderConfirmationHandler : INotificationHandler<OrderPlacedEvent>
{
    public async Task Handle(OrderPlacedEvent notification, CancellationToken ct)
        => await _emailService.SendOrderConfirmationAsync(notification.Email, notification.OrderId, ct);
}

Each class has exactly one axis of change.


O — Open/Closed Principle

Open for extension, closed for modification. Add new behavior without touching existing code.

Adding a new payment method should not require touching PaymentService.

Before:

C#
// Bad: every new payment method requires modifying this switch
public class PaymentService
{
    public Task<PaymentResult> ProcessAsync(PaymentRequest request)
    {
        return request.Method switch
        {
            "stripe"  => ProcessStripeAsync(request),
            "paypal"  => ProcessPayPalAsync(request),
            "crypto"  => throw new NotImplementedException(), // always coming "soon"
            _         => throw new InvalidOperationException("Unknown payment method.")
        };
    }
}

After — introduce an abstraction and let DI wire in new providers:

C#
// Abstraction
public interface IPaymentProvider
{
    string Method { get; }
    Task<PaymentResult> ProcessAsync(PaymentRequest request, CancellationToken ct);
}

// Existing providers — never modified when new ones are added
public class StripePaymentProvider : IPaymentProvider
{
    public string Method => "stripe";
    public async Task<PaymentResult> ProcessAsync(PaymentRequest request, CancellationToken ct) { ... }
}

public class PayPalPaymentProvider : IPaymentProvider
{
    public string Method => "paypal";
    public async Task<PaymentResult> ProcessAsync(PaymentRequest request, CancellationToken ct) { ... }
}

// New provider: zero changes to existing code
public class CryptoPaymentProvider : IPaymentProvider
{
    public string Method => "crypto";
    public async Task<PaymentResult> ProcessAsync(PaymentRequest request, CancellationToken ct) { ... }
}

// Dispatcher — also unchanged when providers are added
public class PaymentService
{
    private readonly Dictionary<string, IPaymentProvider> _providers;

    public PaymentService(IEnumerable<IPaymentProvider> providers)
        => _providers = providers.ToDictionary(p => p.Method);

    public Task<PaymentResult> ProcessAsync(PaymentRequest request, CancellationToken ct)
    {
        if (!_providers.TryGetValue(request.Method, out var provider))
            throw new InvalidOperationException($"No provider for method '{request.Method}'.");

        return provider.ProcessAsync(request, ct);
    }
}

Register all providers once:

C#
builder.Services.AddScoped<IPaymentProvider, StripePaymentProvider>();
builder.Services.AddScoped<IPaymentProvider, PayPalPaymentProvider>();
builder.Services.AddScoped<IPaymentProvider, CryptoPaymentProvider>();
builder.Services.AddScoped<PaymentService>();

L — Liskov Substitution Principle

Subtypes must be substitutable for their base type without the caller caring which concrete type it has.

Violation — ReadOnlyOrderRepository throws on a write method from the interface:

C#
// Bad: subtype breaks the contract
public class ReadOnlyOrderRepository : IOrderRepository
{
    public Task<Order?> GetByIdAsync(int id) => _db.Orders.FindAsync(id).AsTask();

    public Task AddAsync(Order order)
        => throw new NotSupportedException("This repository is read-only."); // LSP violation
}

Fix — split the interface (see ISP below), so ReadOnlyOrderRepository never implements a method it can't support:

C#
public interface IOrderReadRepository
{
    Task<Order?> GetByIdAsync(int id, CancellationToken ct);
    Task<IReadOnlyList<Order>> GetByCustomerAsync(int customerId, CancellationToken ct);
}

public interface IOrderWriteRepository
{
    Task AddAsync(Order order, CancellationToken ct);
    Task UpdateAsync(Order order, CancellationToken ct);
}

// Read-only implementation: only implements what it can support
public class ReadOnlyOrderRepository : IOrderReadRepository { ... }

// Full implementation: substitutable anywhere IOrderReadRepository is expected
public class OrderRepository : IOrderReadRepository, IOrderWriteRepository { ... }

I — Interface Segregation Principle

Clients should not depend on methods they don't use. Fat interfaces force classes to implement no-ops or throw.

Before:

C#
// Bad: everything gets all these methods even if it needs one
public interface INotificationService
{
    Task SendEmailAsync(string to, string subject, string body);
    Task SendSmsAsync(string phone, string message);
    Task SendPushAsync(string deviceToken, string message);
    Task SendSlackAsync(string channel, string message);
}

// SMS service forced to implement irrelevant methods
public class SmsNotificationService : INotificationService
{
    public Task SendSmsAsync(string phone, string message) { ... }
    public Task SendEmailAsync(...)  => throw new NotImplementedException();
    public Task SendPushAsync(...)   => throw new NotImplementedException();
    public Task SendSlackAsync(...) => throw new NotImplementedException();
}

After:

C#
public interface IEmailSender  { Task SendEmailAsync(string to, string subject, string body, CancellationToken ct); }
public interface ISmsSender    { Task SendSmsAsync(string phone, string message, CancellationToken ct); }
public interface IPushSender   { Task SendPushAsync(string deviceToken, string message, CancellationToken ct); }
public interface ISlackSender  { Task SendSlackAsync(string channel, string message, CancellationToken ct); }

// Each class implements only what it does
public class SmsNotificationService : ISmsSender { ... }
public class EmailNotificationService : IEmailSender { ... }

// Composed service if you need multi-channel
public class OrderNotifier
{
    private readonly IEmailSender _email;
    private readonly ISmsSender   _sms;

    public OrderNotifier(IEmailSender email, ISmsSender sms)
    {
        _email = email;
        _sms   = sms;
    }
}

D — Dependency Inversion Principle

Depend on abstractions, not concretions. High-level modules (business logic) should not depend on low-level modules (EF Core, SMTP clients). Both depend on interfaces.

ASP.NET Core's DI container is exactly this principle at the infrastructure level.

Before:

C#
// Bad: OrderService creates its own dependencies — can't be unit tested, can't swap implementations
public class OrderService
{
    private readonly AppDbContext _db = new AppDbContext(new DbContextOptions<AppDbContext>());
    private readonly SmtpEmailClient _email = new SmtpEmailClient("smtp.company.com", 587);
}

After:

C#
// Good: depends on interfaces, injected by the container
public class OrderService
{
    private readonly IOrderRepository _orders;
    private readonly IEmailSender     _email;

    public OrderService(IOrderRepository orders, IEmailSender email)
    {
        _orders = orders;
        _email  = email;
    }
}

// Registrations in Program.cs — the composition root
builder.Services.AddScoped<IOrderRepository, EfOrderRepository>();
builder.Services.AddScoped<IEmailSender, SmtpEmailSender>();

// In tests — swap for fakes without changing OrderService at all
services.AddScoped<IOrderRepository, InMemoryOrderRepository>();
services.AddScoped<IEmailSender, FakeEmailSender>();

The key insight: OrderService has no using directive for Entity Framework or any SMTP library. It only knows about interfaces defined in the domain layer.


When NOT to Follow SOLID

SOLID is a tool, not a religion. Over-engineering is real:

  • Single endpoint microservice: SRP through 5 layers of abstraction for GET /health is absurd
  • Throwaway scripts and migration utilities: interfaces and DI add zero value in a 50-line script
  • CRUD endpoints with no business logic: a repository that just proxies EF Core is often fine to use directly
  • Small teams, early-stage products: premature abstraction makes code harder to read and change, not easier

The signal to apply SOLID: you've already felt the pain of not having it — tests are hard to write, adding a new case requires touching five files, or bugs keep appearing in unrelated places when you change something.

Key Takeaways

  • SRP: each class changes for one reason — split by axis of change, not by type of code
  • OCP: use a strategy/provider pattern behind an interface; registration in the DI container is the extension point
  • LSP: if a subtype can't honour the interface contract, split the interface
  • ISP: small, focused interfaces beat one large fat interface — compose them where needed
  • DIP: depend on abstractions; the DI container is your composition root — production and test code only swap registrations